click-async-plugins 0.7.3__tar.gz → 0.8.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.
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/.gitignore +1 -0
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/.pre-commit-config.yaml +2 -2
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/PKG-INFO +62 -6
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/README.md +60 -4
- click_async_plugins-0.8.0/_version.py +24 -0
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/__init__.py +4 -0
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/command.py +6 -0
- click_async_plugins-0.8.0/click_async_plugins/dependencies.py +114 -0
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/itc.py +9 -4
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/util.py +11 -1
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/demo.py +2 -0
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/pyproject.toml +10 -4
- click_async_plugins-0.8.0/tests/test_command_group_core.py +195 -0
- click_async_plugins-0.8.0/tests/test_debug.py +321 -0
- click_async_plugins-0.8.0/tests/test_dependencies.py +205 -0
- click_async_plugins-0.8.0/tests/test_itc.py +209 -0
- click_async_plugins-0.8.0/tests/test_util.py +299 -0
- click_async_plugins-0.7.3/.github/workflows/python-package.yml +0 -88
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/LICENSE +0 -0
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/core.py +0 -0
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/debug.py +0 -0
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/group.py +0 -0
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/py.typed +0 -0
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/typedefs.py +0 -0
- {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/tests/test_typedefs.py +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# See https://pre-commit.com/hooks.html for more hooks
|
|
3
3
|
repos:
|
|
4
4
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
|
5
|
-
rev:
|
|
5
|
+
rev: v2.3.1
|
|
6
6
|
hooks:
|
|
7
7
|
- id: mypy
|
|
8
8
|
additional_dependencies:
|
|
@@ -10,7 +10,7 @@ repos:
|
|
|
10
10
|
- pytest
|
|
11
11
|
- pytest-asyncio
|
|
12
12
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
13
|
-
rev: v0.
|
|
13
|
+
rev: v0.16.4
|
|
14
14
|
hooks:
|
|
15
15
|
- id: ruff-check
|
|
16
16
|
args: [--show-fixes]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: click-async-plugins
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: An architecture to easily run asyncio tasks from Click
|
|
5
5
|
Author-email: "martin f. krafft" <click-async-plugins@pobox.madduck.net>
|
|
6
6
|
License-File: LICENSE
|
|
@@ -21,7 +21,7 @@ Requires-Dist: pytest-ruff; extra == 'dev'
|
|
|
21
21
|
Requires-Dist: ruff; extra == 'dev'
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
|
|
24
|
-
[](https://github.com/madduck/click-async-plugins/actions/workflows/0-testing.yml)
|
|
25
25
|
|
|
26
26
|
# click-async-plugins
|
|
27
27
|
|
|
@@ -37,7 +37,7 @@ async def myplugin(sleep: float) -> PluginLifespan:
|
|
|
37
37
|
|
|
38
38
|
# code to set things up goes here
|
|
39
39
|
|
|
40
|
-
async def long_running_task(*, sleep: float):
|
|
40
|
+
async def long_running_task(*, sleep: float) -> None:
|
|
41
41
|
# task initialisation can happen here
|
|
42
42
|
|
|
43
43
|
try:
|
|
@@ -94,6 +94,62 @@ its running via key presses. Hit `?` to get an overview of commands available.
|
|
|
94
94
|
|
|
95
95
|
Looking forward to your feedback.
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
## Plugin dependencies
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
A plugin can declare that it needs other plugins to be active, using the
|
|
100
|
+
`depends_on` decorator. Put it underneath `@plugin`/`@plugin_command`, just like
|
|
101
|
+
you would `click.option`:
|
|
102
|
+
|
|
103
|
+
```Python
|
|
104
|
+
from click_async_plugins import depends_on
|
|
105
|
+
|
|
106
|
+
@cli_core.plugin_command
|
|
107
|
+
@depends_on("countdown")
|
|
108
|
+
@pass_clictx
|
|
109
|
+
async def echo(clictx: CliContext) -> PluginLifespan: ...
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Dependencies are given either as the plugin's command name (as typed on the
|
|
113
|
+
command line), or as the plugin command object itself, e.g.
|
|
114
|
+
`@depends_on(countdown)`; you can mix and match, and use the decorator more than
|
|
115
|
+
once.
|
|
116
|
+
|
|
117
|
+
When the plugins are run:
|
|
118
|
+
|
|
119
|
+
- The declared dependencies must have been specified on the command line,
|
|
120
|
+
otherwise a usage error is raised before anything is set up;
|
|
121
|
+
- The order on the command line does not matter: dependencies are always set up
|
|
122
|
+
*before* the plugins that need them, and (as teardown happens in reverse) torn
|
|
123
|
+
down *after* them. Plugins that are not constrained by a dependency keep their
|
|
124
|
+
command line order;
|
|
125
|
+
- Circular dependencies are reported as a usage error, too.
|
|
126
|
+
|
|
127
|
+
Both errors are raised as `PluginDependencyError`, a `click.UsageError`. If you
|
|
128
|
+
call `run_plugins` yourself, dependencies are resolved there; the logic is
|
|
129
|
+
available separately as `resolve_dependencies`.
|
|
130
|
+
|
|
131
|
+
## Contributing
|
|
132
|
+
|
|
133
|
+
To contribute, please ensure you have the appropriate dependencies installed:
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
pip install -e .[dev]
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
and then install the Git pre-commit hooks that ensure that any commits conform
|
|
140
|
+
with the coding-style used by this project.
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
pre-commit install
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
All code is 100% test-covered, and all contributions are expected to keep this up.
|
|
147
|
+
Use `pytest` to run the test suite.
|
|
148
|
+
|
|
149
|
+
Note that all code is typed, and typing is part of test-coverage.
|
|
150
|
+
|
|
151
|
+
## Copyright & Licence
|
|
152
|
+
|
|
153
|
+
© 2025–26 martin f. krafft <<click-async-plugins@pobox.madduck.net>>
|
|
154
|
+
|
|
155
|
+
Available under the terms of the MIT licence.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
[](https://github.com/madduck/click-async-plugins/actions/workflows/0-testing.yml)
|
|
2
2
|
|
|
3
3
|
# click-async-plugins
|
|
4
4
|
|
|
@@ -14,7 +14,7 @@ async def myplugin(sleep: float) -> PluginLifespan:
|
|
|
14
14
|
|
|
15
15
|
# code to set things up goes here
|
|
16
16
|
|
|
17
|
-
async def long_running_task(*, sleep: float):
|
|
17
|
+
async def long_running_task(*, sleep: float) -> None:
|
|
18
18
|
# task initialisation can happen here
|
|
19
19
|
|
|
20
20
|
try:
|
|
@@ -71,6 +71,62 @@ its running via key presses. Hit `?` to get an overview of commands available.
|
|
|
71
71
|
|
|
72
72
|
Looking forward to your feedback.
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
## Plugin dependencies
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
A plugin can declare that it needs other plugins to be active, using the
|
|
77
|
+
`depends_on` decorator. Put it underneath `@plugin`/`@plugin_command`, just like
|
|
78
|
+
you would `click.option`:
|
|
79
|
+
|
|
80
|
+
```Python
|
|
81
|
+
from click_async_plugins import depends_on
|
|
82
|
+
|
|
83
|
+
@cli_core.plugin_command
|
|
84
|
+
@depends_on("countdown")
|
|
85
|
+
@pass_clictx
|
|
86
|
+
async def echo(clictx: CliContext) -> PluginLifespan: ...
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Dependencies are given either as the plugin's command name (as typed on the
|
|
90
|
+
command line), or as the plugin command object itself, e.g.
|
|
91
|
+
`@depends_on(countdown)`; you can mix and match, and use the decorator more than
|
|
92
|
+
once.
|
|
93
|
+
|
|
94
|
+
When the plugins are run:
|
|
95
|
+
|
|
96
|
+
- The declared dependencies must have been specified on the command line,
|
|
97
|
+
otherwise a usage error is raised before anything is set up;
|
|
98
|
+
- The order on the command line does not matter: dependencies are always set up
|
|
99
|
+
*before* the plugins that need them, and (as teardown happens in reverse) torn
|
|
100
|
+
down *after* them. Plugins that are not constrained by a dependency keep their
|
|
101
|
+
command line order;
|
|
102
|
+
- Circular dependencies are reported as a usage error, too.
|
|
103
|
+
|
|
104
|
+
Both errors are raised as `PluginDependencyError`, a `click.UsageError`. If you
|
|
105
|
+
call `run_plugins` yourself, dependencies are resolved there; the logic is
|
|
106
|
+
available separately as `resolve_dependencies`.
|
|
107
|
+
|
|
108
|
+
## Contributing
|
|
109
|
+
|
|
110
|
+
To contribute, please ensure you have the appropriate dependencies installed:
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
pip install -e .[dev]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
and then install the Git pre-commit hooks that ensure that any commits conform
|
|
117
|
+
with the coding-style used by this project.
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
pre-commit install
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
All code is 100% test-covered, and all contributions are expected to keep this up.
|
|
124
|
+
Use `pytest` to run the test suite.
|
|
125
|
+
|
|
126
|
+
Note that all code is typed, and typing is part of test-coverage.
|
|
127
|
+
|
|
128
|
+
## Copyright & Licence
|
|
129
|
+
|
|
130
|
+
© 2025–26 martin f. krafft <<click-async-plugins@pobox.madduck.net>>
|
|
131
|
+
|
|
132
|
+
Available under the terms of the MIT licence.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.8.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 8, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from .command import plugin
|
|
2
2
|
from .core import cli_core, runner
|
|
3
|
+
from .dependencies import PluginDependencyError, depends_on, resolve_dependencies
|
|
3
4
|
from .group import plugin_group
|
|
4
5
|
from .itc import ITC
|
|
5
6
|
from .typedefs import PluginFactory, PluginLifespan
|
|
@@ -20,9 +21,12 @@ __all__ = [
|
|
|
20
21
|
"ITC",
|
|
21
22
|
"pass_clictx",
|
|
22
23
|
"plugin",
|
|
24
|
+
"PluginDependencyError",
|
|
23
25
|
"PluginFactory",
|
|
24
26
|
"plugin_group",
|
|
25
27
|
"PluginLifespan",
|
|
28
|
+
"depends_on",
|
|
29
|
+
"resolve_dependencies",
|
|
26
30
|
"react_to_data_update",
|
|
27
31
|
"runner",
|
|
28
32
|
"run_plugins",
|
|
@@ -5,6 +5,7 @@ from typing import Any
|
|
|
5
5
|
|
|
6
6
|
import click
|
|
7
7
|
|
|
8
|
+
from .dependencies import get_dependencies, mark_plugin_factory
|
|
8
9
|
from .typedefs import PluginFactory, PluginLifespan
|
|
9
10
|
|
|
10
11
|
|
|
@@ -17,6 +18,11 @@ class PluginCommand(click.Command):
|
|
|
17
18
|
def wrapper(*args: list[Any], **kwargs: dict[str, Any]) -> PluginFactory:
|
|
18
19
|
lifespan_manager = asynccontextmanager(partial(callback, *args, **kwargs))
|
|
19
20
|
lifespan_manager.__name__ = callback.__name__
|
|
21
|
+
mark_plugin_factory(
|
|
22
|
+
lifespan_manager,
|
|
23
|
+
name=self.name,
|
|
24
|
+
requires=get_dependencies(callback),
|
|
25
|
+
)
|
|
20
26
|
return lifespan_manager
|
|
21
27
|
|
|
22
28
|
return ctx.invoke(wrapper, **ctx.params)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from collections.abc import Callable, Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from .typedefs import PluginFactory
|
|
7
|
+
|
|
8
|
+
# Dependencies are recorded as attributes, rather than in some registry, so
|
|
9
|
+
# that they survive decorators that use functools.wraps (e.g. pass_clictx).
|
|
10
|
+
_REQUIRES_ATTR = "__plugin_requires__"
|
|
11
|
+
_NAME_ATTR = "__plugin_name__"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PluginDependencyError(click.UsageError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def depends_on[T](*plugins: str | click.Command) -> Callable[[T], T]:
|
|
19
|
+
if not plugins:
|
|
20
|
+
raise ValueError("depends_on() requires at least one plugin")
|
|
21
|
+
|
|
22
|
+
names = tuple(
|
|
23
|
+
_command_name(p) if isinstance(p, click.Command) else p for p in plugins
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def decorator(obj: T) -> T:
|
|
27
|
+
# Also allow use above @plugin, where we get handed the command.
|
|
28
|
+
target: Any = obj.callback if isinstance(obj, click.Command) else obj
|
|
29
|
+
if target is None:
|
|
30
|
+
raise TypeError(f"{obj!r} has no callback to attach dependencies to")
|
|
31
|
+
|
|
32
|
+
existing: tuple[str, ...] = getattr(target, _REQUIRES_ATTR, ())
|
|
33
|
+
setattr(target, _REQUIRES_ATTR, (*existing, *names))
|
|
34
|
+
return obj
|
|
35
|
+
|
|
36
|
+
return decorator
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _command_name(command: click.Command) -> str:
|
|
40
|
+
if command.name is None:
|
|
41
|
+
raise ValueError(f"{command!r} has no name, so cannot be depended upon")
|
|
42
|
+
return command.name
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_dependencies(obj: object) -> tuple[str, ...]:
|
|
46
|
+
return tuple(getattr(obj, _REQUIRES_ATTR, ()))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def mark_plugin_factory(
|
|
50
|
+
factory: PluginFactory, *, name: str | None, requires: tuple[str, ...]
|
|
51
|
+
) -> None:
|
|
52
|
+
setattr(factory, _NAME_ATTR, name)
|
|
53
|
+
setattr(factory, _REQUIRES_ATTR, requires)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _lookup(factory: PluginFactory, attr: str) -> Any:
|
|
57
|
+
# Also look through functools.partial, like util._get_name does.
|
|
58
|
+
for candidate in (factory, getattr(factory, "func", None)):
|
|
59
|
+
if candidate is not None and (value := getattr(candidate, attr, None)):
|
|
60
|
+
return value
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _plugin_name(factory: PluginFactory) -> str | None:
|
|
65
|
+
name: str | None = _lookup(factory, _NAME_ATTR)
|
|
66
|
+
return name
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _display_name(factory: PluginFactory) -> str:
|
|
70
|
+
return _plugin_name(factory) or str(getattr(factory, "__name__", factory))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve_dependencies(
|
|
74
|
+
plugin_factories: Sequence[PluginFactory],
|
|
75
|
+
) -> list[PluginFactory]:
|
|
76
|
+
names = [_display_name(f) for f in plugin_factories]
|
|
77
|
+
requires: list[tuple[str, ...]] = [
|
|
78
|
+
_lookup(f, _REQUIRES_ATTR) or () for f in plugin_factories
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
by_name: dict[str, list[int]] = {}
|
|
82
|
+
for idx, factory in enumerate(plugin_factories):
|
|
83
|
+
if (name := _plugin_name(factory)) is not None:
|
|
84
|
+
by_name.setdefault(name, []).append(idx)
|
|
85
|
+
|
|
86
|
+
ordered: list[int] = []
|
|
87
|
+
done: set[int] = set()
|
|
88
|
+
|
|
89
|
+
def visit(idx: int, trail: tuple[int, ...]) -> None:
|
|
90
|
+
if idx in done:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
if idx in trail:
|
|
94
|
+
cycle = [*trail[trail.index(idx) :], idx]
|
|
95
|
+
raise PluginDependencyError(
|
|
96
|
+
"Circular plugin dependency: " + " → ".join(names[i] for i in cycle)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
for dependency in requires[idx]:
|
|
100
|
+
if dependency not in by_name:
|
|
101
|
+
raise PluginDependencyError(
|
|
102
|
+
f"Plugin '{names[idx]}' requires plugin '{dependency}', "
|
|
103
|
+
"which was not specified."
|
|
104
|
+
)
|
|
105
|
+
for dependency_idx in by_name[dependency]:
|
|
106
|
+
visit(dependency_idx, (*trail, idx))
|
|
107
|
+
|
|
108
|
+
done.add(idx)
|
|
109
|
+
ordered.append(idx)
|
|
110
|
+
|
|
111
|
+
for idx in range(len(plugin_factories)):
|
|
112
|
+
visit(idx, ())
|
|
113
|
+
|
|
114
|
+
return [plugin_factories[idx] for idx in ordered]
|
|
@@ -58,10 +58,10 @@ class ITC:
|
|
|
58
58
|
event = asyncio.Event()
|
|
59
59
|
self._events[key].append(event)
|
|
60
60
|
|
|
61
|
-
if yield_immediately:
|
|
62
|
-
yield self._objects.get(key, yield_for_no_value)
|
|
63
|
-
|
|
64
61
|
try:
|
|
62
|
+
if yield_immediately:
|
|
63
|
+
yield self._objects.get(key, yield_for_no_value)
|
|
64
|
+
|
|
65
65
|
timestamp = 0.0
|
|
66
66
|
while True:
|
|
67
67
|
logger.debug(f"Waiting for update to '{key}'…")
|
|
@@ -75,8 +75,13 @@ class ITC:
|
|
|
75
75
|
logger.debug(f"Too early, sleeping for {waitremain:.02f}s")
|
|
76
76
|
await asyncio.sleep(waitremain)
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
# Clear the event in the same synchronous step as reading the
|
|
79
|
+
# value, and *before* yielding: updates that arrive while the
|
|
80
|
+
# consumer is busy will then trigger the next iteration
|
|
81
|
+
# instead of being wiped out.
|
|
79
82
|
event.clear()
|
|
83
|
+
value = self._objects.get(key, yield_for_no_value)
|
|
84
|
+
yield value
|
|
80
85
|
timestamp = time.monotonic()
|
|
81
86
|
|
|
82
87
|
finally:
|
|
@@ -8,9 +8,16 @@ from typing import Any, Never
|
|
|
8
8
|
|
|
9
9
|
import click
|
|
10
10
|
|
|
11
|
+
from .dependencies import resolve_dependencies
|
|
11
12
|
from .itc import ITC
|
|
12
13
|
from .typedefs import PluginFactory, PluginTask
|
|
13
14
|
|
|
15
|
+
try:
|
|
16
|
+
from inspect import iscoroutine
|
|
17
|
+
except ImportError:
|
|
18
|
+
# fall back to legacy implementation
|
|
19
|
+
from asyncio import iscoroutine # type: ignore[assignment]
|
|
20
|
+
|
|
14
21
|
logger = logging.getLogger(__name__)
|
|
15
22
|
|
|
16
23
|
|
|
@@ -41,7 +48,7 @@ def create_plugin_task[T](
|
|
|
41
48
|
) -> asyncio.Task[T]:
|
|
42
49
|
async def task_wrapper() -> None:
|
|
43
50
|
try:
|
|
44
|
-
if
|
|
51
|
+
if iscoroutine(task.task):
|
|
45
52
|
logger.debug(f"Scheduling task for '{task.name}'")
|
|
46
53
|
await task.task
|
|
47
54
|
|
|
@@ -105,6 +112,9 @@ async def run_tasks(tasks: list[TaskWithName]) -> None:
|
|
|
105
112
|
async def run_plugins(
|
|
106
113
|
plugin_factories: list[PluginFactory], *args: Any, **kwargs: Any
|
|
107
114
|
) -> None:
|
|
115
|
+
# Fail early, before anything is set up, if dependencies cannot be met
|
|
116
|
+
plugin_factories = resolve_dependencies(plugin_factories)
|
|
117
|
+
|
|
108
118
|
async with AsyncExitStack() as stack:
|
|
109
119
|
tasks = await setup_plugins(plugin_factories, *args, stack=stack, **kwargs)
|
|
110
120
|
await run_tasks(tasks)
|
|
@@ -7,6 +7,7 @@ from click_async_plugins import (
|
|
|
7
7
|
CliContext,
|
|
8
8
|
PluginLifespan,
|
|
9
9
|
cli_core,
|
|
10
|
+
depends_on,
|
|
10
11
|
pass_clictx,
|
|
11
12
|
plugin,
|
|
12
13
|
)
|
|
@@ -54,6 +55,7 @@ async def countdown(
|
|
|
54
55
|
is_flag=True,
|
|
55
56
|
help="Don't wait for first update but echo right upon start",
|
|
56
57
|
)
|
|
58
|
+
@depends_on(countdown)
|
|
57
59
|
@pass_clictx
|
|
58
60
|
async def echo(clictx: CliContext, immediately: bool) -> PluginLifespan:
|
|
59
61
|
async def reactor() -> None:
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["hatchling"]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
3
|
build-backend = "hatchling.build"
|
|
4
4
|
|
|
5
|
-
[tool.
|
|
6
|
-
|
|
5
|
+
[tool.hatch.build.hooks.vcs]
|
|
6
|
+
version-file = "_version.py"
|
|
7
|
+
|
|
8
|
+
[tool.hatch.version]
|
|
9
|
+
source = "vcs"
|
|
10
|
+
|
|
11
|
+
[tool.hatch.build.targets.sdist]
|
|
12
|
+
exclude = [".direnv", ".gitignore", ".github"]
|
|
7
13
|
|
|
8
14
|
[project]
|
|
9
15
|
name = "click-async-plugins"
|
|
10
|
-
|
|
16
|
+
dynamic = ["version"]
|
|
11
17
|
authors = [
|
|
12
18
|
{ name = "martin f. krafft", email = "click-async-plugins@pobox.madduck.net" },
|
|
13
19
|
]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import pytest
|
|
8
|
+
from click.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from click_async_plugins import (
|
|
11
|
+
ITC,
|
|
12
|
+
CliContext,
|
|
13
|
+
PluginFactory,
|
|
14
|
+
PluginLifespan,
|
|
15
|
+
cli_core,
|
|
16
|
+
pass_clictx,
|
|
17
|
+
plugin,
|
|
18
|
+
plugin_group,
|
|
19
|
+
runner,
|
|
20
|
+
)
|
|
21
|
+
from click_async_plugins.command import PluginCommand
|
|
22
|
+
from click_async_plugins.group import PluginGroup
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _make_group() -> PluginGroup:
|
|
26
|
+
@plugin_group
|
|
27
|
+
def group() -> None: ...
|
|
28
|
+
|
|
29
|
+
@group.result_callback()
|
|
30
|
+
def collect(factories: list[PluginFactory]) -> list[PluginFactory]:
|
|
31
|
+
return factories
|
|
32
|
+
|
|
33
|
+
return group
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _invoke(group: click.Group, args: list[str], **kwargs: Any) -> Any:
|
|
37
|
+
result = CliRunner().invoke(group, args, standalone_mode=False, **kwargs)
|
|
38
|
+
if result.exception:
|
|
39
|
+
raise result.exception
|
|
40
|
+
return result.return_value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_plugin_group_is_chained_plugin_group() -> None:
|
|
44
|
+
group = _make_group()
|
|
45
|
+
assert isinstance(group, PluginGroup)
|
|
46
|
+
assert group.chain
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_plugin_decorator_returns_plugin_command() -> None:
|
|
50
|
+
@plugin
|
|
51
|
+
async def myplugin() -> PluginLifespan:
|
|
52
|
+
yield None
|
|
53
|
+
|
|
54
|
+
assert isinstance(myplugin, PluginCommand)
|
|
55
|
+
assert myplugin.name == "myplugin"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_plugin_command_registers_on_group() -> None:
|
|
59
|
+
group = _make_group()
|
|
60
|
+
|
|
61
|
+
@group.plugin_command
|
|
62
|
+
async def myplugin() -> PluginLifespan:
|
|
63
|
+
yield None
|
|
64
|
+
|
|
65
|
+
assert isinstance(myplugin, PluginCommand)
|
|
66
|
+
assert group.commands["myplugin"] is myplugin
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_plugin_command_without_callback_invokes_to_none() -> None:
|
|
70
|
+
command = PluginCommand("noop")
|
|
71
|
+
with click.Context(command) as ctx:
|
|
72
|
+
assert command.invoke(ctx) is None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.asyncio
|
|
76
|
+
async def test_plugin_command_invoke_returns_lifespan_factory() -> None:
|
|
77
|
+
group = _make_group()
|
|
78
|
+
events: list[tuple[str, int]] = []
|
|
79
|
+
|
|
80
|
+
@group.plugin_command
|
|
81
|
+
@click.option("--count", type=int, default=1)
|
|
82
|
+
async def counter(count: int) -> PluginLifespan:
|
|
83
|
+
async def work() -> None:
|
|
84
|
+
events.append(("work", count))
|
|
85
|
+
|
|
86
|
+
events.append(("setup", count))
|
|
87
|
+
yield work()
|
|
88
|
+
events.append(("teardown", count))
|
|
89
|
+
|
|
90
|
+
(factory,) = _invoke(group, ["counter", "--count", "3"])
|
|
91
|
+
assert factory.__name__ == "counter"
|
|
92
|
+
assert events == [] # nothing runs until the factory is entered
|
|
93
|
+
|
|
94
|
+
async with factory() as task:
|
|
95
|
+
assert task is not None
|
|
96
|
+
await task
|
|
97
|
+
|
|
98
|
+
assert events == [("setup", 3), ("work", 3), ("teardown", 3)]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_chained_plugin_commands_yield_one_factory_each() -> None:
|
|
102
|
+
group = _make_group()
|
|
103
|
+
|
|
104
|
+
@group.plugin_command
|
|
105
|
+
async def first() -> PluginLifespan:
|
|
106
|
+
yield None
|
|
107
|
+
|
|
108
|
+
@group.plugin_command
|
|
109
|
+
async def second() -> PluginLifespan:
|
|
110
|
+
yield None
|
|
111
|
+
|
|
112
|
+
factories = _invoke(group, ["first", "second"])
|
|
113
|
+
assert [f.__name__ for f in factories] == ["first", "second"]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@pytest.fixture
|
|
117
|
+
def registered_commands() -> Iterator[list[str]]:
|
|
118
|
+
"""Track commands added to the global cli_core and remove them afterwards"""
|
|
119
|
+
before = set(cli_core.commands)
|
|
120
|
+
names: list[str] = []
|
|
121
|
+
yield names
|
|
122
|
+
for name in set(cli_core.commands) - before:
|
|
123
|
+
del cli_core.commands[name]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_cli_core_provides_cli_context_and_runs_plugins(
|
|
127
|
+
registered_commands: list[str],
|
|
128
|
+
) -> None:
|
|
129
|
+
events: list[Any] = []
|
|
130
|
+
|
|
131
|
+
@cli_core.plugin_command
|
|
132
|
+
@pass_clictx
|
|
133
|
+
async def spy(clictx: CliContext) -> PluginLifespan:
|
|
134
|
+
async def work() -> None:
|
|
135
|
+
events.append(("work", clictx.itc))
|
|
136
|
+
|
|
137
|
+
events.append(("setup", clictx))
|
|
138
|
+
yield work()
|
|
139
|
+
events.append(("teardown", clictx))
|
|
140
|
+
|
|
141
|
+
result = CliRunner().invoke(cli_core, ["spy"], standalone_mode=False)
|
|
142
|
+
assert result.exception is None
|
|
143
|
+
|
|
144
|
+
assert [e[0] for e in events] == ["setup", "work", "teardown"]
|
|
145
|
+
clictx = events[0][1]
|
|
146
|
+
assert isinstance(clictx, CliContext)
|
|
147
|
+
assert isinstance(clictx.itc, ITC)
|
|
148
|
+
assert events[1][1] is clictx.itc
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_cli_core_chains_plugins_sharing_itc(
|
|
152
|
+
registered_commands: list[str],
|
|
153
|
+
) -> None:
|
|
154
|
+
received: list[int] = []
|
|
155
|
+
|
|
156
|
+
@cli_core.plugin_command
|
|
157
|
+
@pass_clictx
|
|
158
|
+
async def producer(clictx: CliContext) -> PluginLifespan:
|
|
159
|
+
async def work() -> None:
|
|
160
|
+
await asyncio.sleep(0.01)
|
|
161
|
+
clictx.itc.set("answer", 42)
|
|
162
|
+
|
|
163
|
+
yield work()
|
|
164
|
+
|
|
165
|
+
@cli_core.plugin_command
|
|
166
|
+
@pass_clictx
|
|
167
|
+
async def consumer(clictx: CliContext) -> PluginLifespan:
|
|
168
|
+
async def work() -> None:
|
|
169
|
+
async for value in clictx.itc.updates("answer", yield_immediately=False):
|
|
170
|
+
assert value is not None
|
|
171
|
+
received.append(value)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
yield work()
|
|
175
|
+
|
|
176
|
+
result = CliRunner().invoke(
|
|
177
|
+
cli_core, ["consumer", "producer"], standalone_mode=False
|
|
178
|
+
)
|
|
179
|
+
assert result.exception is None
|
|
180
|
+
assert received == [42]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_runner_runs_plugin_factories() -> None:
|
|
184
|
+
events: list[str] = []
|
|
185
|
+
|
|
186
|
+
@asynccontextmanager
|
|
187
|
+
async def lifespan() -> PluginLifespan:
|
|
188
|
+
async def work() -> None:
|
|
189
|
+
events.append("work")
|
|
190
|
+
|
|
191
|
+
yield work()
|
|
192
|
+
events.append("teardown")
|
|
193
|
+
|
|
194
|
+
runner([lifespan])
|
|
195
|
+
assert events == ["work", "teardown"]
|