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.
Files changed (25) hide show
  1. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/.gitignore +1 -0
  2. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/.pre-commit-config.yaml +2 -2
  3. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/PKG-INFO +62 -6
  4. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/README.md +60 -4
  5. click_async_plugins-0.8.0/_version.py +24 -0
  6. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/__init__.py +4 -0
  7. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/command.py +6 -0
  8. click_async_plugins-0.8.0/click_async_plugins/dependencies.py +114 -0
  9. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/itc.py +9 -4
  10. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/util.py +11 -1
  11. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/demo.py +2 -0
  12. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/pyproject.toml +10 -4
  13. click_async_plugins-0.8.0/tests/test_command_group_core.py +195 -0
  14. click_async_plugins-0.8.0/tests/test_debug.py +321 -0
  15. click_async_plugins-0.8.0/tests/test_dependencies.py +205 -0
  16. click_async_plugins-0.8.0/tests/test_itc.py +209 -0
  17. click_async_plugins-0.8.0/tests/test_util.py +299 -0
  18. click_async_plugins-0.7.3/.github/workflows/python-package.yml +0 -88
  19. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/LICENSE +0 -0
  20. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/core.py +0 -0
  21. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/debug.py +0 -0
  22. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/group.py +0 -0
  23. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/py.typed +0 -0
  24. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/click_async_plugins/typedefs.py +0 -0
  25. {click_async_plugins-0.7.3 → click_async_plugins-0.8.0}/tests/test_typedefs.py +0 -0
@@ -6,3 +6,4 @@ __pycache__
6
6
  .coverage
7
7
  .*.sw?
8
8
  /dist
9
+ /_version.py
@@ -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: v1.19.1
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.15.6
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: click-async-plugins
3
- Version: 0.7.3
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
- [![Style and unit tests badge](https://github.com/madduck/click-async-plugins/actions/workflows/python-package.yml/badge.svg)](https://github.com/madduck/click-async-plugins/actions/workflows/python-package.yml)
24
+ [![Style and unit tests badge](https://github.com/madduck/click-async-plugins/actions/workflows/0-testing.yml/badge.svg)](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
- Oh, and if someone wanted to turn this into a proper package with tests and everything, I think it could be published to pip/pypy. I need to stop shaving this yak now, though.
97
+ ## Plugin dependencies
98
98
 
99
- © 2025 martin f. krafft <<click-async-plugins@pobox.madduck.net>>
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
- [![Style and unit tests badge](https://github.com/madduck/click-async-plugins/actions/workflows/python-package.yml/badge.svg)](https://github.com/madduck/click-async-plugins/actions/workflows/python-package.yml)
1
+ [![Style and unit tests badge](https://github.com/madduck/click-async-plugins/actions/workflows/0-testing.yml/badge.svg)](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
- Oh, and if someone wanted to turn this into a proper package with tests and everything, I think it could be published to pip/pypy. I need to stop shaving this yak now, though.
74
+ ## Plugin dependencies
75
75
 
76
- © 2025 martin f. krafft <<click-async-plugins@pobox.madduck.net>>
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
- yield self._objects.get(key, yield_for_no_value)
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 asyncio.iscoroutine(task.task):
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.setuptools_scm]
6
- write_to = "_version.py"
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
- version = "0.7.3"
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"]