click-async-plugins 0.7.4__tar.gz → 0.9.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.4 → click_async_plugins-0.9.0}/PKG-INFO +57 -3
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/README.md +56 -2
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/_version.py +2 -2
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/__init__.py +4 -0
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/command.py +6 -0
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/debug.py +7 -2
- click_async_plugins-0.9.0/click_async_plugins/dependencies.py +114 -0
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/itc.py +53 -5
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/util.py +4 -0
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/demo.py +2 -0
- click_async_plugins-0.9.0/tests/test_command_group_core.py +195 -0
- click_async_plugins-0.9.0/tests/test_debug.py +327 -0
- click_async_plugins-0.9.0/tests/test_dependencies.py +205 -0
- click_async_plugins-0.9.0/tests/test_itc.py +323 -0
- click_async_plugins-0.9.0/tests/test_util.py +299 -0
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/.gitignore +0 -0
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/.pre-commit-config.yaml +0 -0
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/LICENSE +0 -0
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/core.py +0 -0
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/group.py +0 -0
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/py.typed +0 -0
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/typedefs.py +0 -0
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/pyproject.toml +0 -0
- {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/tests/test_typedefs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: click-async-plugins
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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
|
|
@@ -94,8 +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
|
|
100
152
|
|
|
101
153
|
© 2025–26 martin f. krafft <<click-async-plugins@pobox.madduck.net>>
|
|
154
|
+
|
|
155
|
+
Available under the terms of the MIT licence.
|
|
@@ -71,8 +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
|
|
77
129
|
|
|
78
130
|
© 2025–26 martin f. krafft <<click-async-plugins@pobox.madduck.net>>
|
|
131
|
+
|
|
132
|
+
Available under the terms of the MIT licence.
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.9.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 9, 0)
|
|
23
23
|
|
|
24
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)
|
|
@@ -10,7 +10,7 @@ from collections.abc import AsyncGenerator, Callable
|
|
|
10
10
|
from contextlib import asynccontextmanager
|
|
11
11
|
from dataclasses import dataclass
|
|
12
12
|
from functools import partial
|
|
13
|
-
from typing import Any, Coroutine, cast
|
|
13
|
+
from typing import Any, Coroutine, Never, cast
|
|
14
14
|
|
|
15
15
|
from click_async_plugins.util import CliContext
|
|
16
16
|
|
|
@@ -44,6 +44,10 @@ def _name_for_coro(coro: Coroutine[Any, Any, Any] | None) -> str:
|
|
|
44
44
|
return "(unknown)"
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def interrupt(clictx: CliContext) -> Never:
|
|
48
|
+
raise KeyboardInterrupt
|
|
49
|
+
|
|
50
|
+
|
|
47
51
|
def debug_info(clictx: CliContext) -> str:
|
|
48
52
|
"""Prints debugging information on tasks and CliContext"""
|
|
49
53
|
ret = "*** BEGIN DEBUG INFO: ***\n"
|
|
@@ -58,7 +62,7 @@ def debug_info(clictx: CliContext) -> str:
|
|
|
58
62
|
ret += "CliContext:\n"
|
|
59
63
|
maxlen = max([len(k) for k in clictx.__dict__.keys()])
|
|
60
64
|
for attr, value in clictx.__dict__.items():
|
|
61
|
-
ret += f" {attr:>{maxlen}s} = {value!r}\n"
|
|
65
|
+
ret += f" {attr:>{maxlen}s} = {value!r} (id:{id(value)})\n"
|
|
62
66
|
return ret + "*** END DEBUG INFO: ***"
|
|
63
67
|
|
|
64
68
|
|
|
@@ -188,6 +192,7 @@ async def monitor_stdin_for_debug_commands[ContextT: CliContext](
|
|
|
188
192
|
decrease_loglevel.__doc__ = "Decrease the logging level"
|
|
189
193
|
|
|
190
194
|
map = {
|
|
195
|
+
0x3: KeyAndFunc(r"^C", interrupt),
|
|
191
196
|
0xA: KeyAndFunc(r"\n", echo_newline),
|
|
192
197
|
0xD: KeyAndFunc(r"\n", echo_newline),
|
|
193
198
|
0x1B: KeyAndFunc("<Esc>", terminal_block),
|
|
@@ -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]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import inspect
|
|
2
3
|
import logging
|
|
3
4
|
import time
|
|
4
5
|
from collections import defaultdict
|
|
@@ -8,6 +9,31 @@ from typing import Any
|
|
|
8
9
|
logger = logging.getLogger(__name__)
|
|
9
10
|
|
|
10
11
|
|
|
12
|
+
def _caller(depth: int = 1) -> str:
|
|
13
|
+
"""Return a description of a calling frame, ``depth`` frames up.
|
|
14
|
+
|
|
15
|
+
``depth`` counts frames starting at this helper, so ``depth=1`` (the
|
|
16
|
+
default) reports the direct caller of this helper, ``depth=2`` the frame
|
|
17
|
+
above that, and so on. Returns a ``file:lineno in function`` string, or
|
|
18
|
+
a fallback marker if the frame cannot be determined.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
ValueError: If ``depth`` is less than 1.
|
|
22
|
+
"""
|
|
23
|
+
if depth < 1:
|
|
24
|
+
raise ValueError(f"depth must be >= 1, got {depth}")
|
|
25
|
+
|
|
26
|
+
frame = inspect.currentframe()
|
|
27
|
+
try:
|
|
28
|
+
for _ in range(depth):
|
|
29
|
+
frame = frame.f_back if frame is not None else None
|
|
30
|
+
if frame is None:
|
|
31
|
+
return "<unknown>"
|
|
32
|
+
return f"{frame.f_code.co_filename}:{frame.f_lineno} in {frame.f_code.co_name}"
|
|
33
|
+
finally:
|
|
34
|
+
del frame
|
|
35
|
+
|
|
36
|
+
|
|
11
37
|
class ITC:
|
|
12
38
|
def __init__(self) -> None:
|
|
13
39
|
self._events: dict[str, list[asyncio.Event]] = defaultdict(list[asyncio.Event])
|
|
@@ -25,13 +51,20 @@ class ITC:
|
|
|
25
51
|
return f"{ret}{' ' if i > 0 else ''})"
|
|
26
52
|
|
|
27
53
|
def set(self, key: str, obj: Any) -> None:
|
|
54
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
55
|
+
logger.debug(f"set('{key}', {obj!r}) called from {_caller(2)}")
|
|
28
56
|
self._objects[key] = obj
|
|
29
57
|
self.fire(key)
|
|
30
58
|
|
|
31
59
|
def get(self, key: str, default: Any = None) -> Any:
|
|
32
|
-
|
|
60
|
+
value = self._objects.get(key, default)
|
|
61
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
62
|
+
logger.debug(f"get('{key}') -> {value!r} called from {_caller(2)}")
|
|
63
|
+
return value
|
|
33
64
|
|
|
34
65
|
def fire(self, key: str) -> None:
|
|
66
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
67
|
+
logger.debug(f"fire('{key}') called from {_caller(2)}")
|
|
35
68
|
logger.debug(f"Notifying subscribers of update to '{key}'…")
|
|
36
69
|
for event in self._events.get(key) or []:
|
|
37
70
|
event.set()
|
|
@@ -47,6 +80,9 @@ class ITC:
|
|
|
47
80
|
) -> AsyncGenerator[Any | None]:
|
|
48
81
|
at_most_every = 0 if at_most_every is None else at_most_every
|
|
49
82
|
|
|
83
|
+
caller = _caller(2) if logger.isEnabledFor(logging.DEBUG) else ""
|
|
84
|
+
logger.debug(f"updates('{key}') subscribing from {caller}")
|
|
85
|
+
|
|
50
86
|
if timeout is not None and timeout <= 0:
|
|
51
87
|
logger.warning("Updates timeout <= 0, disabling timeout…")
|
|
52
88
|
timeout = None
|
|
@@ -58,10 +94,15 @@ class ITC:
|
|
|
58
94
|
event = asyncio.Event()
|
|
59
95
|
self._events[key].append(event)
|
|
60
96
|
|
|
61
|
-
if yield_immediately:
|
|
62
|
-
yield self._objects.get(key, yield_for_no_value)
|
|
63
|
-
|
|
64
97
|
try:
|
|
98
|
+
if yield_immediately:
|
|
99
|
+
value = self._objects.get(key, yield_for_no_value)
|
|
100
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
101
|
+
logger.debug(
|
|
102
|
+
f"updates('{key}') yielding initial {value!r} to {caller}"
|
|
103
|
+
)
|
|
104
|
+
yield value
|
|
105
|
+
|
|
65
106
|
timestamp = 0.0
|
|
66
107
|
while True:
|
|
67
108
|
logger.debug(f"Waiting for update to '{key}'…")
|
|
@@ -75,8 +116,15 @@ class ITC:
|
|
|
75
116
|
logger.debug(f"Too early, sleeping for {waitremain:.02f}s")
|
|
76
117
|
await asyncio.sleep(waitremain)
|
|
77
118
|
|
|
78
|
-
|
|
119
|
+
# Clear the event in the same synchronous step as reading the
|
|
120
|
+
# value, and *before* yielding: updates that arrive while the
|
|
121
|
+
# consumer is busy will then trigger the next iteration
|
|
122
|
+
# instead of being wiped out.
|
|
79
123
|
event.clear()
|
|
124
|
+
value = self._objects.get(key, yield_for_no_value)
|
|
125
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
126
|
+
logger.debug(f"updates('{key}') yielding {value!r} to {caller}")
|
|
127
|
+
yield value
|
|
80
128
|
timestamp = time.monotonic()
|
|
81
129
|
|
|
82
130
|
finally:
|
|
@@ -8,6 +8,7 @@ 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
|
|
|
@@ -111,6 +112,9 @@ async def run_tasks(tasks: list[TaskWithName]) -> None:
|
|
|
111
112
|
async def run_plugins(
|
|
112
113
|
plugin_factories: list[PluginFactory], *args: Any, **kwargs: Any
|
|
113
114
|
) -> None:
|
|
115
|
+
# Fail early, before anything is set up, if dependencies cannot be met
|
|
116
|
+
plugin_factories = resolve_dependencies(plugin_factories)
|
|
117
|
+
|
|
114
118
|
async with AsyncExitStack() as stack:
|
|
115
119
|
tasks = await setup_plugins(plugin_factories, *args, stack=stack, **kwargs)
|
|
116
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:
|
|
@@ -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"]
|