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.
Files changed (24) hide show
  1. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/PKG-INFO +57 -3
  2. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/README.md +56 -2
  3. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/_version.py +2 -2
  4. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/__init__.py +4 -0
  5. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/command.py +6 -0
  6. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/debug.py +7 -2
  7. click_async_plugins-0.9.0/click_async_plugins/dependencies.py +114 -0
  8. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/itc.py +53 -5
  9. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/util.py +4 -0
  10. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/demo.py +2 -0
  11. click_async_plugins-0.9.0/tests/test_command_group_core.py +195 -0
  12. click_async_plugins-0.9.0/tests/test_debug.py +327 -0
  13. click_async_plugins-0.9.0/tests/test_dependencies.py +205 -0
  14. click_async_plugins-0.9.0/tests/test_itc.py +323 -0
  15. click_async_plugins-0.9.0/tests/test_util.py +299 -0
  16. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/.gitignore +0 -0
  17. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/.pre-commit-config.yaml +0 -0
  18. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/LICENSE +0 -0
  19. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/core.py +0 -0
  20. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/group.py +0 -0
  21. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/py.typed +0 -0
  22. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/click_async_plugins/typedefs.py +0 -0
  23. {click_async_plugins-0.7.4 → click_async_plugins-0.9.0}/pyproject.toml +0 -0
  24. {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.7.4
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
- ## TODO
97
+ ## Plugin dependencies
98
98
 
99
- Test coverage… yeah, there's none right now :(
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
- ## TODO
74
+ ## Plugin dependencies
75
75
 
76
- Test coverage… yeah, there's none right now :(
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.7.4'
22
- __version_tuple__ = version_tuple = (0, 7, 4)
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
- return self._objects.get(key, default)
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
- yield self._objects.get(key, yield_for_no_value)
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"]