####################################################################################################
# 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/>.
####################################################################################################
"""Module for package manifest handling and parsing.
This module provides data models and functions for working with FastSandPM
manifest files (proj.toml). It handles parsing, validation, and representation
of package metadata, dependencies, and registry configurations.
Classes:
- :py:exc:`~ManifestNotFoundError`: Exception raised when manifest file is not found.
- :py:exc:`~ManifestParseError`: Exception raised when manifest parsing fails.
- :py:class:`~Package`: Package metadata (name, version, description, authors).
- :py:class:`~Dependencies`: Collection of package dependencies.
- :py:class:`~Manifest`: The complete manifest model.
Functions:
- :py:func:`~get_manifest`: Load and parse a manifest from a repository path.
- :py:func:`~get_manifest_from_bytes`: Parse a manifest from raw bytes content.
Constants:
- :py:const:`~MANIFEST_FILENAME`: The default manifest filename ("proj.toml").
Example:
>>> from fastsandpm.manifest import get_manifest
>>> manifest = get_manifest("./my-project")
>>> print(manifest.package.name)
'my-package'
"""
from __future__ import annotations
import os
import pathlib
import tomllib
from typing import Annotated, Any, Self, SupportsIndex
from pydantic import (
BaseModel,
Field,
PlainSerializer,
PlainValidator,
RootModel,
ValidationError,
ValidationInfo,
WithJsonSchema,
field_validator,
model_validator,
)
from fastsandpm.dependencies.requirements import ConcreteRequirement, PathRequirement
from fastsandpm.registries import Registries
from fastsandpm.versioning import LibraryVersion
_Version = Annotated[
LibraryVersion,
PlainValidator(lambda v: LibraryVersion(v), json_schema_input_type=str),
PlainSerializer(lambda v: str(v), return_type=str),
WithJsonSchema({"type": "string"}),
]
[docs]
class ManifestNotFoundError(FileNotFoundError):
"""Raised when a manifest file cannot be found at the specified path."""
def __init__(self, path: pathlib.Path) -> None:
"""Initialize the error with the path that was searched.
Args:
path: The path where the manifest was expected.
"""
self.path = path
super().__init__(f"Manifest file not found: {path / MANIFEST_FILENAME}")
[docs]
class ManifestParseError(ValueError):
"""Raised when a manifest file cannot be parsed."""
def __init__(self, path: pathlib.Path, reason: str) -> None:
"""Initialize the error with the path and reason for failure.
Args:
path: The path to the manifest file.
reason: Description of why parsing failed.
"""
self.path = path
self.reason = reason
super().__init__(f"Failed to parse manifest at {path}: {reason}")
[docs]
class Package(BaseModel):
"""Package metadata from a manifest file.
Contains the core package information including name, version, and
description. Authors and readme path are optional fields.
Example TOML:
.. code-block:: toml
[package]
name = "package_name"
version = "1.2.3-a4"
description = "A sample package"
authors = "Jane Doe <jdoe@doelife.com>"
readme = "README.txt"
See Also:
`Pydantic BaseModel <https://docs.pydantic.dev/latest/api/base_model/>`_
for details on the parent BaseModel class and its methods.
"""
name: str
"""The unique package identifier."""
version: _Version
"""The semantic version of the package."""
description: str = ""
"""A brief description of the package."""
flist: pathlib.Path = Field(
default_factory=lambda data: pathlib.Path(data.get("name", "") + ".f")
)
"""Path to the packages File-List relative to manifest."""
authors: str | list[str] | dict[str, str] | None = None
"""Package authors (string, list, or dict format)."""
readme: pathlib.Path | None = None # TODO: Field(default_factory=_find_readme)
"""Path to the README file relative to manifest."""
[docs]
@field_validator("name")
@classmethod
def validate_name_not_empty(cls, v: str) -> str:
"""Validate that package name is not empty.
Args:
v: The package name to validate.
Returns:
The validated package name.
Raises:
ValueError: If the name is empty or contains only whitespace.
"""
if not v or not v.strip():
raise ValueError("Package name cannot be empty or whitespace-only")
return v
[docs]
class Dependencies(RootModel[list[ConcreteRequirement]]):
"""A collection of package dependencies.
This class handles parsing dependencies from various TOML formats into
the appropriate dependency type objects. It provides list-like access
to the underlying dependency collection.
Supported TOML formats:
- Simple string: ``name = "version"``
- Registry format: ``name = {version = "1.0.0"}``
- Git format: ``name = {git = "url", ...}``
- Path format: ``name = {path = "./local/path"}``
The model validator automatically converts dictionary-style TOML
dependencies into the correct dependency type based on the keys present.
Example TOML:
.. code-block:: toml
[dependencies]
my-lib = "1.0.0"
other_lib = {git = "https://github.com/username/repo.git", tag = "v1.0.0"}
Example Usage:
>>> deps = manifest.dependencies
>>> for dep in deps:
... print(dep.name)
>>> specific_dep = deps.get_by_name("my-lib")
See Also:
`Pydantic RootModel <https://docs.pydantic.dev/latest/api/root_model/>`_
for details on the base class and its methods.
"""
[docs]
@model_validator(mode="before")
@classmethod
def parse_dependencies(cls, data: Any) -> Any:
"""Parse dependency data from various formats.
Handles conversion from TOML-style dependency specifications to
the internal dependency model format.
Args:
data: Raw dependency data, either as a dict (from TOML) or list.
Returns:
A list of dependency dictionaries ready for model instantiation.
Examples:
Input formats supported:
- {"name": "foo", "version": "1.0.0"} -> [{"name": "foo", ...}]
- {"foo": "1.0.0"} -> [{"name": "foo", "version": "1.0.0"}]
- {"foo": {"git": "url"}} -> [{"name": "foo", "git": "url"}]
- {"foo": {"path": "./path"}} -> [{"name": "foo", "path": "./path"}]
"""
if isinstance(data, dict):
# Handle single dependency passed as dict with 'name' key
if "name" in data:
return [data]
# Convert dict-style dependencies to list
new_data = []
for name, spec in data.items():
if isinstance(spec, dict):
# Dict specification: {git: ..., version: ...} or {path: ...}
new_data.append({"name": name, **spec})
elif isinstance(spec, str):
# Simple string specification: "version" -> RegistryDependency
new_data.append({"name": name, "version": spec})
else:
# Pass through as-is (will fail validation if invalid)
new_data.append({"name": name, "version": spec})
return new_data
return data
[docs]
@model_validator(mode="after")
def validate_unique_names(self) -> Self:
"""Validate that all dependency names are unique.
Raises:
ValueError: If duplicate dependency names are found.
Returns:
Self: The validated model instance.
"""
names = [dep.name for dep in self.root]
duplicates = [name for name in names if names.count(name) > 1]
if duplicates:
unique_duplicates = list(set(duplicates))
raise ValueError(f"Duplicate dependency names found: {unique_duplicates}")
return self
def __iter__(self):
"""Iterate over the dependencies.
Returns:
Iterator over the dependency list.
"""
return iter(self.root)
def __len__(self) -> int:
"""Return the number of dependencies.
Returns:
The count of dependencies.
"""
return len(self.root)
def __getitem__(self, index: int) -> ConcreteRequirement:
"""Get a dependency by index.
Args:
index: The index of the dependency to retrieve.
Returns:
The dependency at the specified index.
"""
return self.root[index]
[docs]
def append(self, object: ConcreteRequirement) -> None:
"""Append a dependency to the collection.
Args:
object: The dependency to append to the end of the collection.
"""
self.root.append(object)
[docs]
def insert(self, index: SupportsIndex, object: ConcreteRequirement) -> None:
"""Insert a dependency at a specific position.
Args:
index: The position where the dependency should be inserted.
object: The dependency to insert into the collection.
"""
self.root.insert(index, object)
[docs]
def get_by_name(self, name: str) -> ConcreteRequirement | None:
"""Get a dependency by its name.
Args:
name: The name of the dependency to find.
Returns:
The dependency with the specified name, or None if not found.
"""
for dep in self.root:
if dep.name == name:
return dep
return None
[docs]
class Manifest(BaseModel):
"""The complete package manifest representing a proj.toml file.
A manifest contains all the information needed to build, distribute,
and manage dependencies for a FastSandPM package. It includes package
metadata, required and optional dependencies, and registry configurations.
Example TOML:
.. code-block:: toml
[package]
name = "my-package"
version = "1.0.0"
description = "My HDL package"
[dependencies]
some-lib = "^1.2.0"
[optional_dependencies.dev]
test-lib = "1.0.0"
See Also:
- :class:`Package` for package metadata details.
- :class:`Dependencies` for dependency collection details.
- `Pydantic BaseModel <https://docs.pydantic.dev/latest/api/base_model/>`_
for details on the parent BaseModel class.
"""
package: Package
"""The package metadata (name, version, description)."""
dependencies: Dependencies = Field(default_factory=lambda: Dependencies(list()))
"""Required package dependencies."""
optional_dependencies: dict[str, Dependencies] = Field(default_factory=dict)
"""Named groups of optional dependencies."""
registries: Registries = Field(default_factory=lambda: Registries(list()))
"""Package registries for dependency resolution."""
[docs]
@model_validator(mode="before")
@classmethod
def parse_optional_dependencies(cls, data: Any) -> Any:
"""Parse optional_dependencies from various TOML formats.
Handles conversion from TOML-style optional dependency specifications:
- List format: [optional_dependencies] group = [{name="foo", version="1.0"}]
- Table format: [optional_dependencies.group] foo = "1.0"
Args:
data: Raw manifest data from TOML parsing.
Returns:
The data with optional_dependencies converted to list format.
"""
if not isinstance(data, dict):
return data
if "optional_dependencies" not in data:
return data
opt_deps = data["optional_dependencies"]
if not isinstance(opt_deps, dict):
return data
new_opt_deps: dict[str, list[dict[str, Any]]] = {}
for group_name, group_deps in opt_deps.items():
if isinstance(group_deps, list):
# Already in list format: [{name="foo", version="1.0"}]
new_opt_deps[group_name] = group_deps
elif isinstance(group_deps, dict):
# Table format: {foo = "1.0", bar = {version = "2.0"}}
# Convert to list format
deps_list: list[dict[str, Any]] = []
for dep_name, dep_spec in group_deps.items():
if isinstance(dep_spec, dict):
deps_list.append({"name": dep_name, **dep_spec})
elif isinstance(dep_spec, str):
deps_list.append({"name": dep_name, "version": dep_spec})
else:
deps_list.append({"name": dep_name, "version": dep_spec})
new_opt_deps[group_name] = deps_list
else:
# Pass through as-is (will fail validation if invalid)
new_opt_deps[group_name] = group_deps
data["optional_dependencies"] = new_opt_deps
return data
@model_validator(mode="after")
def _resolve_path_requirement_paths(self, info: ValidationInfo) -> Manifest:
"""Resolve relative paths in PathRequirements to absolute paths.
Creates a new Manifest with all relative path dependencies resolved
relative to the manifest file's directory.
Args:
manifest: The parsed Manifest object.
manifest_dir: The directory containing the manifest file.
Returns:
A new Manifest with resolved path dependencies.
"""
if isinstance(info.context, dict) and "manifest_dir" in info.context:
manifest_dir = pathlib.Path(info.context["manifest_dir"])
else:
# No manifest directory context provided (e.g., loading from bytes)
# Keep relative paths as-is
return self
def resolve_dep(dep: ConcreteRequirement) -> ConcreteRequirement:
"""Resolve paths in a single dependency."""
if isinstance(dep, PathRequirement) and not dep.path.is_absolute():
resolved_path = (manifest_dir / dep.path).resolve()
return dep.model_copy(update={"path": resolved_path})
return dep
# Resolve paths in required dependencies
new_deps = Dependencies([resolve_dep(dep) for dep in self.dependencies])
# Resolve paths in optional dependencies
new_opt_deps: dict[str, Dependencies] = {}
for group_name, deps in self.optional_dependencies.items():
new_opt_deps[group_name] = Dependencies([resolve_dep(dep) for dep in deps])
# Create new manifest with resolved paths
self.dependencies = new_deps
self.optional_dependencies = new_opt_deps
return self
#: The default manifest filename
MANIFEST_FILENAME = "proj.toml"
[docs]
def get_manifest(path: os.PathLike) -> Manifest:
"""Load and parse a manifest from a repository path.
Looks for a `proj.toml` file in the specified directory, parses it,
and returns a Manifest object. Relative paths in path dependencies are
resolved to absolute paths relative to the manifest file's directory.
Args:
path: Path to the repository directory containing the proj.toml file.
Returns:
The parsed Manifest object with resolved path dependencies.
Raises:
ManifestNotFoundError: If the proj.toml file does not exist at the path.
ManifestParseError: If the TOML file is malformed or doesn't match
the expected manifest schema.
Example:
>>> import pathlib
>>> import fastsandpm
>>> manifest = fastsandpm.get_manifest(pathlib.Path("some/repo/path"))
>>> print(manifest.package.name)
'my-package'
"""
if not isinstance(path, pathlib.Path):
path = pathlib.Path(path)
manifest_path = path / MANIFEST_FILENAME
# Check if the manifest file exists
if not manifest_path.exists():
raise ManifestNotFoundError(path)
if not manifest_path.is_file():
raise ManifestParseError(path, f"{MANIFEST_FILENAME} is not a file")
# Read and parse the TOML file
try:
with manifest_path.open("rb") as f:
data = tomllib.load(f)
except tomllib.TOMLDecodeError as e:
raise ManifestParseError(path, f"Invalid TOML syntax: {e}") from e
# Parse the data into a Manifest object
try:
manifest = Manifest.model_validate(data, context={"manifest_dir": path.resolve()})
except ValidationError as e:
raise ManifestParseError(path, str(e)) from e
# Resolve relative paths in path dependencies
return manifest
[docs]
def get_manifest_from_bytes(content: bytes, source: str = "<bytes>") -> Manifest:
"""Parse a manifest from raw bytes content.
This function is useful for parsing manifest content that has been fetched
from a remote source (e.g., via `git archive`) without writing to disk.
Args:
content: The raw bytes content of a proj.toml file.
source: A string identifying the source of the content (for error messages).
Returns:
The parsed Manifest object.
Raises:
ManifestParseError: If the TOML content is malformed or doesn't match
the expected manifest schema.
Example:
>>> content = b'[package]\\nname = "my-pkg"\\nversion = "1.0.0"\\n...'
>>> manifest = get_manifest_from_bytes(content, source="git://repo")
>>> print(manifest.package.name)
'my-pkg'
"""
# Parse the TOML content
try:
data = tomllib.loads(content.decode("utf-8"))
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as e:
raise ManifestParseError(pathlib.Path(source), f"Invalid TOML syntax: {e}") from e
# Parse the data into a Manifest object
try:
return Manifest.model_validate(data)
except ValidationError as e:
raise ManifestParseError(pathlib.Path(source), str(e)) from e