Skip to content

Commit 3e4c14c

Browse files
allRiscBen Davisclaude
authored
Feature/directed graph (#9)
* 🤖 Add ResolveResult to preserve dependency graph from resolution resolve() previously returned a plain dict, discarding the dependency graph that resolvelib computed. Callers like _create_library_filelist() had to rebuild the graph by re-reading manifests from disk. ResolveResult now wraps the mapping with a graph (dict[str, set[str]]) and the set of direct dependencies, while providing dict-like convenience methods for backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🤖 Add topological_order() method to ResolveResult Move topological sort logic into ResolveResult so users can get dependency-ordered package names directly. Remove the private _topological_sort from install.py in favor of the new public method. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🤖 Add tests for ResolveResult and fix topological_order() Add 14 tests covering the dict-like interface and topological ordering. The tests caught a bug in topological_order() which was producing dependents-first instead of dependencies-first order. Fixed by reversing the traversal direction in Kahn's algorithm. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fixed warnings from document generation which cause tox to fail --------- Co-authored-by: Ben Davis <b-davis1@ti.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ffffd6f commit 3e4c14c

6 files changed

Lines changed: 308 additions & 62 deletions

File tree

src/fastsandpm/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,15 @@
3434
'my-package'
3535
>>> resolved = fastsandpm.dependencies.resolve(manifest)
3636
>>> print(type(resolved))
37-
<class 'dict'>
37+
<class 'fastsandpm.dependencies.provider.ResolveResult'>
3838
>>> build_library(resolved, pathlib.Path("my-library"))
3939
4040
Included Classes:
4141
- :py:class:`~manifest.Manifest`: The main manifest model representing a `proj.toml` file.
4242
- :py:class:`~manifest.Package`: Package metadata (name, version, description, authors).
4343
- :py:class:`~manifest.ManifestNotFoundError`: Raised when a manifest file cannot be found.
4444
- :py:class:`~manifest.ManifestParseError`: Raised when a manifest file cannot be parsed.
45+
- :py:class:`~dependencies.ResolveResult`: Result of dependency resolution with graph.
4546
4647
Functions:
4748
- :py:func:`~manifest.get_manifest`: Load and parse a manifest from a repository path.
@@ -63,6 +64,7 @@
6364

6465
from fastsandpm import _info
6566
from fastsandpm.dependencies import (
67+
ResolveResult,
6668
resolve,
6769
)
6870
from fastsandpm.install import build_library, library_from_manifest
@@ -85,6 +87,7 @@
8587
"ManifestNotFoundError",
8688
"ManifestParseError",
8789
"Package",
90+
"ResolveResult",
8891
"resolve",
8992
"library_from_manifest",
9093
"build_library",

src/fastsandpm/dependencies/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
- :py:class:`~candidates.PathCandidate`: Candidate from a local filesystem path.
3737
- :py:class:`~candidates.GitCandidate`: Candidate from a git repository.
3838
39+
Included Classes (continued):
40+
- :py:class:`~provider.ResolveResult`: Result of dependency resolution containing the resolved
41+
packages and their dependency graph.
42+
3943
Included Functions:
4044
- :py:func:`~candidates.candidate_factory`: Singledispatch function to create candidates
4145
from requirements.
@@ -52,7 +56,7 @@
5256
PathCandidate,
5357
candidate_factory,
5458
)
55-
from .provider import resolve
59+
from .provider import ResolveResult, resolve
5660
from .requirements import (
5761
BranchGitRequirement,
5862
CommitGitRequirement,
@@ -78,5 +82,6 @@
7882
"PathCandidate",
7983
"GitCandidate",
8084
"candidate_factory",
85+
"ResolveResult",
8186
"resolve",
8287
]

src/fastsandpm/dependencies/__main__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,9 @@
6565
pkg_manifest = Manifest.model_validate(definition)
6666
pprint.pprint(pkg_manifest)
6767
print("")
68-
pprint.pprint(resolve(pkg_manifest))
68+
result = resolve(pkg_manifest)
69+
pprint.pprint(result.mapping)
70+
print("\nDependency graph:")
71+
pprint.pprint(result.graph)
72+
print("\nDirect dependencies:")
73+
pprint.pprint(result.direct_dependencies)

src/fastsandpm/dependencies/provider.py

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535

3636
from __future__ import annotations
3737

38-
from collections.abc import Iterable, Iterator, Mapping, Sequence
38+
from collections.abc import ItemsView, Iterable, Iterator, Mapping, Sequence
39+
from dataclasses import dataclass
3940
from typing import TYPE_CHECKING
4041

4142
import resolvelib
@@ -285,7 +286,90 @@ def narrow_requirement_selection(
285286
"""Type alias for the resolution reporter."""
286287

287288

288-
def resolve(manifest: Manifest, optional_deps: list[str] | None = None) -> dict[str, Candidate]:
289+
@dataclass(frozen=True)
290+
class ResolveResult:
291+
"""Result of dependency resolution, containing resolved packages and their dependency graph.
292+
293+
The mapping contains all resolved packages keyed by name. The graph preserves
294+
the dependency relationships computed by the resolver, avoiding the need to
295+
re-read manifests from disk to reconstruct them.
296+
"""
297+
298+
mapping: dict[str, Candidate]
299+
"""Dictionary mapping package names to their resolved Candidate objects."""
300+
graph: dict[str, set[str]]
301+
"""Dictionary mapping each package name to the set of package names it depends on.
302+
Only includes dependencies that are themselves in the resolved set.
303+
"""
304+
direct_dependencies: frozenset[str]
305+
"""The set of package names that were direct (user-specified) dependencies,
306+
as opposed to transitive dependencies.
307+
"""
308+
309+
def items(self) -> ItemsView[str, Candidate]:
310+
"""Return items view of the mapping, for dict-like iteration."""
311+
return self.mapping.items()
312+
313+
def __getitem__(self, key: str) -> Candidate:
314+
"""Get a candidate by package name."""
315+
return self.mapping[key]
316+
317+
def __iter__(self) -> Iterator[str]:
318+
"""Iterate over package names."""
319+
return iter(self.mapping)
320+
321+
def __len__(self) -> int:
322+
"""Return the number of resolved packages."""
323+
return len(self.mapping)
324+
325+
def __contains__(self, key: object) -> bool:
326+
"""Check if a package name is in the resolved set."""
327+
return key in self.mapping
328+
329+
def topological_order(self) -> list[str]:
330+
"""Return package names in topological order (dependencies first).
331+
332+
Packages that have no dependencies on other resolved packages appear
333+
first, followed by packages whose dependencies have already appeared.
334+
Ties are broken alphabetically for deterministic output.
335+
336+
Returns:
337+
List of package names sorted so that every package appears after
338+
all of its dependencies.
339+
"""
340+
# Count unresolved dependencies for each node
341+
remaining_deps: dict[str, int] = {
342+
name: len(deps) for name, deps in self.graph.items()
343+
}
344+
345+
# Build reverse lookup: for each dep, which nodes depend on it?
346+
dependents: dict[str, set[str]] = {name: set() for name in self.graph}
347+
for name, deps in self.graph.items():
348+
for dep in deps:
349+
if dep in dependents:
350+
dependents[dep].add(name)
351+
352+
# Start with nodes that have no dependencies
353+
queue = sorted(
354+
name for name in self.graph if remaining_deps[name] == 0
355+
)
356+
result: list[str] = []
357+
358+
while queue:
359+
node = queue.pop(0)
360+
result.append(node)
361+
for dependent in dependents.get(node, set()):
362+
remaining_deps[dependent] -= 1
363+
if remaining_deps[dependent] == 0:
364+
queue.append(dependent)
365+
queue.sort()
366+
367+
return result
368+
369+
370+
def resolve(
371+
manifest: Manifest, optional_deps: list[str] | None = None
372+
) -> ResolveResult:
289373
"""Resolve all dependencies for a manifest.
290374
291375
Creates a FastSandProvider with the manifest's registries and runs the
@@ -297,7 +381,8 @@ def resolve(manifest: Manifest, optional_deps: list[str] | None = None) -> dict[
297381
optional_deps: Optional dependency groups to include in the library.
298382
299383
Returns:
300-
A dictionary mapping package names to their resolved Candidate objects.
384+
A ResolveResult containing the resolved packages, their dependency graph,
385+
and the set of direct dependencies.
301386
302387
Raises:
303388
resolvelib.ResolutionImpossible: If no compatible resolution exists.
@@ -322,4 +407,23 @@ def resolve(manifest: Manifest, optional_deps: list[str] | None = None) -> dict[
322407
dependencies.extend(manifest.optional_dependencies[group])
323408

324409
result = resolver.resolve(dependencies)
325-
return result.mapping
410+
411+
# Convert resolvelib's DirectedGraph to a plain dict,
412+
# excluding the None root vertex.
413+
dep_graph: dict[str, set[str]] = {}
414+
for name in result.mapping:
415+
dep_graph[name] = {
416+
child for child in result.graph.iter_children(name)
417+
if child is not None
418+
}
419+
420+
direct_deps = frozenset(
421+
child for child in result.graph.iter_children(None)
422+
if child is not None
423+
)
424+
425+
return ResolveResult(
426+
mapping=result.mapping,
427+
graph=dep_graph,
428+
direct_dependencies=direct_deps,
429+
)

src/fastsandpm/install.py

Lines changed: 11 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
from subprocess import CalledProcessError
5757

5858
from fastsandpm import _git_utils
59-
from fastsandpm.dependencies import Candidate, resolve
59+
from fastsandpm.dependencies import ResolveResult, resolve
6060
from fastsandpm.dependencies.candidates import GitCandidate, PackageIndexCandidate, PathCandidate
6161
from fastsandpm.manifest import (
6262
Manifest,
@@ -93,7 +93,7 @@ def library_from_manifest(
9393
build_library(library, dest, clean)
9494

9595

96-
def build_library(definition: dict[str, Candidate], dest: pathlib.Path, clean: bool = True) -> bool:
96+
def build_library(definition: ResolveResult, dest: pathlib.Path, clean: bool = True) -> bool:
9797
"""Build a library candidate from a manifest definition.
9898
9999
The library will be placed in the destination directory with each dependency having it's own
@@ -134,8 +134,8 @@ def build_library(definition: dict[str, Candidate], dest: pathlib.Path, clean: b
134134
a manifest.
135135
136136
Args:
137-
definition: The definition of the library to build. Where the key is the name of the
138-
dependency and the value is the candidate for that dependency.
137+
definition: The resolved dependency definition containing packages and their
138+
dependency graph.
139139
dest: The destination directory for the library.
140140
clean: If True, clean the destination directory before building the library.
141141
@@ -420,39 +420,29 @@ def _install_path_candidate(candidate: PathCandidate, dep_dir: pathlib.Path, cle
420420
return False
421421

422422

423-
def _create_library_filelist(definition: dict[str, Candidate], dest: pathlib.Path) -> None:
423+
def _create_library_filelist(definition: ResolveResult, dest: pathlib.Path) -> None:
424424
"""Create the library.f filelist with proper dependency ordering.
425425
426426
Args:
427-
definition: The library definition with candidates.
427+
definition: The resolved dependency definition containing the dependency graph.
428428
dest: The destination directory for the library.
429-
_logger: Logger for warnings and errors.
430429
"""
431430

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

436-
for name, _ in definition.items():
437-
dep_graph[name] = set()
433+
for name in definition:
438434
dep_dir = dest / name
439435

440-
# Check if candidate has a manifest
436+
# Read manifests to get flist paths
441437
try:
442438
dep_manifests[name] = get_manifest(dep_dir)
443-
444-
# Add dependencies from manifest to graph
445-
for dep in dep_manifests[name].dependencies:
446-
if dep.name in definition:
447-
dep_graph[name].add(dep.name)
448-
449-
except ManifestNotFoundError as _:
439+
except ManifestNotFoundError:
450440
_logger.debug("No manifest found for %s", name)
451441
except ManifestParseError as e:
452442
_logger.warning("Failed to read manifest for %s: %s", name, e)
453443

454-
# Topological sort to order dependencies
455-
ordered_deps = _topological_sort(dep_graph)
444+
# Use the topological ordering from the resolve result
445+
ordered_deps = definition.topological_order()
456446

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

468458
_logger.debug("Created library.f with %s dependencies", len(ordered_deps))
469-
470-
471-
def _topological_sort(graph: dict[str, set[str]]) -> list[str]:
472-
"""Perform topological sort on dependency graph.
473-
474-
Args:
475-
graph: Dictionary mapping node to set of its dependencies.
476-
477-
Returns:
478-
List of nodes in topologically sorted order (dependencies first).
479-
"""
480-
# Count incoming edges for each node
481-
in_degree: dict[str, int] = {node: 0 for node in graph}
482-
for node in graph:
483-
for dep in graph[node]:
484-
in_degree[dep] = in_degree.get(dep, 0) + 1
485-
486-
# Start with nodes that have no dependencies
487-
queue = [node for node in graph if in_degree[node] == 0]
488-
result = []
489-
490-
while queue:
491-
# Sort to ensure deterministic ordering
492-
queue.sort()
493-
node = queue.pop(0)
494-
result.append(node)
495-
496-
# Remove edges from this node
497-
for dep in graph.get(node, set()):
498-
in_degree[dep] -= 1
499-
if in_degree[dep] == 0:
500-
queue.append(dep)
501-
502-
return result

0 commit comments

Comments
 (0)