"""Dependency graph for transitive dependency resolution. Builds a complete dependency graph with: - Transitive closure (A -> B -> C) - Cycle detection - Version conflict detection with provenance - Topological sorting for install order """ from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Set, Optional, Tuple import yaml from .tool import TOOLS_DIR from .manifest import Dependency from .version import matches_constraint # Default owner for unqualified tool references DEFAULT_OWNER = "official" @dataclass class DependencyNode: """A node in the dependency graph.""" owner: str # e.g., "official" (resolved), "" if unresolved name: str # e.g., "summarize" version_constraint: Optional[str] # e.g., "^1.0.0", "*", None resolved_version: Optional[str] # e.g., "1.2.3" (after resolution) source: Optional[str] # "local", "global", "registry", None (unresolved) path: Optional[Path] # Path to installed tool (if installed) children: List[str] = field(default_factory=list) # Qualified names of dependencies requested_by: str = "root" # Parent that introduced this dep @property def qualified_name(self) -> str: """Consistent key for graph lookups.""" return f"{self.owner}/{self.name}" if self.owner else self.name @property def is_resolved(self) -> bool: """Check if this node has been resolved to a source.""" return self.source is not None @dataclass class DependencyGraph: """Complete dependency graph with transitive closure.""" root_dependencies: List[str] = field(default_factory=list) # Direct deps (qualified names) nodes: Dict[str, DependencyNode] = field(default_factory=dict) # qualified_name -> node resolution_order: List[str] = field(default_factory=list) # Topological sort (install order) conflicts: List[Tuple[str, str, str, str, str]] = field(default_factory=list) # (tool, c1, from1, c2, from2) cycles: List[List[str]] = field(default_factory=list) # Detected cycles def add_node(self, node: DependencyNode, requested_by: str = "root") -> None: """Add or update a node. Detects version conflicts with provenance.""" key = node.qualified_name node.requested_by = requested_by if key in self.nodes: existing = self.nodes[key] # Check for version conflict if (existing.version_constraint and node.version_constraint and existing.version_constraint != node.version_constraint): self.conflicts.append(( key, existing.version_constraint, existing.requested_by, node.version_constraint, requested_by )) self.nodes[key] = node def get_node(self, qualified_name: str) -> Optional[DependencyNode]: """Get a node by qualified name.""" return self.nodes.get(qualified_name) def get_install_order(self) -> List[DependencyNode]: """Return nodes in installation order (dependencies first).""" return [self.nodes[name] for name in self.resolution_order if name in self.nodes] def has_conflicts(self) -> bool: """Check if any version conflicts were detected.""" return len(self.conflicts) > 0 def has_cycles(self) -> bool: """Check if any circular dependencies were detected.""" return len(self.cycles) > 0 def get_unresolved(self) -> List[DependencyNode]: """Get all unresolved nodes.""" return [node for node in self.nodes.values() if not node.is_resolved] def get_registry_nodes(self) -> List[DependencyNode]: """Get nodes that need to be installed from registry.""" return [ node for node in self.nodes.values() if node.source == "registry" and node.path is None ] class DependencyGraphBuilder: """Builds a complete dependency graph with transitive resolution.""" def __init__( self, resolver: Optional["ToolResolver"] = None, client: Optional["RegistryClient"] = None, max_depth: int = 50, verbose: bool = False ): """ Initialize the graph builder. Args: resolver: ToolResolver for local/global lookups (created if not provided) client: RegistryClient for registry lookups (created if not provided) max_depth: Maximum dependency depth (prevents infinite recursion) verbose: Print debug info """ self.resolver = resolver self.client = client self.max_depth = max_depth self.verbose = verbose self._visited: Set[str] = set() # qualified names self._stack: List[str] = [] # For cycle detection self._registry_cache: Dict[str, "DownloadResult"] = {} # cache_key -> DownloadResult self._resolved_count = 0 self.include_implicit = True def _get_resolver(self) -> "ToolResolver": """Get or create the tool resolver.""" if self.resolver is None: from .resolver import ToolResolver self.resolver = ToolResolver(auto_fetch=False) return self.resolver def _get_client(self) -> "RegistryClient": """Get or create the registry client.""" if self.client is None: from .registry_client import get_client self.client = get_client() return self.client def build( self, dependencies: List[Dependency], include_implicit: bool = True ) -> DependencyGraph: """ Build complete dependency graph. Args: dependencies: Direct dependencies to resolve include_implicit: Whether to include ToolStep dependencies Returns: DependencyGraph with all transitive dependencies """ graph = DependencyGraph() self.include_implicit = include_implicit # Reset state for new build self._visited.clear() self._stack.clear() self._resolved_count = 0 # Track original names to resolve qualified names after original_names = [] for dep in dependencies: original_names.append(dep.name) self._resolve_recursive(dep.name, dep.version, graph, depth=0) # Update root_dependencies with qualified names from resolved nodes for orig_name in original_names: # Find the resolved node - check both qualified and unqualified node = graph.get_node(orig_name) if node: graph.root_dependencies.append(node.qualified_name) else: # Try to find by matching tool name across all nodes found = False for qname, n in graph.nodes.items(): if n.name == orig_name or qname == orig_name: graph.root_dependencies.append(n.qualified_name) found = True break if not found: # Unresolved - keep original name for error reporting graph.root_dependencies.append(orig_name) graph.resolution_order = self._topological_sort(graph) return graph def _qualify_name(self, name: str, resolved_owner: Optional[str] = None) -> str: """Return qualified name after resolution (local-first).""" if "/" in name: return name owner = resolved_owner or DEFAULT_OWNER return f"{owner}/{name}" def _parse_qualified(self, qualified: str) -> Tuple[Optional[str], str]: """Split qualified name into owner and name (owner may be None).""" if "/" in qualified: parts = qualified.split("/", 1) return (parts[0], parts[1]) return (None, qualified) def _resolve_recursive( self, qualified_name: str, version_constraint: Optional[str], graph: DependencyGraph, depth: int ) -> Optional[DependencyNode]: """Recursively resolve a dependency and its children.""" # Cycle detection if qualified_name in self._stack: cycle = self._stack[self._stack.index(qualified_name):] + [qualified_name] graph.cycles.append(cycle) if self.verbose: print(f" Cycle detected: {' -> '.join(cycle)}") return None # Already resolved - check for version conflicts if qualified_name in self._visited: existing = graph.get_node(qualified_name) if existing and version_constraint and existing.version_constraint != version_constraint: graph.conflicts.append(( qualified_name, existing.version_constraint or "*", existing.requested_by or "unknown", version_constraint or "*", self._stack[-1] if self._stack else "unknown" )) return existing # Depth limit if depth > self.max_depth: raise RecursionError(f"Dependency depth exceeded {self.max_depth}") self._visited.add(qualified_name) self._stack.append(qualified_name) try: # Resolve the tool node = self._resolve_single(qualified_name, version_constraint) if node is None: return None # Also track the resolved qualified name to prevent duplicates if node.qualified_name != qualified_name: self._visited.add(node.qualified_name) graph.add_node(node, requested_by=self._stack[-2] if len(self._stack) > 1 else "root") self._resolved_count += 1 if self.verbose: print(f" Resolved {self._resolved_count}: {node.qualified_name}") # Get child dependencies (only if this node is resolved) if node.is_resolved: child_deps = self._get_tool_dependencies(node) else: child_deps = [] # Recurse into children and capture resolved keys node.children = [] for child in child_deps: child_node = self._resolve_recursive(child.name, child.version, graph, depth + 1) if child_node and child_node.is_resolved: node.children.append(child_node.qualified_name) elif child_node: # Child exists but is unresolved - still track for error reporting node.children.append(child_node.qualified_name) if self.verbose: print(f" Warning: Unresolved dependency {child.name}") return node finally: self._stack.pop() def _resolve_single( self, qualified_name: str, version_constraint: Optional[str] ) -> Optional[DependencyNode]: """Resolve a single dependency to a node.""" owner, name = self._parse_qualified(qualified_name) owner_hint = None # Try local/global first via resolver try: from .resolver import ToolNotFoundError resolver = self._get_resolver() resolved = resolver.resolve(qualified_name) owner_hint = resolved.owner if version_constraint and version_constraint not in ("", "*", "latest"): resolved_version = resolved.version or "" if not resolved_version or not matches_constraint(resolved_version, version_constraint): if self.verbose: print( f" Local/global {qualified_name}@{resolved_version or 'unknown'} " f"does not satisfy {version_constraint}, resolving from registry" ) else: return DependencyNode( owner=resolved.owner or owner or "", name=resolved.tool.name, version_constraint=version_constraint, resolved_version=resolved.version, source=resolved.source, path=resolved.path, children=[] ) else: return DependencyNode( owner=resolved.owner or owner or "", name=resolved.tool.name, version_constraint=version_constraint, resolved_version=resolved.version, source=resolved.source, path=resolved.path, children=[] ) except ToolNotFoundError: pass except Exception as e: if self.verbose: print(f" Warning: Resolver error for {qualified_name}: {e}") # Try registry try: reg_owner = owner or owner_hint or DEFAULT_OWNER result = self._download_registry_config( reg_owner, name, version_constraint ) return DependencyNode( owner=reg_owner, name=name, version_constraint=version_constraint, resolved_version=result.resolved_version or "", source="registry", path=None, # Not installed yet children=[] ) except Exception as e: if self.verbose: print(f" Could not resolve {qualified_name}: {e}") # Record unresolved node so graph reflects missing dependency return DependencyNode( owner=owner or owner_hint or "", name=name, version_constraint=version_constraint, resolved_version=None, source=None, path=None, children=[] ) def _get_tool_dependencies(self, node: DependencyNode) -> List[Dependency]: """Get dependencies for a resolved tool node.""" deps = [] if node.source in ("local", "global") and node.path: # Read from local config file deps.extend(self._read_deps_from_config(node.path)) elif node.source == "registry": # Fetch config from registry without installing deps.extend(self._read_deps_from_registry(node.owner, node.name, node.resolved_version or node.version_constraint)) return deps def _read_deps_from_config(self, tool_path: Path) -> List[Dependency]: """Read dependencies from a local tool config.""" deps = [] config_path = tool_path / "config.yaml" if not config_path.exists(): return deps try: data = yaml.safe_load(config_path.read_text()) or {} # Explicit dependencies for dep in data.get("dependencies", []): deps.append(Dependency.from_dict(dep)) # Implicit dependencies from ToolSteps if self.include_implicit: for step in data.get("steps", []): if step.get("type") == "tool": tool_ref = step.get("tool") if tool_ref: deps.append(Dependency.from_dict(tool_ref)) except Exception as e: if self.verbose: print(f" Warning: Could not read deps from {config_path}: {e}") return deps def _read_deps_from_registry(self, owner: str, name: str, version: Optional[str]) -> List[Dependency]: """Read dependencies from registry tool config.""" deps = [] try: # Download config without installing (cache by qualified name + version/constraint) result = self._download_registry_config(owner, name, version) config_yaml = result.config_yaml data = yaml.safe_load(config_yaml) or {} # Explicit dependencies for dep in data.get("dependencies", []): deps.append(Dependency.from_dict(dep)) # Implicit dependencies from ToolSteps if self.include_implicit: for step in data.get("steps", []): if step.get("type") == "tool": tool_ref = step.get("tool") if tool_ref: deps.append(Dependency.from_dict(tool_ref)) except Exception as e: if self.verbose: print(f" Warning: Could not read deps from registry {owner}/{name}: {e}") return deps def _download_registry_config( self, owner: str, name: str, version: Optional[str] ) -> "DownloadResult": """Download registry config with caching (version or constraint aware).""" cache_key = f"{owner}/{name}@{version or ''}" if cache_key in self._registry_cache: return self._registry_cache[cache_key] client = self._get_client() result = client.download_tool(owner, name, version=version, install=False) self._registry_cache[cache_key] = result return result def _topological_sort(self, graph: DependencyGraph) -> List[str]: """Return nodes in installation order (dependencies first).""" # Kahn's algorithm in_degree = {name: 0 for name in graph.nodes} # Build in-degree counts for node in graph.nodes.values(): for child in node.children: if child in in_degree: in_degree[child] += 1 # Start with nodes that have no dependencies queue = [name for name, deg in in_degree.items() if deg == 0] result = [] while queue: name = queue.pop(0) result.append(name) node = graph.nodes.get(name) if node: for child in node.children: if child in in_degree: in_degree[child] -= 1 if in_degree[child] == 0: queue.append(child) # In our graph, A.children contains B means "A depends on B". # Kahn's algorithm processes nodes with in_degree=0 first. # Nodes with in_degree=0 are roots (no one depends on them). # So the raw output is: dependents first, then dependencies. # We reverse to get: dependencies first, then dependents (correct install order). return list(reversed(result)) @dataclass class ResolutionResult: """Result of dependency resolution.""" graph: DependencyGraph to_install: List[DependencyNode] # Tools needing installation already_installed: List[DependencyNode] # Already present failed: List[Tuple[str, str]] # (qualified_name, error_message) warnings: List[str] # Non-fatal issues @property def success(self) -> bool: """Check if resolution was successful.""" return not self.graph.has_cycles() and not self.failed def resolve_dependencies( dependencies: List[Dependency], verbose: bool = False, include_implicit: bool = True ) -> ResolutionResult: """ Convenience function to resolve dependencies and categorize results. Args: dependencies: Direct dependencies to resolve verbose: Print debug info include_implicit: Include ToolStep dependencies Returns: ResolutionResult with categorized nodes """ builder = DependencyGraphBuilder(verbose=verbose) graph = builder.build(dependencies, include_implicit=include_implicit) to_install = [] already_installed = [] failed = [] warnings = [] for node in graph.get_install_order(): if not node.is_resolved: failed.append((node.qualified_name, "Tool not found")) elif node.source == "registry" and node.path is None: to_install.append(node) else: already_installed.append(node) if graph.has_conflicts(): for tool, v1, from1, v2, from2 in graph.conflicts: warnings.append(f"Version conflict for {tool}: {v1} (from {from1}) vs {v2} (from {from2})") if graph.has_cycles(): for cycle in graph.cycles: warnings.append(f"Circular dependency: {' -> '.join(cycle)}") return ResolutionResult( graph=graph, to_install=to_install, already_installed=already_installed, failed=failed, warnings=warnings )