pyrig-runtime 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyrig_runtime/__init__.py +1 -0
- pyrig_runtime/core/__init__.py +1 -0
- pyrig_runtime/core/dependencies/__init__.py +6 -0
- pyrig_runtime/core/dependencies/discovery.py +124 -0
- pyrig_runtime/core/dependencies/graph.py +69 -0
- pyrig_runtime/core/dependencies/subclass.py +180 -0
- pyrig_runtime/core/graph.py +201 -0
- pyrig_runtime/core/introspection/__init__.py +6 -0
- pyrig_runtime/core/introspection/classes.py +105 -0
- pyrig_runtime/core/introspection/functions.py +72 -0
- pyrig_runtime/core/introspection/inspection.py +84 -0
- pyrig_runtime/core/introspection/modules.py +124 -0
- pyrig_runtime/core/introspection/packages.py +81 -0
- pyrig_runtime/core/strings.py +53 -0
- pyrig_runtime/py.typed +0 -0
- pyrig_runtime/rig/__init__.py +1 -0
- pyrig_runtime/rig/cli/__init__.py +1 -0
- pyrig_runtime/rig/cli/cli/__init__.py +1 -0
- pyrig_runtime/rig/cli/cli/cli.py +313 -0
- pyrig_runtime/rig/cli/commands/__init__.py +1 -0
- pyrig_runtime/rig/cli/commands/version.py +18 -0
- pyrig_runtime/rig/cli/main.py +8 -0
- pyrig_runtime/rig/cli/shared_subcommands.py +24 -0
- pyrig_runtime/rig/cli/subcommands.py +6 -0
- pyrig_runtime-0.1.0.dist-info/METADATA +59 -0
- pyrig_runtime-0.1.0.dist-info/RECORD +29 -0
- pyrig_runtime-0.1.0.dist-info/WHEEL +4 -0
- pyrig_runtime-0.1.0.dist-info/entry_points.txt +3 -0
- pyrig_runtime-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The top-level package for the project."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Package initialization."""
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Introspection primitives for class, module, and package discovery.
|
|
2
|
+
|
|
3
|
+
Provides the low-level building blocks that power pyrig's cross-package subclass
|
|
4
|
+
discovery: walking package hierarchies, dynamically importing modules, filtering
|
|
5
|
+
class collections, and locating equivalent module paths across dependent packages.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Subclass and module discovery scoped across installed package dependents."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from functools import cache
|
|
6
|
+
from itertools import chain
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
|
|
9
|
+
from pyrig_runtime.core.dependencies.graph import DependencyGraph
|
|
10
|
+
from pyrig_runtime.core.introspection.modules import (
|
|
11
|
+
import_module_with_default,
|
|
12
|
+
import_modules,
|
|
13
|
+
root_module,
|
|
14
|
+
)
|
|
15
|
+
from pyrig_runtime.core.introspection.packages import (
|
|
16
|
+
discover_subclasses_across_package,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def discover_subclasses_across_dependencies[T](
|
|
23
|
+
cls: type[T],
|
|
24
|
+
package: ModuleType,
|
|
25
|
+
) -> Iterator[type[T]]:
|
|
26
|
+
"""Yield subclasses of `cls` found in `package` and in every dependent of its root.
|
|
27
|
+
|
|
28
|
+
Searches `package` itself first, then each installed package that depends on
|
|
29
|
+
`package`'s root package. In each dependent, the module path equivalent to
|
|
30
|
+
`package` is located (e.g., `pyrig.rig` becomes `<dep>.rig`), and every
|
|
31
|
+
subclass of `cls` defined there is collected.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
cls: Base class whose subclasses should be discovered.
|
|
35
|
+
package: Module whose dotted path is replicated in each dependent package
|
|
36
|
+
to locate the modules to search. The root of this module determines
|
|
37
|
+
which installed packages are searched. For example, passing
|
|
38
|
+
`pyrig.rig` would search `<dep>.rig` in each dependent of `pyrig`.
|
|
39
|
+
|
|
40
|
+
Yields:
|
|
41
|
+
Subclass types of `cls` found across `package` and all dependent
|
|
42
|
+
packages, in dependency order (base package first, then dependents).
|
|
43
|
+
"""
|
|
44
|
+
logger.debug(
|
|
45
|
+
"Discovering subclasses of %s from modules in packages depending on %s",
|
|
46
|
+
cls.__name__,
|
|
47
|
+
package.__name__,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
subclass
|
|
52
|
+
for pkg in chain(
|
|
53
|
+
(package,),
|
|
54
|
+
discover_equivalent_modules_across_dependents(module=package),
|
|
55
|
+
)
|
|
56
|
+
for subclass in discover_subclasses_across_package(
|
|
57
|
+
cls,
|
|
58
|
+
package=pkg,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def discover_equivalent_modules_across_dependents(
|
|
64
|
+
module: ModuleType,
|
|
65
|
+
) -> Iterator[ModuleType]:
|
|
66
|
+
"""Yield the equivalent module from every package that depends on `module`'s root.
|
|
67
|
+
|
|
68
|
+
Given a module (e.g., `pyrig.rig.configs`), infers the root package
|
|
69
|
+
(e.g., `pyrig`), then constructs the equivalent dotted path in each package
|
|
70
|
+
that depends on that root (e.g., `myapp.rig.configs`), imports it if it
|
|
71
|
+
exists, and yields it.
|
|
72
|
+
|
|
73
|
+
The root package itself is not included in results — only its dependents are
|
|
74
|
+
iterated. The path transformation replaces the first occurrence of the root
|
|
75
|
+
package name in `module.__name__` with each dependent package's name, so a
|
|
76
|
+
consistent directory structure across the ecosystem is assumed.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
module: Template module whose path pattern is replicated in each dependent
|
|
80
|
+
package (e.g., `pyrig.core` → `<pkg>.core` for every dependent).
|
|
81
|
+
The root of this module is used to discover dependent packages.
|
|
82
|
+
|
|
83
|
+
Yields:
|
|
84
|
+
Successfully imported module objects in dependency order. Packages whose
|
|
85
|
+
equivalent module path cannot be imported are silently skipped.
|
|
86
|
+
"""
|
|
87
|
+
dependency = root_module(module)
|
|
88
|
+
logger.debug(
|
|
89
|
+
"Discovering modules equivalent to %s in packages depending on %s",
|
|
90
|
+
module.__name__,
|
|
91
|
+
dependency.__name__,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
for package in deps_depending_on_dep(dependency):
|
|
95
|
+
package_module_name = module.__name__.replace(
|
|
96
|
+
dependency.__name__, package.__name__, 1
|
|
97
|
+
)
|
|
98
|
+
package_module = import_module_with_default(package_module_name)
|
|
99
|
+
if package_module is not None:
|
|
100
|
+
yield package_module
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@cache
|
|
104
|
+
def deps_depending_on_dep(dependency: ModuleType) -> tuple[ModuleType, ...]:
|
|
105
|
+
"""Return all installed packages that depend on `dependency`, as module objects.
|
|
106
|
+
|
|
107
|
+
Find every installed package that depends on `dependency` (directly or
|
|
108
|
+
transitively), import them, and return the result as a tuple in dependency
|
|
109
|
+
order. The result is cached per unique `dependency` argument.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
dependency: The package whose dependents should be discovered.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Tuple of imported module objects for all packages that depend on
|
|
116
|
+
`dependency`. Does not include `dependency` itself.
|
|
117
|
+
"""
|
|
118
|
+
return tuple(
|
|
119
|
+
import_modules(
|
|
120
|
+
DependencyGraph.cached(root=dependency.__name__).sorted_ancestors(
|
|
121
|
+
dependency.__name__
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Directed graph of installed Python package dependency relationships."""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
from pyrig_runtime.core.graph import DiGraph
|
|
8
|
+
from pyrig_runtime.core.strings import (
|
|
9
|
+
dependency_requirement_as_package_name,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DependencyGraph(DiGraph):
|
|
16
|
+
"""Directed graph of installed Python package dependencies.
|
|
17
|
+
|
|
18
|
+
Nodes are package names; an edge A → B means "A depends on B".
|
|
19
|
+
The graph is built at instantiation by scanning all installed
|
|
20
|
+
distributions.
|
|
21
|
+
|
|
22
|
+
When a `root` package is given, the graph is pruned to retain only
|
|
23
|
+
that package and every package that depends on it directly or
|
|
24
|
+
transitively.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, root: str | None = None) -> None:
|
|
28
|
+
"""Initialize the dependency graph rooted at the given package.
|
|
29
|
+
|
|
30
|
+
Only `root` and packages that depend on it transitively are retained.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
root: Name of the root package. Accepts either the installed name
|
|
34
|
+
(`some-package`) or the import name (`some_package`).
|
|
35
|
+
"""
|
|
36
|
+
super().__init__(
|
|
37
|
+
root=dependency_requirement_as_package_name(root) if root else None
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def build(self) -> None:
|
|
41
|
+
"""Build the graph from installed Python distributions."""
|
|
42
|
+
logger.debug("Building dependency graph from installed distributions")
|
|
43
|
+
for dist in importlib.metadata.distributions():
|
|
44
|
+
name, deps = self.parse_name_and_deps(dist)
|
|
45
|
+
self.add_node(name)
|
|
46
|
+
for dep in deps:
|
|
47
|
+
self.add_edge(name, dep) # package → dependency
|
|
48
|
+
logger.debug("Dependency graph built with %d packages", len(self.nodes()))
|
|
49
|
+
|
|
50
|
+
def parse_name_and_deps(
|
|
51
|
+
self, dist: importlib.metadata.Distribution
|
|
52
|
+
) -> tuple[str, Iterator[str]]:
|
|
53
|
+
"""Extract the package name and dependencies from a distribution.
|
|
54
|
+
|
|
55
|
+
Both the package name and every dependency name are normalized to
|
|
56
|
+
snake_case. The dependency iterator is exhausted once consumed; it
|
|
57
|
+
yields nothing when the distribution declares no dependencies.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
dist: Distribution to extract metadata from.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
A two-tuple `(name, deps)` where `name` is the normalized package
|
|
64
|
+
name and `deps` is an iterator over the normalized name of each
|
|
65
|
+
declared dependency.
|
|
66
|
+
"""
|
|
67
|
+
return dependency_requirement_as_package_name(dist.name), (
|
|
68
|
+
dependency_requirement_as_package_name(req) for req in (dist.requires or [])
|
|
69
|
+
)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Abstract base for cross-package subclass discovery without explicit registration."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Iterable, Iterator
|
|
7
|
+
from functools import cache
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import Any, Self, TypeVar
|
|
10
|
+
|
|
11
|
+
from pyrig import rig
|
|
12
|
+
|
|
13
|
+
from pyrig_runtime.core.dependencies.discovery import (
|
|
14
|
+
discover_subclasses_across_dependencies,
|
|
15
|
+
)
|
|
16
|
+
from pyrig_runtime.core.introspection.classes import (
|
|
17
|
+
classproperty,
|
|
18
|
+
discard_abstract_classes,
|
|
19
|
+
discard_parent_classes,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T", bound="DependencySubclass")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DependencySubclass(ABC):
|
|
28
|
+
"""Base class that enables the plugin-style extensibility across installed packages.
|
|
29
|
+
|
|
30
|
+
Subclasses declare a sub-package scope via `dependency_package()`, and
|
|
31
|
+
the discovery machinery automatically finds all concrete implementations
|
|
32
|
+
defined in that sub-package across every installed package that depends
|
|
33
|
+
on the root package. No explicit registration is required.
|
|
34
|
+
|
|
35
|
+
The `sort_key()` hook controls ordering when `subclasses_sorted()` is
|
|
36
|
+
used. The `L` classproperty provides a cached shortcut to the leaf
|
|
37
|
+
subclass type, and `I` provides a cached instance of it.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
"""Return the fully qualified class name of this instance."""
|
|
42
|
+
return f"{self.__module__}.{self.__class__.__name__}"
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def dependency_package(cls) -> ModuleType:
|
|
47
|
+
"""Return the sub-package that scopes subclass discovery for this hierarchy.
|
|
48
|
+
|
|
49
|
+
Every concrete subclass must override this to return the sub-package
|
|
50
|
+
where its own implementations are defined. The returned module's root
|
|
51
|
+
package determines which installed packages are searched.
|
|
52
|
+
|
|
53
|
+
The base implementation returns `pyrig.rig`, making it callable via
|
|
54
|
+
`super()` as a fallback or when calling `subclasses()` directly on
|
|
55
|
+
`DependencySubclass` itself.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Package module whose path pattern is replicated across dependent
|
|
59
|
+
packages to locate the modules to search.
|
|
60
|
+
"""
|
|
61
|
+
return rig
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def sort_key(cls) -> Any:
|
|
65
|
+
"""Return a stable sort key used by `subclasses_sorted()` to order subclasses.
|
|
66
|
+
|
|
67
|
+
Override to sort by priority, numeric position, or any other criterion.
|
|
68
|
+
The default returns the class name, giving alphabetical ordering.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
A value comparable by `sorted()`.
|
|
72
|
+
"""
|
|
73
|
+
return cls.__name__
|
|
74
|
+
|
|
75
|
+
@classproperty
|
|
76
|
+
@cache # noqa: B019 # false warning bc of custom classproperty decorator
|
|
77
|
+
def I(cls) -> Self: # noqa: E743, N802, N805
|
|
78
|
+
"""Return a cached instance of the leaf subclass.
|
|
79
|
+
|
|
80
|
+
The instance is created once per class and reused on every subsequent
|
|
81
|
+
access. Equivalent to instantiating the result of `cls.L`.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
An instance of the leaf subclass.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
RuntimeError: If more than one leaf subclass is found.
|
|
88
|
+
"""
|
|
89
|
+
return cls.L()
|
|
90
|
+
|
|
91
|
+
@classproperty
|
|
92
|
+
@cache # noqa: B019 # false warning bc of custom classproperty decorator
|
|
93
|
+
def L(cls) -> type[Self]: # noqa: N802, N805
|
|
94
|
+
"""Return the cached leaf subclass type.
|
|
95
|
+
|
|
96
|
+
Equivalent to `leaf()`, but the result is cached so repeated accesses
|
|
97
|
+
do not re-run discovery.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The single leaf subclass type. May be abstract.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
RuntimeError: If more than one leaf subclass is found.
|
|
104
|
+
"""
|
|
105
|
+
return cls.leaf()
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def leaf(cls) -> type[Self]:
|
|
109
|
+
"""Return the single leaf subclass found across dependent packages.
|
|
110
|
+
|
|
111
|
+
Calls `subclasses()` and expects at most one result. If no subclasses
|
|
112
|
+
are found, the class itself is returned. Raises `RuntimeError` if
|
|
113
|
+
multiple subclasses are found, because a leaf must be unambiguous:
|
|
114
|
+
exactly one active implementation is allowed.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The sole leaf subclass type. May be abstract.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
RuntimeError: If more than one subclass is discovered across
|
|
121
|
+
the dependent packages.
|
|
122
|
+
"""
|
|
123
|
+
subclasses = cls.subclasses()
|
|
124
|
+
leaf = next(subclasses, cls)
|
|
125
|
+
second = next(subclasses, None)
|
|
126
|
+
if second is None:
|
|
127
|
+
return leaf
|
|
128
|
+
|
|
129
|
+
msg = f"""Multiple leaf subclasses found for {cls}.
|
|
130
|
+
Defining multiple leaf subclasses is ambiguous.
|
|
131
|
+
This can happen if more than one leaf subclass is defined
|
|
132
|
+
across all the dependent packages.
|
|
133
|
+
|
|
134
|
+
Found subclasses:
|
|
135
|
+
{json.dumps([str(subcls) for subcls in (leaf, second, *subclasses)], indent=4)}"""
|
|
136
|
+
raise RuntimeError(msg)
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def concrete_subclasses(cls) -> Iterator[type[Self]]:
|
|
140
|
+
"""Yield all non-abstract subclasses discovered across dependent packages.
|
|
141
|
+
|
|
142
|
+
Equivalent to `subclasses()` with abstract classes removed.
|
|
143
|
+
|
|
144
|
+
Yields:
|
|
145
|
+
Non-abstract subclass types.
|
|
146
|
+
"""
|
|
147
|
+
return discard_abstract_classes(cls.subclasses())
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def subclasses(cls) -> Iterator[type[Self]]:
|
|
151
|
+
"""Yield all subclasses discovered across the package ecosystem.
|
|
152
|
+
|
|
153
|
+
Searches every installed package that depends on the root package of
|
|
154
|
+
`dependency_package()`. Intermediate parent classes are discarded,
|
|
155
|
+
leaving only the outermost leaf-level subclasses.
|
|
156
|
+
|
|
157
|
+
Yields:
|
|
158
|
+
Subclass types with intermediate parent classes removed.
|
|
159
|
+
"""
|
|
160
|
+
return discard_parent_classes(
|
|
161
|
+
discover_subclasses_across_dependencies(
|
|
162
|
+
cls,
|
|
163
|
+
package=cls.dependency_package(),
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def subclasses_sorted(cls, subclasses: Iterable[type[Self]]) -> list[type[Self]]:
|
|
169
|
+
"""Sort the given subclasses using each subclass's `sort_key()`.
|
|
170
|
+
|
|
171
|
+
Does not perform any discovery. Pass any iterable of subclass types
|
|
172
|
+
to produce a deterministically ordered list.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
subclasses: Subclass types to sort.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The same subclass types sorted by their `sort_key()`.
|
|
179
|
+
"""
|
|
180
|
+
return sorted(subclasses, key=lambda subclass: subclass.sort_key())
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Abstract directed graph foundation with forward and reverse edge traversal."""
|
|
2
|
+
|
|
3
|
+
import heapq
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections import deque
|
|
6
|
+
from functools import cache
|
|
7
|
+
from typing import Any, Self
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DiGraph(ABC):
|
|
11
|
+
"""Abstract directed graph with forward and reverse adjacency tracking.
|
|
12
|
+
|
|
13
|
+
Subclasses implement `build` to populate nodes and edges. At construction,
|
|
14
|
+
`build` is called first; if a `root` node is given, the graph is then pruned
|
|
15
|
+
to retain only that node and every node that transitively points to it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
@cache
|
|
20
|
+
def cached(cls, *args: Any, **kwargs: Any) -> Self:
|
|
21
|
+
"""Return a cached instance, constructing it only on the first call.
|
|
22
|
+
|
|
23
|
+
Repeated calls with identical arguments return the same instance
|
|
24
|
+
without rebuilding the graph.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
*args: Positional arguments forwarded to the constructor.
|
|
28
|
+
**kwargs: Keyword arguments forwarded to the constructor.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The cached graph instance for the given arguments.
|
|
32
|
+
"""
|
|
33
|
+
return cls(*args, **kwargs)
|
|
34
|
+
|
|
35
|
+
def __init__(self, root: str | None = None) -> None:
|
|
36
|
+
"""Build the graph, then prune it to `root` if one is given."""
|
|
37
|
+
self.root = root
|
|
38
|
+
self._nodes: set[str] = set()
|
|
39
|
+
self._edges: dict[str, set[str]] = {} # node -> outgoing neighbors
|
|
40
|
+
self._reverse_edges: dict[str, set[str]] = {} # node -> incoming neighbors
|
|
41
|
+
self.build()
|
|
42
|
+
if self.root is not None:
|
|
43
|
+
self.prune(self.root)
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def build(self) -> None:
|
|
47
|
+
"""Populate the graph with nodes and edges.
|
|
48
|
+
|
|
49
|
+
Called automatically during construction before any optional pruning.
|
|
50
|
+
Implementations use `add_node` and `add_edge` to define the graph
|
|
51
|
+
structure.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def prune(self, root: str) -> None:
|
|
55
|
+
"""Remove all nodes that are neither root nor an ancestor of root.
|
|
56
|
+
|
|
57
|
+
Keeps `root` and all its ancestors (nodes with a directed path to
|
|
58
|
+
`root`). All other nodes and their associated edges are removed.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
root: The root node to prune around.
|
|
62
|
+
"""
|
|
63
|
+
keep = self.ancestors(root) | {root}
|
|
64
|
+
self._nodes = keep
|
|
65
|
+
self._edges = {n: self._edges[n] & keep for n in keep}
|
|
66
|
+
self._reverse_edges = {n: self._reverse_edges[n] & keep for n in keep}
|
|
67
|
+
|
|
68
|
+
def add_edge(self, source: str, target: str) -> None:
|
|
69
|
+
"""Add a directed edge from source to target.
|
|
70
|
+
|
|
71
|
+
Creates both nodes if they do not already exist.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
source: Edge origin node.
|
|
75
|
+
target: Edge destination node.
|
|
76
|
+
"""
|
|
77
|
+
self.add_node(source)
|
|
78
|
+
self.add_node(target)
|
|
79
|
+
self._edges[source].add(target)
|
|
80
|
+
self._reverse_edges[target].add(source)
|
|
81
|
+
|
|
82
|
+
def add_node(self, node: str) -> None:
|
|
83
|
+
"""Add a node to the graph. No-op if the node already exists.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
node: Node identifier to add.
|
|
87
|
+
"""
|
|
88
|
+
self._nodes.add(node)
|
|
89
|
+
if node not in self._edges:
|
|
90
|
+
self._edges[node] = set()
|
|
91
|
+
if node not in self._reverse_edges:
|
|
92
|
+
self._reverse_edges[node] = set()
|
|
93
|
+
|
|
94
|
+
def nodes(self) -> set[str]:
|
|
95
|
+
"""Return all node identifiers currently in the graph."""
|
|
96
|
+
return self._nodes
|
|
97
|
+
|
|
98
|
+
def sorted_ancestors(self, target: str) -> list[str]:
|
|
99
|
+
"""Return all ancestors of the target node sorted in topological order.
|
|
100
|
+
|
|
101
|
+
Ancestors are nodes that have a directed path to the target (i.e., nodes
|
|
102
|
+
that depend on it directly or transitively). The result is sorted so that
|
|
103
|
+
dependencies appear before their dependents.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
target: Node to find ancestors of.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
List of ancestor node identifiers, with dependencies first.
|
|
110
|
+
Returns an empty list if the target is not in the graph.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
RuntimeError: If the ancestor subgraph contains a cycle, making
|
|
114
|
+
topological sorting impossible.
|
|
115
|
+
"""
|
|
116
|
+
return self.topological_sort_subgraph(self.ancestors(target))
|
|
117
|
+
|
|
118
|
+
def ancestors(self, target: str) -> set[str]:
|
|
119
|
+
"""Find all nodes that have a directed path to the target node.
|
|
120
|
+
|
|
121
|
+
Collects every node that reaches the target directly or transitively.
|
|
122
|
+
The target itself is excluded from the result.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
target: Node to find ancestors for.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Set of all nodes with a directed path to the target, excluding the
|
|
129
|
+
target itself. Returns an empty set if the target is not in the graph.
|
|
130
|
+
"""
|
|
131
|
+
visited: set[str] = set()
|
|
132
|
+
queue: deque[str] = deque(self._reverse_edges.get(target, set()))
|
|
133
|
+
|
|
134
|
+
while queue:
|
|
135
|
+
node = queue.popleft()
|
|
136
|
+
if node not in visited:
|
|
137
|
+
visited.add(node)
|
|
138
|
+
# Iterate directly to avoid creating intermediate set
|
|
139
|
+
for neighbor in self._reverse_edges.get(node, set()):
|
|
140
|
+
if neighbor not in visited:
|
|
141
|
+
queue.append(neighbor)
|
|
142
|
+
|
|
143
|
+
return visited
|
|
144
|
+
|
|
145
|
+
def topological_sort_subgraph(self, nodes: set[str]) -> list[str]:
|
|
146
|
+
"""Sort a subset of nodes in topological order.
|
|
147
|
+
|
|
148
|
+
An edge A → B means "A depends on B", so B appears before A in the
|
|
149
|
+
output. The ordering is deterministic: when multiple nodes are ready to
|
|
150
|
+
be emitted at the same step, they are emitted in ascending order.
|
|
151
|
+
|
|
152
|
+
Only edges whose both endpoints are in `nodes` are considered; edges
|
|
153
|
+
to or from nodes outside the subset are ignored.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
nodes: The subset of nodes to sort.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
List of nodes in topological order, with each node's dependencies
|
|
160
|
+
appearing before the node itself.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
RuntimeError: If the subgraph contains a cycle, making topological
|
|
164
|
+
sorting impossible.
|
|
165
|
+
"""
|
|
166
|
+
# Count outgoing edges (dependencies) for each node in the subgraph
|
|
167
|
+
# Nodes with 0 outgoing edges have no dependencies
|
|
168
|
+
out_degree: dict[str, int] = dict.fromkeys(nodes, 0)
|
|
169
|
+
|
|
170
|
+
for node in nodes:
|
|
171
|
+
for dependency in self._edges.get(node, set()):
|
|
172
|
+
if dependency in nodes:
|
|
173
|
+
out_degree[node] += 1
|
|
174
|
+
|
|
175
|
+
# Use heapq for O(log n) insertion maintaining sorted order
|
|
176
|
+
# This replaces O(n log n) sort() + O(n) pop(0) with O(log n) heappop()
|
|
177
|
+
heap: list[str] = [node for node in nodes if out_degree[node] == 0]
|
|
178
|
+
heapq.heapify(heap)
|
|
179
|
+
result: list[str] = []
|
|
180
|
+
|
|
181
|
+
while heap:
|
|
182
|
+
node = heapq.heappop(heap)
|
|
183
|
+
result.append(node)
|
|
184
|
+
|
|
185
|
+
# For each package that depends on this node (reverse edges)
|
|
186
|
+
for dependent in self._reverse_edges.get(node, set()):
|
|
187
|
+
if dependent in nodes:
|
|
188
|
+
out_degree[dependent] -= 1
|
|
189
|
+
if out_degree[dependent] == 0:
|
|
190
|
+
heapq.heappush(heap, dependent)
|
|
191
|
+
|
|
192
|
+
# Check for cycles
|
|
193
|
+
if len(result) != len(nodes):
|
|
194
|
+
msg = (
|
|
195
|
+
"Graph contains a cycle; topological sort not possible. "
|
|
196
|
+
"This indicates a circular dependency among the following nodes: "
|
|
197
|
+
f"{set(nodes) - set(result)}"
|
|
198
|
+
)
|
|
199
|
+
raise RuntimeError(msg)
|
|
200
|
+
|
|
201
|
+
return result
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Introspection primitives for class, module, and package discovery.
|
|
2
|
+
|
|
3
|
+
Provides the low-level building blocks that power pyrig's cross-package subclass
|
|
4
|
+
discovery: walking package hierarchies, dynamically importing modules, filtering
|
|
5
|
+
class collections, and locating equivalent module paths across dependent packages.
|
|
6
|
+
"""
|