pyrig-runtime 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- pyrig_runtime-0.1.0/PKG-INFO +59 -0
- pyrig_runtime-0.1.0/README.md +33 -0
- pyrig_runtime-0.1.0/pyproject.toml +113 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/__init__.py +1 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/core/__init__.py +1 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/core/dependencies/__init__.py +6 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/core/dependencies/discovery.py +124 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/core/dependencies/graph.py +69 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/core/dependencies/subclass.py +180 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/core/graph.py +201 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/core/introspection/__init__.py +6 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/core/introspection/classes.py +105 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/core/introspection/functions.py +72 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/core/introspection/inspection.py +84 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/core/introspection/modules.py +124 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/core/introspection/packages.py +81 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/core/strings.py +53 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/py.typed +0 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/rig/__init__.py +1 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/__init__.py +1 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/cli/__init__.py +1 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/cli/cli.py +313 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/commands/__init__.py +1 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/commands/version.py +18 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/main.py +8 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/shared_subcommands.py +24 -0
- pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/subcommands.py +6 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Winipedia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyrig-runtime
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Keywords: pyrig
|
|
6
|
+
Author: Winipedia
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Programming Language :: Python
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: typer>=0.26.7
|
|
18
|
+
Maintainer: Winipedia
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Project-URL: Homepage, https://github.com/Winipedia/pyrig-runtime
|
|
21
|
+
Project-URL: Documentation, https://Winipedia.github.io/pyrig-runtime
|
|
22
|
+
Project-URL: Source, https://github.com/Winipedia/pyrig-runtime
|
|
23
|
+
Project-URL: Issues, https://github.com/Winipedia/pyrig-runtime/issues
|
|
24
|
+
Project-URL: Changelog, https://github.com/Winipedia/pyrig-runtime/releases
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# pyrig-runtime
|
|
28
|
+
|
|
29
|
+
<!-- ci/cd -->
|
|
30
|
+
[](https://github.com/Winipedia/pyrig-runtime/actions/workflows/health_check.yml)
|
|
31
|
+
[](https://github.com/Winipedia/pyrig-runtime/actions/workflows/deploy.yml)
|
|
32
|
+
<!-- testing -->
|
|
33
|
+
[](https://codecov.io/gh/Winipedia/pyrig-runtime)
|
|
34
|
+
[](https://pytest.org)
|
|
35
|
+
<!-- code-quality -->
|
|
36
|
+
[](https://github.com/pypa/pip-audit)
|
|
37
|
+
[](https://github.com/osprey-oss/deptry)
|
|
38
|
+
[](https://github.com/rvben/rumdl)
|
|
39
|
+
[](https://github.com/astral-sh/ruff)
|
|
40
|
+
[](https://github.com/PyCQA/bandit)
|
|
41
|
+
[](https://github.com/crate-ci/typos)
|
|
42
|
+
[](https://github.com/astral-sh/ty)
|
|
43
|
+
[](https://github.com/j178/prek)
|
|
44
|
+
<!-- tooling -->
|
|
45
|
+
[](https://github.com/astral-sh/uv)
|
|
46
|
+
[](https://github.com/Winipedia/pyrig)
|
|
47
|
+
[](https://github.com/Winipedia/pyrig-runtime)
|
|
48
|
+
[](https://git-scm.com)
|
|
49
|
+
<!-- project-info -->
|
|
50
|
+
[](https://Winipedia.github.io/pyrig-runtime)
|
|
51
|
+
[](https://pypi.org/project/pyrig-runtime)
|
|
52
|
+
[](https://www.python.org)
|
|
53
|
+
[](https://github.com/Winipedia/pyrig-runtime/blob/main/LICENSE)
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
> Add your description here
|
|
58
|
+
|
|
59
|
+
---
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# pyrig-runtime
|
|
2
|
+
|
|
3
|
+
<!-- ci/cd -->
|
|
4
|
+
[](https://github.com/Winipedia/pyrig-runtime/actions/workflows/health_check.yml)
|
|
5
|
+
[](https://github.com/Winipedia/pyrig-runtime/actions/workflows/deploy.yml)
|
|
6
|
+
<!-- testing -->
|
|
7
|
+
[](https://codecov.io/gh/Winipedia/pyrig-runtime)
|
|
8
|
+
[](https://pytest.org)
|
|
9
|
+
<!-- code-quality -->
|
|
10
|
+
[](https://github.com/pypa/pip-audit)
|
|
11
|
+
[](https://github.com/osprey-oss/deptry)
|
|
12
|
+
[](https://github.com/rvben/rumdl)
|
|
13
|
+
[](https://github.com/astral-sh/ruff)
|
|
14
|
+
[](https://github.com/PyCQA/bandit)
|
|
15
|
+
[](https://github.com/crate-ci/typos)
|
|
16
|
+
[](https://github.com/astral-sh/ty)
|
|
17
|
+
[](https://github.com/j178/prek)
|
|
18
|
+
<!-- tooling -->
|
|
19
|
+
[](https://github.com/astral-sh/uv)
|
|
20
|
+
[](https://github.com/Winipedia/pyrig)
|
|
21
|
+
[](https://github.com/Winipedia/pyrig-runtime)
|
|
22
|
+
[](https://git-scm.com)
|
|
23
|
+
<!-- project-info -->
|
|
24
|
+
[](https://Winipedia.github.io/pyrig-runtime)
|
|
25
|
+
[](https://pypi.org/project/pyrig-runtime)
|
|
26
|
+
[](https://www.python.org)
|
|
27
|
+
[](https://github.com/Winipedia/pyrig-runtime/blob/main/LICENSE)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
> Add your description here
|
|
32
|
+
|
|
33
|
+
---
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyrig-runtime"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"typer>=0.26.7",
|
|
9
|
+
]
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = [
|
|
12
|
+
"LICENSE",
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
keywords = [
|
|
25
|
+
"pyrig",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[[project.authors]]
|
|
29
|
+
name = "Winipedia"
|
|
30
|
+
|
|
31
|
+
[[project.maintainers]]
|
|
32
|
+
name = "Winipedia"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/Winipedia/pyrig-runtime"
|
|
36
|
+
Documentation = "https://Winipedia.github.io/pyrig-runtime"
|
|
37
|
+
Source = "https://github.com/Winipedia/pyrig-runtime"
|
|
38
|
+
Issues = "https://github.com/Winipedia/pyrig-runtime/issues"
|
|
39
|
+
Changelog = "https://github.com/Winipedia/pyrig-runtime/releases"
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
pyrig-runtime = "pyrig.rig.cli.main:main"
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = [
|
|
46
|
+
"bandit>=1.9.4",
|
|
47
|
+
"deptry>=0.25.1",
|
|
48
|
+
"mkdocs>=1.6.1",
|
|
49
|
+
"mkdocs-material>=9.7.6",
|
|
50
|
+
"mkdocs-mermaid2-plugin>=1.2.3",
|
|
51
|
+
"mkdocstrings[python]>=1.0.4",
|
|
52
|
+
"pip-audit>=2.10.1",
|
|
53
|
+
"prek>=0.4.5",
|
|
54
|
+
"pyrig>=15.23.0",
|
|
55
|
+
"pyrig-codecov>=1.10.0",
|
|
56
|
+
"pyrig-fixtures>=0.10.0",
|
|
57
|
+
"pyrig-pypi>=1.13.0",
|
|
58
|
+
"pytest>=9.1.1",
|
|
59
|
+
"pytest-cov>=7.1.0",
|
|
60
|
+
"ruff>=0.15.19",
|
|
61
|
+
"rumdl>=0.2.22",
|
|
62
|
+
"ty>=0.0.53",
|
|
63
|
+
"typos>=1.47.2",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[build-system]
|
|
67
|
+
requires = [
|
|
68
|
+
"uv_build",
|
|
69
|
+
]
|
|
70
|
+
build-backend = "uv_build"
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint]
|
|
73
|
+
select = [
|
|
74
|
+
"ALL",
|
|
75
|
+
]
|
|
76
|
+
ignore = [
|
|
77
|
+
"COM812",
|
|
78
|
+
"ANN401",
|
|
79
|
+
]
|
|
80
|
+
fixable = [
|
|
81
|
+
"ALL",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
[tool.ruff.lint.per-file-ignores]
|
|
85
|
+
"**/tests/**/*.py" = [
|
|
86
|
+
"S101",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
[tool.ruff.lint.pydocstyle]
|
|
90
|
+
convention = "google"
|
|
91
|
+
|
|
92
|
+
[tool.ty.terminal]
|
|
93
|
+
error-on-warning = true
|
|
94
|
+
|
|
95
|
+
[tool.pytest.ini_options]
|
|
96
|
+
testpaths = [
|
|
97
|
+
"tests",
|
|
98
|
+
]
|
|
99
|
+
addopts = "--cov=pyrig_runtime --cov-branch --cov-report=term-missing --cov-fail-under=100 --cov-report=xml"
|
|
100
|
+
|
|
101
|
+
[tool.bandit.assert_used]
|
|
102
|
+
skips = [
|
|
103
|
+
"*/tests/*.py",
|
|
104
|
+
"*/test_*/*.py",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
[tool.deptry]
|
|
108
|
+
root = "src"
|
|
109
|
+
|
|
110
|
+
[tool.deptry.per_rule_ignores]
|
|
111
|
+
DEP002 = [
|
|
112
|
+
"pyrig",
|
|
113
|
+
]
|
|
@@ -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())
|