Source code for duck.contrib.reloader.dependency_graph

"""
Provides a class to build a **reverse dependency graph** for Python modules,
track runtime/dynamic imports lazily, and determine safe reload order.  

Intended for use in hot-reload systems, e.g., DuckSightReloader.

---

Key Concepts:

1. Reverse Dependency Graph
   - Maps a module to the set of modules that depend on it.
   - Example:
       *module_a.py*  
       module_b.py -> imports module_a  
       module_c.py -> imports module_b  
       
       Reverse graph:
       ```py
       {
           "module_a": {"module_b"},
           "module_b": {"module_c"}
       }
       ```
   - If module_a changes, we reload module_b and module_c in safe order.

2. Static Imports
   - Collected using AST parsing from the source code.
   - Detects top-level and nested imports (in functions/classes).

3. Runtime Imports
   - Captured dynamically by patching Python's built-in __import__.
   - Tracks imports that happen inside setup functions or dynamically loaded code.

4. Safe Reload Workflow
   - Initialize DependencyGraph before importing your main app.
   - Optionally pre-build the graph for all files at startup.
   - On file change:
       1. Lazily build graph for changed file
       2. Merge into global graph
       3. Compute affected modules with get_modules_to_reload()
       4. Reload affected modules using importlib.reload()
       5. Apply SETTINGS.reload() or other mutable object updates
   - Repeat for subsequent file changes.

5. Usage Summary:

    ```py
    from duck.contrib.reloader.dependency_graph import DependencyGraph

    # Initialize BEFORE importing app
    graph = DependencyGraph(project_root=".")

    # Optional: pre-build graph for all project files
    for py_file in Path(".").rglob("*.py"):
        local_graph, _ = graph.build_graph_for_file(str(py_file))
        graph.merge_graph(local_graph)

    # Import app after graph is ready
    from duck.app import App
    app = App()
    app.run()

    # On file change:
    local_graph, changed_module = graph.build_graph_for_file(changed_file)
    graph.merge_graph(local_graph)
    affected = graph.get_modules_to_reload(changed_module)
    for mod_name in affected:
        importlib.reload(sys.modules[mod_name])
    ```

6. Notes:
   - Always initialize DependencyGraph before any other imports to capture runtime imports.
   - Supports lazy updates: only rebuild for changed files, not the whole project.
   - Leaf-first reload order ensures safe reloading of dependencies.
   - Runtime imports captured automatically; manual additions possible via add_runtime_dependency().

---

Author: Brian Musakwa
"""
import ast
import sys
import types
import builtins
import inspect

from pathlib import Path
from collections import defaultdict, deque


[docs] class DependencyGraph: """ Tracks module dependencies (static and runtime) and provides methods to determine safe reload order for affected modules. """ def __init__(self, project_root: str = "."): """ Initialize the dependency graph. Args: project_root (str): Root directory of the project for module path resolution. """ self.project_root = Path(project_root).resolve() self.reverse_graph = defaultdict(set) # module -> set of dependents self.runtime_imports = set() # modules imported dynamically # Patch built-in import to track runtime imports self._original_import = builtins.__import__ builtins.__import__ = self._tracked_import
[docs] def _tracked_import(self, name, globals=None, locals=None, fromlist=(), level=0): """ Intercepts dynamic imports and updates the dependency graph lazily. Args: name (str): Name of the module being imported. """ module = self._original_import(name, globals, locals, fromlist, level) if isinstance(module, types.ModuleType): self.runtime_imports.add(module.__name__) # Identify the module performing the import frame = inspect.currentframe() try: caller_module = inspect.getmodule(frame.f_back) if caller_module: self.reverse_graph[module.__name__].add(caller_module.__name__) finally: del frame return module
[docs] def module_name_from_path(self, file_path: str) -> str: """ Convert a file path to a Python module name relative to project root. Args: file_path (str): Path to the Python file. Returns: str: Python module name (dot-separated). """ path = Path(file_path).resolve() rel = path.relative_to(self.project_root).with_suffix("") return ".".join(rel.parts)
[docs] @staticmethod def parse_imports(source: str) -> set: """ Parse all imported module names in a Python source file. Args: source (str): Python source code. Returns: set: Names of imported modules. """ tree = ast.parse(source) imports = set() class ImportVisitor(ast.NodeVisitor): def visit_Import(self, node): for n in node.names: imports.add(n.name) def visit_ImportFrom(self, node): if node.module: imports.add(node.module) def visit_FunctionDef(self, node): self.generic_visit(node) def visit_ClassDef(self, node): self.generic_visit(node) ImportVisitor().visit(tree) return imports
[docs] def build_graph_for_file(self, file_path: str) -> tuple[dict, str]: """ Build a reverse dependency graph for a specific file lazily. Args: file_path (str): File to parse. Returns: tuple[dict, str]: Local graph {imported_module: set([current_module])}, Current module name """ module_name = self.module_name_from_path(file_path) try: source = Path(file_path).read_text() except Exception: return {}, module_name # Could not read file imports = self.parse_imports(source) local_graph = {imp: set([module_name]) for imp in imports} return local_graph, module_name
[docs] def merge_graph(self, local_graph: dict): """ Merge a local graph into the global reverse dependency graph. Args: local_graph (dict): {imported_module: set([dependent_modules])} """ for imported_module, dependents in local_graph.items(): self.reverse_graph[imported_module].update(dependents)
[docs] def get_modules_to_reload(self, changed_module: str) -> list[str]: """ Return a list of modules affected by a changed module, in safe reload order (leaf-first). Args: changed_module (str): Module that has changed. Returns: list[str]: Modules to reload in order. """ affected = set() queue = deque([changed_module]) while queue: mod = queue.popleft() if mod in affected: continue affected.add(mod) for dependent in self.reverse_graph.get(mod, []): queue.append(dependent) # Leaf-first order: modules with fewer dots first return sorted(affected, key=lambda x: len(x.split(".")))
[docs] def add_runtime_dependency(self, module_name: str, imported_module: str): """ Manually add a runtime dependency (optional). Args: module_name (str): The module that imported another. imported_module (str): The module that was imported. """ self.reverse_graph[imported_module].add(module_name) self.runtime_imports.add(imported_module)
[docs] def restore_import(self): """ Restore the original built-in import function. """ builtins.__import__ = self._original_import
[docs] def stop(self): """ Cleanup the `DependancyGraph` by restoring the default `__import__` function. """ self.restore_import()
[docs] def __repr__(self): return ( f"<DependencyGraph " f"modules={len(self.reverse_graph)}, " f"runtime_imports={len(self.runtime_imports)}>" )