pyspokes 0.1.0a2__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.
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.3
2
+ Name: pyspokes
3
+ Version: 0.1.0a2
4
+ Summary: Type-based spoke discovery and resolution for Python applications
5
+ Author: AI Helpers
6
+ Requires-Dist: pyyaml>=6.0.1,<7
7
+ Requires-Dist: typer>=0.20.0,<0.21 ; extra == 'cli'
8
+ Requires-Dist: rich>=13.0.0,<16 ; extra == 'cli'
9
+ Requires-Python: >=3.13
10
+ Provides-Extra: cli
11
+ Description-Content-Type: text/markdown
12
+
13
+ ![Tests](docs/_static/badges/test-badge.svg)
14
+ ![Coverage](docs/_static/badges/coverage-badge.svg)
15
+
16
+ # pyspokes
17
+
18
+ pyspokes is a plugin system built around one idea: your application is the hub, and every implementation it can plug in is a spoke.
19
+ Packages declare their spokes in a `spokes.yaml` file, and the application asks the hub for the interface it needs.
20
+
21
+ See the full documentation at <https://ai-helpers.github.io/pyspokes>
22
+
23
+ ## Table of Contents (ToC)
24
+
25
+ - [pyspokes](#pyspokes)
26
+ - [Table of Contents (ToC)](#table-of-contents-toc)
27
+ - [Usage](#usage)
28
+ - [Getting Started](#getting-started)
29
+ - [Prerequisites](#prerequisites)
30
+ - [Install dependencies](#install-dependencies)
31
+ - [Common tasks](#common-tasks)
32
+ - [Build the documentation](#build-the-documentation)
33
+ - [Contributing](#contributing)
34
+
35
+ ## Usage
36
+
37
+ A package declares its spokes in a `spokes.yaml` file at its root:
38
+
39
+ ```yaml
40
+ # notify_app/spokes.yaml
41
+ - path: channels.EmailNotifier
42
+ name: email
43
+
44
+ - path: channels.SlackNotifier
45
+ name: slack
46
+ priority: 5
47
+ ```
48
+
49
+ ```python
50
+ # notify_app/channels.py
51
+ from notify_app.notifier import Notifier
52
+
53
+
54
+ class EmailNotifier(Notifier):
55
+ def send(self, recipient: str, message: str) -> str:
56
+ return f"email to {recipient}: {message}"
57
+
58
+
59
+ class SlackNotifier(Notifier):
60
+ def send(self, recipient: str, message: str) -> str:
61
+ return f"slack DM to {recipient}: {message}"
62
+ ```
63
+
64
+ Once the package is installed, the application asks the hub for the interface and never imports
65
+ an implementation:
66
+
67
+ ```python
68
+ from notify_app.notifier import Notifier
69
+ from pyspokes import get_spoke, get_spokes
70
+
71
+ notifier = get_spoke(Notifier) # highest priority: SlackNotifier
72
+ print(notifier.send("ada", "build finished")) # slack DM to ada: build finished
73
+
74
+ email = get_spoke(Notifier, name="email") # pick one by name
75
+ channels = get_spokes(Notifier) # all of them, highest priority first
76
+ ```
77
+
78
+ Adding a channel means installing a package that ships a `spokes.yaml`; the code above does not
79
+ change. The [quickstart](https://ai-helpers.github.io/pyspokes/quickstart.html) walks through the
80
+ whole example.
81
+
82
+ ## Getting Started
83
+
84
+ ### Prerequisites
85
+
86
+ - Python 3.13+
87
+ - [uv](https://docs.astral.sh/uv/)
88
+
89
+ ### Install dependencies
90
+
91
+ ```bash
92
+ uv sync --all-groups
93
+ ```
94
+
95
+ ### Common tasks
96
+
97
+ Day-to-day commands are exposed as make tasks (run `make help` to list them all):
98
+
99
+ ```bash
100
+ make check-tests # run the unit tests with pytest
101
+ make checkers # run all the checkers (lock, format, quality, types, coverage)
102
+ make formatters # format the sources and tests with ruff
103
+ make badges # regenerate the test and coverage badges shown above
104
+ make documenters # build the documentation
105
+ ```
106
+
107
+ ### Build the documentation
108
+
109
+ The documentation is built with [Sphinx](https://www.sphinx-doc.org/) and the
110
+ [furo](https://github.com/pradyunsg/furo) theme, and is deployed to GitHub Pages
111
+ on every push to `main`.
112
+
113
+ ```bash
114
+ make documenters
115
+ ```
116
+
117
+ ## Contributing
118
+
119
+ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup,
120
+ the checks to run before pushing, and the coding conventions.
@@ -0,0 +1,108 @@
1
+ ![Tests](docs/_static/badges/test-badge.svg)
2
+ ![Coverage](docs/_static/badges/coverage-badge.svg)
3
+
4
+ # pyspokes
5
+
6
+ pyspokes is a plugin system built around one idea: your application is the hub, and every implementation it can plug in is a spoke.
7
+ Packages declare their spokes in a `spokes.yaml` file, and the application asks the hub for the interface it needs.
8
+
9
+ See the full documentation at <https://ai-helpers.github.io/pyspokes>
10
+
11
+ ## Table of Contents (ToC)
12
+
13
+ - [pyspokes](#pyspokes)
14
+ - [Table of Contents (ToC)](#table-of-contents-toc)
15
+ - [Usage](#usage)
16
+ - [Getting Started](#getting-started)
17
+ - [Prerequisites](#prerequisites)
18
+ - [Install dependencies](#install-dependencies)
19
+ - [Common tasks](#common-tasks)
20
+ - [Build the documentation](#build-the-documentation)
21
+ - [Contributing](#contributing)
22
+
23
+ ## Usage
24
+
25
+ A package declares its spokes in a `spokes.yaml` file at its root:
26
+
27
+ ```yaml
28
+ # notify_app/spokes.yaml
29
+ - path: channels.EmailNotifier
30
+ name: email
31
+
32
+ - path: channels.SlackNotifier
33
+ name: slack
34
+ priority: 5
35
+ ```
36
+
37
+ ```python
38
+ # notify_app/channels.py
39
+ from notify_app.notifier import Notifier
40
+
41
+
42
+ class EmailNotifier(Notifier):
43
+ def send(self, recipient: str, message: str) -> str:
44
+ return f"email to {recipient}: {message}"
45
+
46
+
47
+ class SlackNotifier(Notifier):
48
+ def send(self, recipient: str, message: str) -> str:
49
+ return f"slack DM to {recipient}: {message}"
50
+ ```
51
+
52
+ Once the package is installed, the application asks the hub for the interface and never imports
53
+ an implementation:
54
+
55
+ ```python
56
+ from notify_app.notifier import Notifier
57
+ from pyspokes import get_spoke, get_spokes
58
+
59
+ notifier = get_spoke(Notifier) # highest priority: SlackNotifier
60
+ print(notifier.send("ada", "build finished")) # slack DM to ada: build finished
61
+
62
+ email = get_spoke(Notifier, name="email") # pick one by name
63
+ channels = get_spokes(Notifier) # all of them, highest priority first
64
+ ```
65
+
66
+ Adding a channel means installing a package that ships a `spokes.yaml`; the code above does not
67
+ change. The [quickstart](https://ai-helpers.github.io/pyspokes/quickstart.html) walks through the
68
+ whole example.
69
+
70
+ ## Getting Started
71
+
72
+ ### Prerequisites
73
+
74
+ - Python 3.13+
75
+ - [uv](https://docs.astral.sh/uv/)
76
+
77
+ ### Install dependencies
78
+
79
+ ```bash
80
+ uv sync --all-groups
81
+ ```
82
+
83
+ ### Common tasks
84
+
85
+ Day-to-day commands are exposed as make tasks (run `make help` to list them all):
86
+
87
+ ```bash
88
+ make check-tests # run the unit tests with pytest
89
+ make checkers # run all the checkers (lock, format, quality, types, coverage)
90
+ make formatters # format the sources and tests with ruff
91
+ make badges # regenerate the test and coverage badges shown above
92
+ make documenters # build the documentation
93
+ ```
94
+
95
+ ### Build the documentation
96
+
97
+ The documentation is built with [Sphinx](https://www.sphinx-doc.org/) and the
98
+ [furo](https://github.com/pradyunsg/furo) theme, and is deployed to GitHub Pages
99
+ on every push to `main`.
100
+
101
+ ```bash
102
+ make documenters
103
+ ```
104
+
105
+ ## Contributing
106
+
107
+ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup,
108
+ the checks to run before pushing, and the coding conventions.
@@ -0,0 +1,75 @@
1
+ [project]
2
+ name = "pyspokes"
3
+ version = "0.1.0a2"
4
+ description = "Type-based spoke discovery and resolution for Python applications"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = ["pyyaml>=6.0.1,<7"]
8
+
9
+ [[project.authors]]
10
+ name = "AI Helpers"
11
+
12
+ [project.optional-dependencies]
13
+ cli = [
14
+ "typer>=0.20.0,<0.21",
15
+ "rich>=13.0.0,<16",
16
+ ]
17
+
18
+ [project.scripts]
19
+ pyspokes = "pyspokes.cli:main"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "ruff>=0.8.2,<0.9",
24
+ "mypy>=1.13,<2",
25
+ "types-pyyaml>=6.0.12",
26
+ ]
27
+ test = [
28
+ "pyspokes[cli]",
29
+ "pytest>=8.3.4,<9",
30
+ "coverage>=7.11.0,<8",
31
+ "anybadge>=1.16.0,<2",
32
+ ]
33
+ docs = [
34
+ "sphinx>=8.2,<9",
35
+ "furo>=2024.8.6",
36
+ "myst-parser>=4.0,<5",
37
+ ]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+
42
+ [tool.mypy]
43
+ files = ["src"]
44
+ check_untyped_defs = true
45
+
46
+ [tool.ruff]
47
+ line-length = 150
48
+
49
+ [tool.ruff.lint]
50
+ select = [
51
+ "E",
52
+ "W",
53
+ "F",
54
+ "I",
55
+ "B",
56
+ "C4",
57
+ "N",
58
+ "D",
59
+ "UP",
60
+ ]
61
+ ignore = [
62
+ "C901",
63
+ "W191",
64
+ "D401",
65
+ "D205",
66
+ "D100",
67
+ "D104",
68
+ ]
69
+
70
+ [tool.ruff.lint.pydocstyle]
71
+ convention = "pep257"
72
+
73
+ [build-system]
74
+ requires = ["uv_build>=0.8.0,<1"]
75
+ build-backend = "uv_build"
@@ -0,0 +1,75 @@
1
+ [project]
2
+ name = "pyspokes"
3
+ version = "0.1.0a2"
4
+ description = "Type-based spoke discovery and resolution for Python applications"
5
+ authors = [{ name = "AI Helpers" }]
6
+ readme = "README.md"
7
+ requires-python = ">=3.13"
8
+ dependencies = [
9
+ "pyyaml>=6.0.1,<7",
10
+ ]
11
+
12
+ [project.optional-dependencies]
13
+ cli = [
14
+ "typer>=0.20.0,<0.21",
15
+ "rich>=13.0.0,<16",
16
+ ]
17
+
18
+ [project.scripts]
19
+ pyspokes = "pyspokes.cli:main"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "ruff>=0.8.2,<0.9",
24
+ "mypy>=1.13,<2",
25
+ "types-pyyaml>=6.0.12",
26
+ ]
27
+ test = [
28
+ "pyspokes[cli]",
29
+ "pytest>=8.3.4,<9",
30
+ "coverage>=7.11.0,<8",
31
+ "anybadge>=1.16.0,<2",
32
+ ]
33
+ docs = [
34
+ "sphinx>=8.2,<9",
35
+ "furo>=2024.8.6",
36
+ "myst-parser>=4.0,<5",
37
+ ]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+
42
+ [tool.mypy]
43
+ files = ["src"]
44
+ check_untyped_defs = true
45
+
46
+ [tool.ruff]
47
+ line-length = 150
48
+
49
+ [tool.ruff.lint]
50
+ select = [
51
+ "E", # pycodestyle errors
52
+ "W", # pycodestyle warnings
53
+ "F", # pyflakes
54
+ "I", # isort
55
+ "B", # flake8-bugbear
56
+ "C4", # flake8-comprehensions
57
+ "N", # PEP8 naming convetions
58
+ "D", # pydocstyle
59
+ "UP", # pyupgrade
60
+ ]
61
+ ignore = [
62
+ "C901", # too complex
63
+ "W191", # indentation contains tabs
64
+ "D401", # imperative mood
65
+ "D205", # 1 blank line required between summary line and description
66
+ "D100", # Missing docstring in public module
67
+ "D104", # Missing docstring in public package
68
+ ]
69
+
70
+ [tool.ruff.lint.pydocstyle]
71
+ convention = "pep257"
72
+
73
+ [build-system]
74
+ requires = ["uv_build>=0.8.0,<1"]
75
+ build-backend = "uv_build"
@@ -0,0 +1,49 @@
1
+ from ._constants import LOGGER_NAME
2
+ from .bindings import SpokeBinding, SpokeDef
3
+ from .exceptions import (
4
+ PySpokesError,
5
+ SpokeDefinitionError,
6
+ SpokeLoadError,
7
+ SpokeNotFoundError,
8
+ SpokeRawMismatchError,
9
+ )
10
+ from .hub import (
11
+ UNNAMED,
12
+ Hub,
13
+ disable_discovery,
14
+ get_resolutions,
15
+ get_spoke,
16
+ get_spokes,
17
+ hub,
18
+ register_spoke,
19
+ spokes_by_name,
20
+ track_resolutions,
21
+ )
22
+ from .resolutions import Resolution
23
+
24
+ __all__ = [
25
+ # Resolving and registering spokes
26
+ "get_spoke",
27
+ "get_spokes",
28
+ "register_spoke",
29
+ "disable_discovery",
30
+ "spokes_by_name",
31
+ "UNNAMED",
32
+ # Resolution tracking
33
+ "get_resolutions",
34
+ "track_resolutions",
35
+ "Resolution",
36
+ # Types
37
+ "SpokeDef",
38
+ "SpokeBinding",
39
+ "Hub",
40
+ "hub",
41
+ # Errors
42
+ "PySpokesError",
43
+ "SpokeDefinitionError",
44
+ "SpokeLoadError",
45
+ "SpokeNotFoundError",
46
+ "SpokeRawMismatchError",
47
+ # Logging
48
+ "LOGGER_NAME",
49
+ ]
@@ -0,0 +1,4 @@
1
+ """Shared package constants."""
2
+
3
+ ENV_VARS_PREFIX = "PYSPOKES_"
4
+ LOGGER_NAME = "pyspokes"
@@ -0,0 +1,147 @@
1
+ """Core spoke definitions and runtime wrappers."""
2
+
3
+ import importlib
4
+ import inspect
5
+ import logging
6
+ import time
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from typing import Any, Generic, TypeVar
10
+
11
+ from ._constants import LOGGER_NAME
12
+ from .exceptions import SpokeLoadError
13
+
14
+ logger = logging.getLogger(LOGGER_NAME)
15
+
16
+
17
+ @dataclass
18
+ class SpokeDef:
19
+ """One spoke declaration, as written in ``spokes.yaml`` or passed to ``register_spoke``.
20
+
21
+ :param path: Location of the class or function, relative to its package.
22
+ :param name: A label requests can filter on.
23
+ :param priority: Higher wins when several spokes match the same request.
24
+ :param raw: Hand out the class or function itself instead of calling it.
25
+ """
26
+
27
+ path: str
28
+ name: str | None = None
29
+ priority: int = 1
30
+ raw: bool = False
31
+
32
+
33
+ T = TypeVar("T")
34
+
35
+
36
+ class SpokeBinding(Generic[T]):
37
+ """A declaration bound to the class or function it points to.
38
+
39
+ :ivar def_: The declaration.
40
+ :ivar module: The package that provides the spoke.
41
+ :ivar class_or_method: The imported class or function.
42
+ :ivar type_: The type the spoke answers to.
43
+ """
44
+
45
+ def __init__(self, spoke_def: SpokeDef, module: str, class_or_method: type | Callable | None = None):
46
+ """
47
+ Create a SpokeBinding instance.
48
+
49
+ :param spoke_def: The spoke definition
50
+ :param module: The name of the module where the spoke is defined
51
+ :param class_or_method: Optional direct reference to the class or method (for __main__ module)
52
+ """
53
+ self._obj: Any = None
54
+ self.type_: type[T] | None = None
55
+ self.class_or_method: Callable[..., Any]
56
+ self.def_: SpokeDef = spoke_def
57
+ self.module = module
58
+
59
+ names = spoke_def.path.rsplit(".", 1)
60
+ module = module + ("." + names[0] if len(names) > 1 else "")
61
+
62
+ try:
63
+ start = time.time()
64
+ if class_or_method is not None:
65
+ self.class_or_method = class_or_method
66
+ logger.debug(f"registering {class_or_method} by direct reference")
67
+ else:
68
+ self.class_or_method = getattr(importlib.import_module(module), names[-1])
69
+ logger.debug(f"imported {module} in {time.time() - start:.3f}s")
70
+ self.type_ = SpokeBinding.get_type(self.class_or_method)
71
+ obj = None
72
+ if not self.type_:
73
+ obj = self.class_or_method()
74
+
75
+ if obj is not None:
76
+ self._obj = obj
77
+ self.type_ = SpokeBinding.get_type(type(obj))
78
+ logger.warning(
79
+ "%s has no return annotation, so it was called at registration time to find its type. "
80
+ "Annotate its return type to defer the call until the spoke is requested.",
81
+ self.class_or_method,
82
+ )
83
+
84
+ logger.debug(f"resolved spoke target {self.class_or_method}")
85
+ except Exception as error:
86
+ raise SpokeLoadError(f"Cannot import {names[-1]} from {module}: {type(error).__name__}: {error}") from error
87
+
88
+ def get(self, singleton: bool = True) -> Any:
89
+ """Return the spoke's value, building it on first use; a generator yields its next item."""
90
+ result: Any = None
91
+ if singleton and self._obj is not None:
92
+ result = self._obj
93
+
94
+ if result is None:
95
+ result = self.class_or_method if self.def_.raw else self.class_or_method()
96
+
97
+ if singleton:
98
+ self._obj = result
99
+
100
+ if inspect.isgenerator(result):
101
+ return next(result)
102
+
103
+ return result
104
+
105
+ def matches(self, target_type: type) -> bool:
106
+ """Return whether this spoke can answer a request for ``target_type``."""
107
+ if self.type_ == target_type:
108
+ return True
109
+ # Special case: if target_type is 'type', match any class
110
+ if target_type is type and inspect.isclass(self.type_):
111
+ return True
112
+ try:
113
+ spoke_type = SpokeBinding.get_type(self.class_or_method)
114
+ if spoke_type and inspect.isclass(spoke_type) and inspect.isclass(target_type):
115
+ return issubclass(spoke_type, target_type)
116
+ except TypeError:
117
+ # issubclass raises TypeError for non-class types
118
+ pass
119
+ return False
120
+
121
+ @staticmethod
122
+ def get_type(spoke):
123
+ """Return the type represented by a spoke class, function, or instance."""
124
+ if inspect.isclass(spoke):
125
+ return spoke
126
+ if inspect.isfunction(spoke) or inspect.ismethod(spoke):
127
+ signature = inspect.signature(spoke)
128
+ return None if signature.return_annotation is inspect.Signature.empty else signature.return_annotation
129
+ return type(spoke)
130
+
131
+ def __str__(self):
132
+ """Return the spoke metadata as a formatted string."""
133
+ return (
134
+ f"Module: {self.module}\n"
135
+ f"Class or Method: {self.class_or_method}\n"
136
+ f"Type: {self.type_}\n"
137
+ f"Object: {self._obj}\n"
138
+ f"SpokeBinding Definition:\n"
139
+ f" Name: {self.def_.name}\n"
140
+ f" Path: {self.def_.path}\n"
141
+ f" Priority: {self.def_.priority}\n"
142
+ f" Raw: {self.def_.raw}\n"
143
+ )
144
+
145
+ def __repr__(self):
146
+ """Return the official string representation."""
147
+ return self.__str__()
@@ -0,0 +1,20 @@
1
+ """Command-line interface for pyspokes.
2
+
3
+ The CLI needs the optional ``cli`` extra (``pip install "pyspokes[cli]"``). Nothing in this
4
+ package is imported by the core library, so ``import pyspokes`` never requires it.
5
+ """
6
+
7
+ import sys
8
+
9
+ _CLI_DEPENDENCIES = {"typer", "rich", "click"}
10
+
11
+
12
+ def main():
13
+ """Main entry point for the CLI."""
14
+ try:
15
+ from .app import run
16
+ except ModuleNotFoundError as error:
17
+ if error.name is None or error.name.split(".")[0] not in _CLI_DEPENDENCIES:
18
+ raise
19
+ sys.exit(f"The pyspokes command line needs the 'cli' extra ({error.name} is not installed).\nInstall it with: pip install \"pyspokes[cli]\"")
20
+ run()
@@ -0,0 +1,44 @@
1
+ """Typer application wiring the pyspokes commands together."""
2
+
3
+ import logging
4
+ from logging import ERROR
5
+
6
+ import typer
7
+
8
+ from pyspokes._constants import LOGGER_NAME
9
+
10
+ from .check import check_spoke as check_spoke_cmd
11
+ from .inventory import inventory as inventory_cmd
12
+
13
+ cli_app = typer.Typer()
14
+
15
+ logger = logging.getLogger(LOGGER_NAME)
16
+ if not logger.hasHandlers():
17
+ handler = logging.StreamHandler()
18
+ formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
19
+ handler.setFormatter(formatter)
20
+ logger.addHandler(handler)
21
+
22
+ # Register commands
23
+ cli_app.command()(inventory_cmd)
24
+ cli_app.command(name="check")(check_spoke_cmd)
25
+
26
+
27
+ @cli_app.command(name="status")
28
+ def status_cmd(
29
+ path: str | None = typer.Argument(None, help="Path to scan for spoke invocations. Defaults to current path."),
30
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"),
31
+ ):
32
+ """Check spoke status (alias for check --format table)."""
33
+ return check_spoke_cmd(path=path, format="table", verbose=verbose)
34
+
35
+
36
+ def run():
37
+ """Run the CLI application."""
38
+ # Keep CLI logging quiet without changing the library logger during import.
39
+ logger.setLevel(ERROR)
40
+ cli_app()
41
+
42
+
43
+ if __name__ == "__main__":
44
+ run()