####################################################################################################
# FastSandPM is a package management and dependency resolution tool for HDL Design and DV projects
# Copyright (C) 2026, Benjamin Davis
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, see
# <https://www.gnu.org/licenses/>.
####################################################################################################
"""Command-line interface for FastSandPM.
This module provides the main entry point for the ``fspm`` command-line tool.
It handles dependency resolution and installation for HDL/RTL projects.
Usage::
fspm [OPTIONS]
Options:
- ``-m, --manifest PATH``: Path to manifest file (default: search up directory tree)
- ``-o, --output PATH``: Output directory for installed libraries (default: ./lib)
- ``-c, --clean``: Clean conflicting directories during installation
- ``--no-clean``: Don't clean conflicting directories (default)
- ``--optional GROUPS``: Comma-separated list of optional dependency groups to install
Example::
# Install dependencies from proj.toml in current or parent directory
fspm
# Install from a specific manifest file
fspm --manifest /path/to/proj.toml
# Install to a custom output directory
fspm --output ./vendor
# Install with optional dependencies
fspm --optional dev,test
# Clean conflicting directories
fspm --clean
"""
from __future__ import annotations
import argparse
import logging
import pathlib
import sys
from fastsandpm._info import __version__
from fastsandpm.install import library_from_manifest
from fastsandpm.manifest import (
MANIFEST_FILENAME,
ManifestNotFoundError,
ManifestParseError,
get_manifest,
)
[docs]
def find_manifest(start_path: pathlib.Path | None = None) -> pathlib.Path:
"""Search up the directory tree to find a manifest file.
Starting from the given path (or current working directory if not provided),
searches up through parent directories until a proj.toml file is found.
Args:
start_path: The directory to start searching from. Defaults to cwd.
Returns:
The path to the directory containing the manifest file.
Raises:
ManifestNotFoundError: If no manifest file is found in any parent directory.
"""
if start_path is None:
start_path = pathlib.Path.cwd()
current = start_path.resolve()
# Search up the directory tree
while True:
manifest_path = current / MANIFEST_FILENAME
if manifest_path.exists() and manifest_path.is_file():
return current
# Move to parent directory
parent = current.parent
if parent == current:
# Reached root without finding manifest
raise ManifestNotFoundError(start_path)
current = parent
[docs]
def create_parser() -> argparse.ArgumentParser:
"""Create the argument parser for the fspm CLI.
Returns:
Configured ArgumentParser instance.
"""
parser = argparse.ArgumentParser(
prog="fspm",
description="FastSandPM - Package manager for HDL Design and DV projects",
epilog="For more information, visit https://fastsandpm.readthedocs.io/",
)
parser.add_argument(
"-V",
"--version",
action="version",
version=f"%(prog)s {__version__}",
)
parser.add_argument(
"-m",
"--manifest",
type=pathlib.Path,
metavar="PATH",
help="Path to manifest file or directory containing it "
f"(default: search up directory tree for {MANIFEST_FILENAME})",
)
parser.add_argument(
"-o",
"--output",
type=pathlib.Path,
default=pathlib.Path("lib"),
metavar="PATH",
help="Output directory for installed libraries (default: ./lib)",
)
# Use mutually exclusive group for clean flags
clean_group = parser.add_mutually_exclusive_group()
clean_group.add_argument(
"-c",
"--clean",
action="store_true",
dest="clean",
help="Clean conflicting directories during installation",
)
clean_group.add_argument(
"--no-clean",
action="store_false",
dest="clean",
help="Don't clean conflicting directories (default)",
)
parser.set_defaults(clean=False)
parser.add_argument(
"--optional",
type=str,
metavar="GROUPS",
help="Comma-separated list of optional dependency groups to install",
)
parser.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="Increase verbosity (can be used multiple times: -v, -vv, -vvv)",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="Suppress all output except errors",
)
return parser
[docs]
def parse_args(args: list[str] | None = None) -> argparse.Namespace:
"""Parse command-line arguments.
Args:
args: Command-line arguments to parse. Defaults to sys.argv[1:].
Returns:
Parsed arguments namespace.
"""
parser = create_parser()
try:
import argcomplete # type: ignore
argcomplete.autocomplete(parser)
except ImportError:
pass
return parser.parse_args(args)
[docs]
def setup_logging(verbose: int, quiet: bool) -> None:
"""Configure logging based on verbosity level.
Args:
verbose: Verbosity level (0=WARNING, 1=INFO, 2+=DEBUG).
quiet: If True, only show ERROR level messages.
"""
if quiet:
level = logging.ERROR
elif verbose == 0:
level = logging.WARNING
elif verbose == 1:
level = logging.INFO
else:
level = logging.DEBUG
logging.basicConfig(
level=level,
format="%(levelname)s: %(message)s",
)
[docs]
def main(args: list[str] | None = None) -> int:
"""Main entry point for the fspm CLI.
Args:
args: Command-line arguments. Defaults to sys.argv[1:].
Returns:
Exit code (0 for success, non-zero for errors).
"""
parsed_args = parse_args(args)
setup_logging(parsed_args.verbose, parsed_args.quiet)
logger = logging.getLogger(__name__)
# Find or resolve manifest path
try:
if parsed_args.manifest is not None:
manifest_path = parsed_args.manifest.resolve()
# If user provided a file path, use its parent directory
if manifest_path.is_file():
manifest_path = manifest_path.parent
else:
# Search up the directory tree for manifest
manifest_path = find_manifest()
logger.info("Found manifest at %s", manifest_path / MANIFEST_FILENAME)
except ManifestNotFoundError as e:
logger.error(
"No %s found in current directory or any parent directory. "
"Use --manifest to specify a path.",
MANIFEST_FILENAME,
)
logger.debug("Search started from: %s", e.path)
return 1
# Load and parse the manifest
try:
manifest = get_manifest(manifest_path)
logger.info(
"Loaded manifest for %s version %s",
manifest.package.name,
manifest.package.version,
)
except ManifestNotFoundError:
logger.error("Manifest file not found at %s", manifest_path / MANIFEST_FILENAME)
return 1
except ManifestParseError as e:
logger.error("Failed to parse manifest: %s", e.reason)
return 1
# Parse optional dependencies
optional_deps: list[str] | None = None
if parsed_args.optional:
optional_deps = [g.strip() for g in parsed_args.optional.split(",") if g.strip()]
logger.info("Including optional dependency groups: %s", ", ".join(optional_deps))
# Resolve output path
output_path = parsed_args.output.resolve()
logger.info("Installing dependencies to %s", output_path)
# Install dependencies
try:
library_from_manifest(
manifest=manifest,
dest=output_path,
optional_deps=optional_deps,
clean=parsed_args.clean,
)
logger.info("Successfully installed dependencies")
except NotImplementedError as e:
logger.error("%s", e)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())