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.
- pyspokes-0.1.0a2/PKG-INFO +120 -0
- pyspokes-0.1.0a2/README.md +108 -0
- pyspokes-0.1.0a2/pyproject.toml +75 -0
- pyspokes-0.1.0a2/pyproject.toml.orig +75 -0
- pyspokes-0.1.0a2/src/pyspokes/__init__.py +49 -0
- pyspokes-0.1.0a2/src/pyspokes/_constants.py +4 -0
- pyspokes-0.1.0a2/src/pyspokes/bindings.py +147 -0
- pyspokes-0.1.0a2/src/pyspokes/cli/__init__.py +20 -0
- pyspokes-0.1.0a2/src/pyspokes/cli/app.py +44 -0
- pyspokes-0.1.0a2/src/pyspokes/cli/check.py +179 -0
- pyspokes-0.1.0a2/src/pyspokes/cli/executor.py +246 -0
- pyspokes-0.1.0a2/src/pyspokes/cli/inventory.py +14 -0
- pyspokes-0.1.0a2/src/pyspokes/cli/scanner.py +125 -0
- pyspokes-0.1.0a2/src/pyspokes/config.py +33 -0
- pyspokes-0.1.0a2/src/pyspokes/definitions.py +87 -0
- pyspokes-0.1.0a2/src/pyspokes/discovery.py +245 -0
- pyspokes-0.1.0a2/src/pyspokes/exceptions.py +25 -0
- pyspokes-0.1.0a2/src/pyspokes/hub.py +334 -0
- pyspokes-0.1.0a2/src/pyspokes/inventory.py +139 -0
- pyspokes-0.1.0a2/src/pyspokes/py.typed +0 -0
- pyspokes-0.1.0a2/src/pyspokes/resolution_log.py +41 -0
- pyspokes-0.1.0a2/src/pyspokes/resolutions.py +83 -0
|
@@ -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
|
+

|
|
14
|
+

|
|
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
|
+

|
|
2
|
+

|
|
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,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()
|