Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/fastsandpm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,15 @@
'my-package'
>>> resolved = fastsandpm.dependencies.resolve(manifest)
>>> print(type(resolved))
<class 'dict'>
<class 'fastsandpm.dependencies.provider.ResolveResult'>
>>> build_library(resolved, pathlib.Path("my-library"))

Included Classes:
- :py:class:`~manifest.Manifest`: The main manifest model representing a `proj.toml` file.
- :py:class:`~manifest.Package`: Package metadata (name, version, description, authors).
- :py:class:`~manifest.ManifestNotFoundError`: Raised when a manifest file cannot be found.
- :py:class:`~manifest.ManifestParseError`: Raised when a manifest file cannot be parsed.
- :py:class:`~dependencies.ResolveResult`: Result of dependency resolution with graph.

Functions:
- :py:func:`~manifest.get_manifest`: Load and parse a manifest from a repository path.
Expand All @@ -63,6 +64,7 @@

from fastsandpm import _info
from fastsandpm.dependencies import (
ResolveResult,
resolve,
)
from fastsandpm.install import build_library, library_from_manifest
Expand All @@ -85,6 +87,7 @@
"ManifestNotFoundError",
"ManifestParseError",
"Package",
"ResolveResult",
"resolve",
"library_from_manifest",
"build_library",
Expand Down
7 changes: 6 additions & 1 deletion src/fastsandpm/dependencies/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
- :py:class:`~candidates.PathCandidate`: Candidate from a local filesystem path.
- :py:class:`~candidates.GitCandidate`: Candidate from a git repository.

Included Classes (continued):
- :py:class:`~provider.ResolveResult`: Result of dependency resolution containing the resolved
packages and their dependency graph.

Included Functions:
- :py:func:`~candidates.candidate_factory`: Singledispatch function to create candidates
from requirements.
Expand All @@ -52,7 +56,7 @@
PathCandidate,
candidate_factory,
)
from .provider import resolve
from .provider import ResolveResult, resolve
from .requirements import (
BranchGitRequirement,
CommitGitRequirement,
Expand All @@ -78,5 +82,6 @@
"PathCandidate",
"GitCandidate",
"candidate_factory",
"ResolveResult",
"resolve",
]
7 changes: 6 additions & 1 deletion src/fastsandpm/dependencies/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,9 @@
pkg_manifest = Manifest.model_validate(definition)
pprint.pprint(pkg_manifest)
print("")
pprint.pprint(resolve(pkg_manifest))
result = resolve(pkg_manifest)
pprint.pprint(result.mapping)
print("\nDependency graph:")
pprint.pprint(result.graph)
print("\nDirect dependencies:")
pprint.pprint(result.direct_dependencies)
112 changes: 108 additions & 4 deletions src/fastsandpm/dependencies/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@

from __future__ import annotations

from collections.abc import Iterable, Iterator, Mapping, Sequence
from collections.abc import ItemsView, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING

import resolvelib
Expand Down Expand Up @@ -285,7 +286,90 @@ def narrow_requirement_selection(
"""Type alias for the resolution reporter."""


def resolve(manifest: Manifest, optional_deps: list[str] | None = None) -> dict[str, Candidate]:
@dataclass(frozen=True)
class ResolveResult:
"""Result of dependency resolution, containing resolved packages and their dependency graph.

The mapping contains all resolved packages keyed by name. The graph preserves
the dependency relationships computed by the resolver, avoiding the need to
re-read manifests from disk to reconstruct them.
"""

mapping: dict[str, Candidate]
"""Dictionary mapping package names to their resolved Candidate objects."""
graph: dict[str, set[str]]
"""Dictionary mapping each package name to the set of package names it depends on.
Only includes dependencies that are themselves in the resolved set.
"""
direct_dependencies: frozenset[str]
"""The set of package names that were direct (user-specified) dependencies,
as opposed to transitive dependencies.
"""

def items(self) -> ItemsView[str, Candidate]:
"""Return items view of the mapping, for dict-like iteration."""
return self.mapping.items()

def __getitem__(self, key: str) -> Candidate:
"""Get a candidate by package name."""
return self.mapping[key]

def __iter__(self) -> Iterator[str]:
"""Iterate over package names."""
return iter(self.mapping)

def __len__(self) -> int:
"""Return the number of resolved packages."""
return len(self.mapping)

def __contains__(self, key: object) -> bool:
"""Check if a package name is in the resolved set."""
return key in self.mapping

def topological_order(self) -> list[str]:
"""Return package names in topological order (dependencies first).

Packages that have no dependencies on other resolved packages appear
first, followed by packages whose dependencies have already appeared.
Ties are broken alphabetically for deterministic output.

Returns:
List of package names sorted so that every package appears after
all of its dependencies.
"""
# Count unresolved dependencies for each node
remaining_deps: dict[str, int] = {
name: len(deps) for name, deps in self.graph.items()
}

# Build reverse lookup: for each dep, which nodes depend on it?
dependents: dict[str, set[str]] = {name: set() for name in self.graph}
for name, deps in self.graph.items():
for dep in deps:
if dep in dependents:
dependents[dep].add(name)

# Start with nodes that have no dependencies
queue = sorted(
name for name in self.graph if remaining_deps[name] == 0
)
result: list[str] = []

while queue:
node = queue.pop(0)
result.append(node)
for dependent in dependents.get(node, set()):
remaining_deps[dependent] -= 1
if remaining_deps[dependent] == 0:
queue.append(dependent)
queue.sort()

return result


def resolve(
manifest: Manifest, optional_deps: list[str] | None = None
) -> ResolveResult:
"""Resolve all dependencies for a manifest.

Creates a FastSandProvider with the manifest's registries and runs the
Expand All @@ -297,7 +381,8 @@ def resolve(manifest: Manifest, optional_deps: list[str] | None = None) -> dict[
optional_deps: Optional dependency groups to include in the library.

Returns:
A dictionary mapping package names to their resolved Candidate objects.
A ResolveResult containing the resolved packages, their dependency graph,
and the set of direct dependencies.

Raises:
resolvelib.ResolutionImpossible: If no compatible resolution exists.
Expand All @@ -322,4 +407,23 @@ def resolve(manifest: Manifest, optional_deps: list[str] | None = None) -> dict[
dependencies.extend(manifest.optional_dependencies[group])

result = resolver.resolve(dependencies)
return result.mapping

# Convert resolvelib's DirectedGraph to a plain dict,
# excluding the None root vertex.
dep_graph: dict[str, set[str]] = {}
for name in result.mapping:
dep_graph[name] = {
child for child in result.graph.iter_children(name)
if child is not None
}

direct_deps = frozenset(
child for child in result.graph.iter_children(None)
if child is not None
)

return ResolveResult(
mapping=result.mapping,
graph=dep_graph,
direct_dependencies=direct_deps,
)
66 changes: 11 additions & 55 deletions src/fastsandpm/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
from subprocess import CalledProcessError

from fastsandpm import _git_utils
from fastsandpm.dependencies import Candidate, resolve
from fastsandpm.dependencies import ResolveResult, resolve
from fastsandpm.dependencies.candidates import GitCandidate, PackageIndexCandidate, PathCandidate
from fastsandpm.manifest import (
Manifest,
Expand Down Expand Up @@ -93,7 +93,7 @@ def library_from_manifest(
build_library(library, dest, clean)


def build_library(definition: dict[str, Candidate], dest: pathlib.Path, clean: bool = True) -> bool:
def build_library(definition: ResolveResult, dest: pathlib.Path, clean: bool = True) -> bool:
"""Build a library candidate from a manifest definition.

The library will be placed in the destination directory with each dependency having it's own
Expand Down Expand Up @@ -134,8 +134,8 @@ def build_library(definition: dict[str, Candidate], dest: pathlib.Path, clean: b
a manifest.

Args:
definition: The definition of the library to build. Where the key is the name of the
dependency and the value is the candidate for that dependency.
definition: The resolved dependency definition containing packages and their
dependency graph.
dest: The destination directory for the library.
clean: If True, clean the destination directory before building the library.

Expand Down Expand Up @@ -420,39 +420,29 @@ def _install_path_candidate(candidate: PathCandidate, dep_dir: pathlib.Path, cle
return False


def _create_library_filelist(definition: dict[str, Candidate], dest: pathlib.Path) -> None:
def _create_library_filelist(definition: ResolveResult, dest: pathlib.Path) -> None:
"""Create the library.f filelist with proper dependency ordering.

Args:
definition: The library definition with candidates.
definition: The resolved dependency definition containing the dependency graph.
dest: The destination directory for the library.
_logger: Logger for warnings and errors.
"""

# Build dependency graph to determine ordering
dep_graph: dict[str, set[str]] = {}
dep_manifests: dict[str, Manifest] = {}

for name, _ in definition.items():
dep_graph[name] = set()
for name in definition:
dep_dir = dest / name

# Check if candidate has a manifest
# Read manifests to get flist paths
try:
dep_manifests[name] = get_manifest(dep_dir)

# Add dependencies from manifest to graph
for dep in dep_manifests[name].dependencies:
if dep.name in definition:
dep_graph[name].add(dep.name)

except ManifestNotFoundError as _:
except ManifestNotFoundError:
_logger.debug("No manifest found for %s", name)
except ManifestParseError as e:
_logger.warning("Failed to read manifest for %s: %s", name, e)

# Topological sort to order dependencies
ordered_deps = _topological_sort(dep_graph)
# Use the topological ordering from the resolve result
ordered_deps = definition.topological_order()

# Create library.f file
library_f_path = dest / "library.f"
Expand All @@ -466,37 +456,3 @@ def _create_library_filelist(definition: dict[str, Candidate], dest: pathlib.Pat
f.write(f"-F {name}/{name}.f\n")

_logger.debug("Created library.f with %s dependencies", len(ordered_deps))


def _topological_sort(graph: dict[str, set[str]]) -> list[str]:
"""Perform topological sort on dependency graph.

Args:
graph: Dictionary mapping node to set of its dependencies.

Returns:
List of nodes in topologically sorted order (dependencies first).
"""
# Count incoming edges for each node
in_degree: dict[str, int] = {node: 0 for node in graph}
for node in graph:
for dep in graph[node]:
in_degree[dep] = in_degree.get(dep, 0) + 1

# Start with nodes that have no dependencies
queue = [node for node in graph if in_degree[node] == 0]
result = []

while queue:
# Sort to ensure deterministic ordering
queue.sort()
node = queue.pop(0)
result.append(node)

# Remove edges from this node
for dep in graph.get(node, set()):
in_degree[dep] -= 1
if in_degree[dep] == 0:
queue.append(dep)

return result
Loading
Loading