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.
Files changed (28) hide show
  1. pyrig_runtime-0.1.0/LICENSE +21 -0
  2. pyrig_runtime-0.1.0/PKG-INFO +59 -0
  3. pyrig_runtime-0.1.0/README.md +33 -0
  4. pyrig_runtime-0.1.0/pyproject.toml +113 -0
  5. pyrig_runtime-0.1.0/src/pyrig_runtime/__init__.py +1 -0
  6. pyrig_runtime-0.1.0/src/pyrig_runtime/core/__init__.py +1 -0
  7. pyrig_runtime-0.1.0/src/pyrig_runtime/core/dependencies/__init__.py +6 -0
  8. pyrig_runtime-0.1.0/src/pyrig_runtime/core/dependencies/discovery.py +124 -0
  9. pyrig_runtime-0.1.0/src/pyrig_runtime/core/dependencies/graph.py +69 -0
  10. pyrig_runtime-0.1.0/src/pyrig_runtime/core/dependencies/subclass.py +180 -0
  11. pyrig_runtime-0.1.0/src/pyrig_runtime/core/graph.py +201 -0
  12. pyrig_runtime-0.1.0/src/pyrig_runtime/core/introspection/__init__.py +6 -0
  13. pyrig_runtime-0.1.0/src/pyrig_runtime/core/introspection/classes.py +105 -0
  14. pyrig_runtime-0.1.0/src/pyrig_runtime/core/introspection/functions.py +72 -0
  15. pyrig_runtime-0.1.0/src/pyrig_runtime/core/introspection/inspection.py +84 -0
  16. pyrig_runtime-0.1.0/src/pyrig_runtime/core/introspection/modules.py +124 -0
  17. pyrig_runtime-0.1.0/src/pyrig_runtime/core/introspection/packages.py +81 -0
  18. pyrig_runtime-0.1.0/src/pyrig_runtime/core/strings.py +53 -0
  19. pyrig_runtime-0.1.0/src/pyrig_runtime/py.typed +0 -0
  20. pyrig_runtime-0.1.0/src/pyrig_runtime/rig/__init__.py +1 -0
  21. pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/__init__.py +1 -0
  22. pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/cli/__init__.py +1 -0
  23. pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/cli/cli.py +313 -0
  24. pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/commands/__init__.py +1 -0
  25. pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/commands/version.py +18 -0
  26. pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/main.py +8 -0
  27. pyrig_runtime-0.1.0/src/pyrig_runtime/rig/cli/shared_subcommands.py +24 -0
  28. 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
+ [![CI](https://img.shields.io/github/actions/workflow/status/Winipedia/pyrig-runtime/health_check.yml?label=CI&logo=github)](https://github.com/Winipedia/pyrig-runtime/actions/workflows/health_check.yml)
31
+ [![CD](https://img.shields.io/github/actions/workflow/status/Winipedia/pyrig-runtime/deploy.yml?label=CD&logo=github)](https://github.com/Winipedia/pyrig-runtime/actions/workflows/deploy.yml)
32
+ <!-- testing -->
33
+ [![CoverageTester](https://codecov.io/gh/Winipedia/pyrig-runtime/branch/main/graph/badge.svg)](https://codecov.io/gh/Winipedia/pyrig-runtime)
34
+ [![ProjectTester](https://img.shields.io/badge/tested%20with-pytest-46a2f1.svg?logo=pytest)](https://pytest.org)
35
+ <!-- code-quality -->
36
+ [![DependencyAuditor](https://img.shields.io/badge/security-pip--audit-blue?logo=python)](https://github.com/pypa/pip-audit)
37
+ [![DependencyChecker](https://img.shields.io/badge/dependencies-deptry-blue)](https://github.com/osprey-oss/deptry)
38
+ [![MarkdownLinter](https://img.shields.io/badge/markdown-rumdl-darkgreen)](https://github.com/rvben/rumdl)
39
+ [![PythonLinter](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
40
+ [![SecurityChecker](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit)
41
+ [![SpellChecker](https://img.shields.io/badge/spell--check-typos-blue)](https://github.com/crate-ci/typos)
42
+ [![TypeChecker](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
43
+ [![VersionControlHookManager](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json)](https://github.com/j178/prek)
44
+ <!-- tooling -->
45
+ [![PackageManager](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
46
+ [![Pyrigger](https://img.shields.io/badge/built%20with-pyrig-3776AB?logo=buildkite&logoColor=black)](https://github.com/Winipedia/pyrig)
47
+ [![RemoteVersionController](https://img.shields.io/github/stars/Winipedia/pyrig-runtime?style=social)](https://github.com/Winipedia/pyrig-runtime)
48
+ [![VersionController](https://img.shields.io/badge/Git-F05032?logo=git&logoColor=white)](https://git-scm.com)
49
+ <!-- project-info -->
50
+ [![DocsBuilder](https://img.shields.io/badge/MkDocs-Documentation-326CE5?logo=mkdocs&logoColor=white)](https://Winipedia.github.io/pyrig-runtime)
51
+ [![PackageIndex](https://img.shields.io/pypi/v/pyrig-runtime?logo=pypi&logoColor=white)](https://pypi.org/project/pyrig-runtime)
52
+ [![ProgrammingLanguage](https://img.shields.io/pypi/pyversions/pyrig-runtime)](https://www.python.org)
53
+ [![License](https://img.shields.io/github/license/Winipedia/pyrig-runtime)](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
+ [![CI](https://img.shields.io/github/actions/workflow/status/Winipedia/pyrig-runtime/health_check.yml?label=CI&logo=github)](https://github.com/Winipedia/pyrig-runtime/actions/workflows/health_check.yml)
5
+ [![CD](https://img.shields.io/github/actions/workflow/status/Winipedia/pyrig-runtime/deploy.yml?label=CD&logo=github)](https://github.com/Winipedia/pyrig-runtime/actions/workflows/deploy.yml)
6
+ <!-- testing -->
7
+ [![CoverageTester](https://codecov.io/gh/Winipedia/pyrig-runtime/branch/main/graph/badge.svg)](https://codecov.io/gh/Winipedia/pyrig-runtime)
8
+ [![ProjectTester](https://img.shields.io/badge/tested%20with-pytest-46a2f1.svg?logo=pytest)](https://pytest.org)
9
+ <!-- code-quality -->
10
+ [![DependencyAuditor](https://img.shields.io/badge/security-pip--audit-blue?logo=python)](https://github.com/pypa/pip-audit)
11
+ [![DependencyChecker](https://img.shields.io/badge/dependencies-deptry-blue)](https://github.com/osprey-oss/deptry)
12
+ [![MarkdownLinter](https://img.shields.io/badge/markdown-rumdl-darkgreen)](https://github.com/rvben/rumdl)
13
+ [![PythonLinter](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
14
+ [![SecurityChecker](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit)
15
+ [![SpellChecker](https://img.shields.io/badge/spell--check-typos-blue)](https://github.com/crate-ci/typos)
16
+ [![TypeChecker](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
17
+ [![VersionControlHookManager](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json)](https://github.com/j178/prek)
18
+ <!-- tooling -->
19
+ [![PackageManager](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
20
+ [![Pyrigger](https://img.shields.io/badge/built%20with-pyrig-3776AB?logo=buildkite&logoColor=black)](https://github.com/Winipedia/pyrig)
21
+ [![RemoteVersionController](https://img.shields.io/github/stars/Winipedia/pyrig-runtime?style=social)](https://github.com/Winipedia/pyrig-runtime)
22
+ [![VersionController](https://img.shields.io/badge/Git-F05032?logo=git&logoColor=white)](https://git-scm.com)
23
+ <!-- project-info -->
24
+ [![DocsBuilder](https://img.shields.io/badge/MkDocs-Documentation-326CE5?logo=mkdocs&logoColor=white)](https://Winipedia.github.io/pyrig-runtime)
25
+ [![PackageIndex](https://img.shields.io/pypi/v/pyrig-runtime?logo=pypi&logoColor=white)](https://pypi.org/project/pyrig-runtime)
26
+ [![ProgrammingLanguage](https://img.shields.io/pypi/pyversions/pyrig-runtime)](https://www.python.org)
27
+ [![License](https://img.shields.io/github/license/Winipedia/pyrig-runtime)](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())