click-async-plugins 0.7.3__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.
@@ -0,0 +1,88 @@
1
+ name: Style and unit tests
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ pull_request:
7
+ branches: ["main"]
8
+
9
+ jobs:
10
+ test-and-build:
11
+ name: Setup test & build environment
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ python-version: ["3.13", "3.14"]
17
+
18
+ steps:
19
+ - name: Check out the source
20
+ uses: actions/checkout@v6
21
+ with:
22
+ persist-credentials: false
23
+
24
+ - name: Set up Python ${{ matrix.python-version }}
25
+ uses: actions/setup-python@v6
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+
29
+ - name: Install dependencies
30
+ run: |
31
+ python -m pip install --upgrade pip
32
+ pip install .
33
+ pip install .[dev]
34
+
35
+ - name: Style and formatting (ruff)
36
+ run: |
37
+ ruff check .
38
+
39
+ - name: Unit and integration tests (pytest)
40
+ run: |
41
+ pytest
42
+
43
+ - name: Mypy Check
44
+ uses: jpetrucciani/mypy-check@master
45
+ with:
46
+ python_version: ${{ matrix.python-version }}
47
+ requirements: ". .[dev]"
48
+
49
+ - name: Upgrade pip
50
+ run: |
51
+ python3 -m pip install --upgrade pip
52
+
53
+ - name: Install pypa/build
54
+ run: python3 -m pip install build --user
55
+
56
+ - name: Build a binary wheel and a source tarball
57
+ run: python3 -m build
58
+
59
+ - name: Store the distribution packages
60
+ uses: actions/upload-artifact@v5
61
+ with:
62
+ name: dist-${{ matrix.python-version }}
63
+ path: dist/
64
+
65
+ publish-to-pypi:
66
+ name: Publish module to PyPI
67
+ # if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes
68
+ needs:
69
+ - test-and-build
70
+ runs-on: ubuntu-latest
71
+ environment: pypi
72
+ # name: pypi
73
+ # url: https://pypi.org/p/click-async-plugins
74
+ permissions:
75
+ id-token: write
76
+ strategy:
77
+ matrix:
78
+ python-version: ["3.13"]
79
+
80
+ steps:
81
+ - name: Download all the dists
82
+ uses: actions/download-artifact@v6
83
+ with:
84
+ name: dist-${{ matrix.python-version }}
85
+ path: dist/
86
+
87
+ - name: Push to PyPI
88
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__
2
+ /.direnv
3
+ .envrc
4
+ /.mypy_cache
5
+ /.ruff_cache
6
+ .coverage
7
+ .*.sw?
8
+ /dist
@@ -0,0 +1,20 @@
1
+ # See https://pre-commit.com for more information
2
+ # See https://pre-commit.com/hooks.html for more hooks
3
+ repos:
4
+ - repo: https://github.com/pre-commit/mirrors-mypy
5
+ rev: v1.19.1
6
+ hooks:
7
+ - id: mypy
8
+ additional_dependencies:
9
+ - click
10
+ - pytest
11
+ - pytest-asyncio
12
+ - repo: https://github.com/astral-sh/ruff-pre-commit
13
+ rev: v0.15.6
14
+ hooks:
15
+ - id: ruff-check
16
+ args: [--show-fixes]
17
+ - id: ruff-format
18
+ args: [--diff]
19
+
20
+ fail_fast: true
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 martin f. krafft
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: click-async-plugins
3
+ Version: 0.7.3
4
+ Summary: An architecture to easily run asyncio tasks from Click
5
+ Author-email: "martin f. krafft" <click-async-plugins@pobox.madduck.net>
6
+ License-File: LICENSE
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.12
11
+ Requires-Dist: click
12
+ Provides-Extra: dev
13
+ Requires-Dist: coverage; extra == 'dev'
14
+ Requires-Dist: ipdb; extra == 'dev'
15
+ Requires-Dist: mypy; extra == 'dev'
16
+ Requires-Dist: pre-commit; extra == 'dev'
17
+ Requires-Dist: pytest; extra == 'dev'
18
+ Requires-Dist: pytest-asyncio; extra == 'dev'
19
+ Requires-Dist: pytest-cov; extra == 'dev'
20
+ Requires-Dist: pytest-ruff; extra == 'dev'
21
+ Requires-Dist: ruff; extra == 'dev'
22
+ Description-Content-Type: text/markdown
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)
25
+
26
+ # click-async-plugins
27
+
28
+ This is a proof-of-concept of a Python [asyncio](https://docs.python.org/3/library/asyncio.html) plugin architecture based on
29
+ [click](https://click.palletsprojects.com/).
30
+
31
+ Instead of writing [functions that make up sub-commands](https://click.palletsprojects.com/en/stable/commands-and-groups/#basic-group-example), you write asynchronous "lifespan functions" (`AsyncGenerator`), like this one:
32
+
33
+ ```Python
34
+ @cli_core.plugin_command
35
+ @click.option("--sleep", type=click.FloatRange(min=0.01), default=1)
36
+ async def myplugin(sleep: float) -> PluginLifespan:
37
+
38
+ # code to set things up goes here
39
+
40
+ async def long_running_task(*, sleep: float):
41
+ # task initialisation can happen here
42
+
43
+ try:
44
+ while True:
45
+ await asyncio.sleep(sleep)
46
+
47
+ finally:
48
+ # code to clean up the task goes here
49
+ pass
50
+
51
+ yield long_running_task(sleep=sleep)
52
+
53
+ # code to tear things down goes here
54
+ ```
55
+
56
+ Multiple such plugins can be defined/added to `core` (see the [demo code](https://github.com/madduck/click-async-plugins/blob/main/demo.py)). These plugins will all have their setup code called in turn. If, after setup, a plugin yields a coroutine (e.g. a long-running task), this task is scheduled with the main event loop, but this is optional, and tasks that yield nothing (`None`) will just sleep until program termination. Upon termination, the plugins' teardown code is invoked (in reverse order).
57
+
58
+ Here's what the demo code logs to the console. Two plugins are invoked. The first counts down from 3 each second, and notifies subscribers of each number. The second plugin — "echo" — just listens for updates from the "countdown" task and echoes them.
59
+
60
+ ```raw
61
+ $ python demo.py countdown --from 3 echo --immediately
62
+ DEBUG:root:Setting up task for 'echo'
63
+ DEBUG:root:Setting up task for 'countdown'
64
+ DEBUG:root:Scheduling task for 'echo'
65
+ DEBUG:root:Waiting for update to 'countdown'…
66
+ DEBUG:root:Scheduling task for 'countdown'
67
+ INFO:root:Counting down… 3
68
+ DEBUG:root:Notifying subscribers of update to 'countdown'…
69
+ INFO:root:Countdown currently at 3
70
+ DEBUG:root:Waiting for update to 'countdown'…
71
+ INFO:root:Counting down… 2
72
+ DEBUG:root:Notifying subscribers of update to 'countdown'…
73
+ INFO:root:Countdown currently at 2
74
+ DEBUG:root:Waiting for update to 'countdown'…
75
+ INFO:root:Counting down… 1
76
+ DEBUG:root:Notifying subscribers of update to 'countdown'…
77
+ INFO:root:Countdown currently at 1
78
+ DEBUG:root:Waiting for update to 'countdown'…
79
+ INFO:root:Finished counting down
80
+ ^C
81
+ DEBUG:root:Task for 'echo' cancelled
82
+ DEBUG:root:Terminating…
83
+ DEBUG:root:Lifespan over for countdown
84
+ DEBUG:root:Lifespan over for echo
85
+ DEBUG:root:Finished.
86
+ ```
87
+
88
+ I hope you get the idea. If you need more input, take a look at look at
89
+ [tptools' tpsrv CLI](https://github.com/madduck/tptools/tree/main/tpsrv), which
90
+ I was developing when I factored out this code.
91
+
92
+ There's also a "debug" plugin included, which allows interaction with the CLI as
93
+ its running via key presses. Hit `?` to get an overview of commands available.
94
+
95
+ Looking forward to your feedback.
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.
98
+
99
+ © 2025 martin f. krafft <<click-async-plugins@pobox.madduck.net>>
@@ -0,0 +1,76 @@
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)
2
+
3
+ # click-async-plugins
4
+
5
+ This is a proof-of-concept of a Python [asyncio](https://docs.python.org/3/library/asyncio.html) plugin architecture based on
6
+ [click](https://click.palletsprojects.com/).
7
+
8
+ Instead of writing [functions that make up sub-commands](https://click.palletsprojects.com/en/stable/commands-and-groups/#basic-group-example), you write asynchronous "lifespan functions" (`AsyncGenerator`), like this one:
9
+
10
+ ```Python
11
+ @cli_core.plugin_command
12
+ @click.option("--sleep", type=click.FloatRange(min=0.01), default=1)
13
+ async def myplugin(sleep: float) -> PluginLifespan:
14
+
15
+ # code to set things up goes here
16
+
17
+ async def long_running_task(*, sleep: float):
18
+ # task initialisation can happen here
19
+
20
+ try:
21
+ while True:
22
+ await asyncio.sleep(sleep)
23
+
24
+ finally:
25
+ # code to clean up the task goes here
26
+ pass
27
+
28
+ yield long_running_task(sleep=sleep)
29
+
30
+ # code to tear things down goes here
31
+ ```
32
+
33
+ Multiple such plugins can be defined/added to `core` (see the [demo code](https://github.com/madduck/click-async-plugins/blob/main/demo.py)). These plugins will all have their setup code called in turn. If, after setup, a plugin yields a coroutine (e.g. a long-running task), this task is scheduled with the main event loop, but this is optional, and tasks that yield nothing (`None`) will just sleep until program termination. Upon termination, the plugins' teardown code is invoked (in reverse order).
34
+
35
+ Here's what the demo code logs to the console. Two plugins are invoked. The first counts down from 3 each second, and notifies subscribers of each number. The second plugin — "echo" — just listens for updates from the "countdown" task and echoes them.
36
+
37
+ ```raw
38
+ $ python demo.py countdown --from 3 echo --immediately
39
+ DEBUG:root:Setting up task for 'echo'
40
+ DEBUG:root:Setting up task for 'countdown'
41
+ DEBUG:root:Scheduling task for 'echo'
42
+ DEBUG:root:Waiting for update to 'countdown'…
43
+ DEBUG:root:Scheduling task for 'countdown'
44
+ INFO:root:Counting down… 3
45
+ DEBUG:root:Notifying subscribers of update to 'countdown'…
46
+ INFO:root:Countdown currently at 3
47
+ DEBUG:root:Waiting for update to 'countdown'…
48
+ INFO:root:Counting down… 2
49
+ DEBUG:root:Notifying subscribers of update to 'countdown'…
50
+ INFO:root:Countdown currently at 2
51
+ DEBUG:root:Waiting for update to 'countdown'…
52
+ INFO:root:Counting down… 1
53
+ DEBUG:root:Notifying subscribers of update to 'countdown'…
54
+ INFO:root:Countdown currently at 1
55
+ DEBUG:root:Waiting for update to 'countdown'…
56
+ INFO:root:Finished counting down
57
+ ^C
58
+ DEBUG:root:Task for 'echo' cancelled
59
+ DEBUG:root:Terminating…
60
+ DEBUG:root:Lifespan over for countdown
61
+ DEBUG:root:Lifespan over for echo
62
+ DEBUG:root:Finished.
63
+ ```
64
+
65
+ I hope you get the idea. If you need more input, take a look at look at
66
+ [tptools' tpsrv CLI](https://github.com/madduck/tptools/tree/main/tpsrv), which
67
+ I was developing when I factored out this code.
68
+
69
+ There's also a "debug" plugin included, which allows interaction with the CLI as
70
+ its running via key presses. Hit `?` to get an overview of commands available.
71
+
72
+ Looking forward to your feedback.
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.
75
+
76
+ © 2025 martin f. krafft <<click-async-plugins@pobox.madduck.net>>
@@ -0,0 +1,31 @@
1
+ from .command import plugin
2
+ from .core import cli_core, runner
3
+ from .group import plugin_group
4
+ from .itc import ITC
5
+ from .typedefs import PluginFactory, PluginLifespan
6
+ from .util import (
7
+ CliContext,
8
+ create_plugin_task,
9
+ pass_clictx,
10
+ react_to_data_update,
11
+ run_plugins,
12
+ run_tasks,
13
+ setup_plugins,
14
+ )
15
+
16
+ __all__ = [
17
+ "CliContext",
18
+ "cli_core",
19
+ "create_plugin_task",
20
+ "ITC",
21
+ "pass_clictx",
22
+ "plugin",
23
+ "PluginFactory",
24
+ "plugin_group",
25
+ "PluginLifespan",
26
+ "react_to_data_update",
27
+ "runner",
28
+ "run_plugins",
29
+ "run_tasks",
30
+ "setup_plugins",
31
+ ]
@@ -0,0 +1,26 @@
1
+ from collections.abc import Callable
2
+ from contextlib import asynccontextmanager
3
+ from functools import partial, wraps
4
+ from typing import Any
5
+
6
+ import click
7
+
8
+ from .typedefs import PluginFactory, PluginLifespan
9
+
10
+
11
+ class PluginCommand(click.Command):
12
+ def invoke(self, ctx: click.Context) -> PluginFactory | None:
13
+ if (callback := self.callback) is None:
14
+ return None
15
+
16
+ @wraps(callback)
17
+ def wrapper(*args: list[Any], **kwargs: dict[str, Any]) -> PluginFactory:
18
+ lifespan_manager = asynccontextmanager(partial(callback, *args, **kwargs))
19
+ lifespan_manager.__name__ = callback.__name__
20
+ return lifespan_manager
21
+
22
+ return ctx.invoke(wrapper, **ctx.params)
23
+
24
+
25
+ def plugin(fn: Callable[..., PluginLifespan]) -> PluginCommand:
26
+ return click.command(cls=PluginCommand)(fn)
@@ -0,0 +1,20 @@
1
+ import asyncio
2
+
3
+ import click
4
+
5
+ from click_async_plugins.itc import ITC
6
+
7
+ from .group import plugin_group
8
+ from .typedefs import PluginFactory
9
+ from .util import CliContext, run_plugins
10
+
11
+
12
+ @plugin_group
13
+ @click.pass_context
14
+ def cli_core(ctx: click.Context) -> None:
15
+ ctx.obj = CliContext(itc=ITC())
16
+
17
+
18
+ @cli_core.result_callback()
19
+ def runner(plugin_factories: list[PluginFactory]) -> None:
20
+ asyncio.run(run_plugins(plugin_factories))
@@ -0,0 +1,211 @@
1
+ # needed < 3.14 so that annotations aren't evaluated
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import datetime
6
+ import logging
7
+ import os
8
+ import sys
9
+ from collections.abc import AsyncGenerator, Callable
10
+ from contextlib import asynccontextmanager
11
+ from dataclasses import dataclass
12
+ from functools import partial
13
+ from typing import Any, Coroutine, cast
14
+
15
+ from click_async_plugins.util import CliContext
16
+
17
+ from . import PluginLifespan, pass_clictx, plugin
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def puts(s: str) -> None:
23
+ print(s, file=sys.stderr)
24
+
25
+
26
+ def echo_newline(_: CliContext) -> str:
27
+ """Outputs a new line"""
28
+ return ""
29
+
30
+
31
+ def terminal_block(_: CliContext) -> str:
32
+ """Outputs a couple of newlines and the current time"""
33
+ return f"{'\n' * 8}The time is now: {datetime.datetime.now().isoformat(sep=' ')}\n"
34
+
35
+
36
+ def _name_for_coro(coro: Coroutine[Any, Any, Any] | None) -> str:
37
+ if coro is None:
38
+ return str(None)
39
+
40
+ for attr in ("__qualname__", "__name__"):
41
+ if (ret := getattr(coro, attr, None)) is not None:
42
+ return cast(str, ret)
43
+
44
+ return "(unknown)"
45
+
46
+
47
+ def debug_info(clictx: CliContext) -> str:
48
+ """Prints debugging information on tasks and CliContext"""
49
+ ret = "*** BEGIN DEBUG INFO: ***\n"
50
+ ret += "Tasks:\n"
51
+ for i, task in enumerate(asyncio.all_tasks(asyncio.get_event_loop()), 1):
52
+ coro = task.get_coro()
53
+ ret += (
54
+ f" {i:02n} {task.get_name():32s} "
55
+ f"state={task._state.lower():8s} "
56
+ f"coro={_name_for_coro(coro)}\n"
57
+ )
58
+ ret += "CliContext:\n"
59
+ maxlen = max([len(k) for k in clictx.__dict__.keys()])
60
+ for attr, value in clictx.__dict__.items():
61
+ ret += f" {attr:>{maxlen}s} = {value!r}\n"
62
+ return ret + "*** END DEBUG INFO: ***"
63
+
64
+
65
+ _LOGLEVELS = {
66
+ logging.DEBUG: "DEBUG",
67
+ logging.INFO: "INFO",
68
+ logging.WARN: "WARN",
69
+ logging.ERROR: "ERROR",
70
+ logging.CRITICAL: "CRITICAL",
71
+ }
72
+
73
+
74
+ def adjust_loglevel(_: CliContext, change: int) -> str | None:
75
+ """Adjusts the log level"""
76
+ rootlogger = logging.getLogger()
77
+ newlevel = rootlogger.getEffectiveLevel() + change
78
+ if newlevel < logging.DEBUG or newlevel > logging.CRITICAL:
79
+ return None
80
+
81
+ rootlogger.setLevel(newlevel)
82
+ return f"Log level now at {_LOGLEVELS[rootlogger.getEffectiveLevel()]}"
83
+
84
+
85
+ @dataclass
86
+ class KeyAndFunc[ContextT: CliContext]:
87
+ key: str
88
+ func: Callable[[ContextT], str | None]
89
+
90
+
91
+ type KeyCmdMapType[ContextT: CliContext] = dict[int, KeyAndFunc[ContextT]]
92
+
93
+
94
+ def print_help[ContextT: CliContext](
95
+ _: ContextT, key_to_cmd: KeyCmdMapType[ContextT]
96
+ ) -> str:
97
+ ret = "Keys I know about for debugging:\n"
98
+ for keyfunc in key_to_cmd.values():
99
+ ret += f" {keyfunc.key:5s} {keyfunc.func.__doc__}\n"
100
+ return ret + " ? Print this message"
101
+
102
+
103
+ async def getch() -> AsyncGenerator[str]:
104
+ try:
105
+ import fcntl
106
+ import termios
107
+ import tty
108
+
109
+ fd = sys.stdin.fileno()
110
+ termios_saved = termios.tcgetattr(fd)
111
+ fnctl_flags = fcntl.fcntl(sys.stdin, fcntl.F_GETFL)
112
+
113
+ try:
114
+ logger.debug("Configuring stdin for raw input")
115
+ tty.setcbreak(fd)
116
+ fcntl.fcntl(sys.stdin, fcntl.F_SETFL, fnctl_flags | os.O_NONBLOCK)
117
+
118
+ while True:
119
+ yield sys.stdin.read(1)
120
+
121
+ except asyncio.CancelledError:
122
+ pass
123
+
124
+ finally:
125
+ logger.debug("Restoring stdin")
126
+ termios.tcsetattr(fd, termios.TCSADRAIN, termios_saved)
127
+ fcntl.fcntl(sys.stdin, fcntl.F_SETFL, fnctl_flags)
128
+
129
+ except ImportError:
130
+ pass
131
+
132
+ try:
133
+ import msvcrt
134
+
135
+ while True:
136
+ if msvcrt.kbhit(): # type: ignore[attr-defined]
137
+ yield msvcrt.getch() # type: ignore[attr-defined]
138
+
139
+ else:
140
+ await asyncio.sleep(0.1)
141
+
142
+ except ImportError as exc:
143
+ raise NotImplementedError from exc
144
+
145
+
146
+ async def _monitor_stdin[ContextT: CliContext](
147
+ clictx: ContextT,
148
+ key_to_cmd: KeyCmdMapType[ContextT],
149
+ *,
150
+ puts: Callable[[str], Any] = puts,
151
+ ) -> None:
152
+ try:
153
+ async for ch in getch():
154
+ if len(ch) == 0:
155
+ await asyncio.sleep(0.1)
156
+ continue
157
+
158
+ if (key := ord(ch)) == 0x3F:
159
+ puts(print_help(clictx, key_to_cmd))
160
+
161
+ elif (keyfunc := key_to_cmd.get(key)) is not None and callable(
162
+ keyfunc.func
163
+ ):
164
+ # TODO: enable async functions
165
+ if (ret := keyfunc.func(clictx)) is not None:
166
+ puts(ret)
167
+
168
+ else:
169
+ logger.debug(f"Ignoring character 0x{key:02x} on stdin")
170
+
171
+ except NotImplementedError:
172
+ logger.warning("The 'debug' plugin does not work on this platform")
173
+ return None
174
+
175
+
176
+ @asynccontextmanager
177
+ async def monitor_stdin_for_debug_commands[ContextT: CliContext](
178
+ clictx: CliContext,
179
+ *,
180
+ key_to_cmd: KeyCmdMapType[ContextT] | None = None,
181
+ puts: Callable[[str], Any] = puts,
182
+ ) -> PluginLifespan:
183
+ key_to_cmd = key_to_cmd or {}
184
+
185
+ increase_loglevel = partial(adjust_loglevel, change=-10)
186
+ increase_loglevel.__doc__ = "Increase the logging level"
187
+ decrease_loglevel = partial(adjust_loglevel, change=10)
188
+ decrease_loglevel.__doc__ = "Decrease the logging level"
189
+
190
+ map = {
191
+ 0xA: KeyAndFunc(r"\n", echo_newline),
192
+ 0xD: KeyAndFunc(r"\n", echo_newline),
193
+ 0x1B: KeyAndFunc("<Esc>", terminal_block),
194
+ 0x4: KeyAndFunc("^D", debug_info),
195
+ 0x2B: KeyAndFunc("+", increase_loglevel),
196
+ 0x2D: KeyAndFunc("-", decrease_loglevel),
197
+ **key_to_cmd,
198
+ }
199
+ yield _monitor_stdin(clictx, map, puts=puts)
200
+
201
+
202
+ @plugin
203
+ @pass_clictx
204
+ async def debug(clictx: CliContext) -> PluginLifespan:
205
+ """Monitor stdin for keypresses to trigger debugging functions
206
+
207
+ Press '?' to get a list of possible keys.
208
+ """
209
+
210
+ async with monitor_stdin_for_debug_commands(clictx) as task:
211
+ yield task
@@ -0,0 +1,16 @@
1
+ from collections.abc import Callable
2
+ from typing import cast
3
+
4
+ import click
5
+
6
+ from .command import PluginCommand
7
+ from .typedefs import PluginLifespan
8
+
9
+
10
+ class PluginGroup(click.Group):
11
+ def plugin_command(self, fn: Callable[..., PluginLifespan]) -> PluginCommand:
12
+ return cast(PluginCommand, self.command(cls=PluginCommand)(fn))
13
+
14
+
15
+ def plugin_group(fn: Callable[..., None]) -> PluginGroup:
16
+ return click.group(cls=PluginGroup, chain=True)(fn)
@@ -0,0 +1,89 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ from collections import defaultdict
5
+ from collections.abc import AsyncGenerator
6
+ from typing import Any
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class ITC:
12
+ def __init__(self) -> None:
13
+ self._events: dict[str, list[asyncio.Event]] = defaultdict(list[asyncio.Event])
14
+ self._objects: dict[str, Any] = {}
15
+
16
+ def __repr__(self) -> str:
17
+ ret = f"{self.__class__.__name__}("
18
+ i = 0
19
+ for i, (key, obj) in enumerate(self._objects.items()):
20
+ ret = (
21
+ f"{ret}{'\n' if i == 0 else ''} "
22
+ f"{key}={obj!r} "
23
+ f"({len(self._events[key])} listeners)\n"
24
+ )
25
+ return f"{ret}{' ' if i > 0 else ''})"
26
+
27
+ def set(self, key: str, obj: Any) -> None:
28
+ self._objects[key] = obj
29
+ self.fire(key)
30
+
31
+ def get(self, key: str, default: Any = None) -> Any:
32
+ return self._objects.get(key, default)
33
+
34
+ def fire(self, key: str) -> None:
35
+ logger.debug(f"Notifying subscribers of update to '{key}'…")
36
+ for event in self._events.get(key) or []:
37
+ event.set()
38
+
39
+ async def updates(
40
+ self,
41
+ key: str,
42
+ *,
43
+ yield_immediately: bool = True,
44
+ timeout: float | None = None,
45
+ at_most_every: float | None = None,
46
+ yield_for_no_value: Any | None = None,
47
+ ) -> AsyncGenerator[Any | None]:
48
+ at_most_every = 0 if at_most_every is None else at_most_every
49
+
50
+ if timeout is not None and timeout <= 0:
51
+ logger.warning("Updates timeout <= 0, disabling timeout…")
52
+ timeout = None
53
+
54
+ if timeout is not None and timeout < at_most_every:
55
+ logger.warning("timeout < at_most_every makes no sense, adjusting…")
56
+ timeout = at_most_every
57
+
58
+ event = asyncio.Event()
59
+ self._events[key].append(event)
60
+
61
+ if yield_immediately:
62
+ yield self._objects.get(key, yield_for_no_value)
63
+
64
+ try:
65
+ timestamp = 0.0
66
+ while True:
67
+ logger.debug(f"Waiting for update to '{key}'…")
68
+ try:
69
+ async with asyncio.timeout(timeout):
70
+ await event.wait()
71
+ except TimeoutError:
72
+ logger.debug(f"Timeout after {timeout}s")
73
+
74
+ if (waitremain := timestamp + at_most_every - time.monotonic()) > 0:
75
+ logger.debug(f"Too early, sleeping for {waitremain:.02f}s")
76
+ await asyncio.sleep(waitremain)
77
+
78
+ yield self._objects.get(key, yield_for_no_value)
79
+ event.clear()
80
+ timestamp = time.monotonic()
81
+
82
+ finally:
83
+ self._events[key].remove(event)
84
+
85
+ def has_subscribers(self, key: str) -> bool:
86
+ return len(self._events.get(key, [])) > 0
87
+
88
+ def knows_about(self, key: str) -> bool:
89
+ return key in self._objects
File without changes
@@ -0,0 +1,7 @@
1
+ from collections.abc import AsyncGenerator, Callable, Coroutine
2
+ from typing import AsyncContextManager
3
+
4
+ type PluginTask = Coroutine[None, None, None]
5
+ type PluginLifespan = AsyncGenerator[PluginTask | None]
6
+ type Plugin = AsyncContextManager[PluginTask | None]
7
+ type PluginFactory = Callable[..., Plugin]
@@ -0,0 +1,127 @@
1
+ import asyncio
2
+ import logging
3
+ from collections.abc import AsyncGenerator, Callable, Coroutine
4
+ from contextlib import AsyncExitStack
5
+ from dataclasses import dataclass
6
+ from functools import partial, update_wrapper
7
+ from typing import Any, Never
8
+
9
+ import click
10
+
11
+ from .itc import ITC
12
+ from .typedefs import PluginFactory, PluginTask
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class CliContext:
19
+ itc: ITC
20
+
21
+
22
+ pass_clictx = click.make_pass_decorator(CliContext)
23
+
24
+
25
+ @dataclass
26
+ class TaskWithName:
27
+ task: PluginTask | None
28
+ name: str
29
+
30
+
31
+ async def sleep_forever(sleep: float = 1, *, forever: bool = True) -> Never | None:
32
+ while await asyncio.sleep(sleep, forever):
33
+ pass
34
+ return None
35
+
36
+
37
+ def create_plugin_task[T](
38
+ task: TaskWithName,
39
+ *,
40
+ create_task_fn: Callable[..., asyncio.Task[T]] = asyncio.create_task,
41
+ ) -> asyncio.Task[T]:
42
+ async def task_wrapper() -> None:
43
+ try:
44
+ if asyncio.iscoroutine(task.task):
45
+ logger.debug(f"Scheduling task for '{task.name}'")
46
+ await task.task
47
+
48
+ else:
49
+ logger.debug(f"Waiting until programme termination for '{task.name}'")
50
+ await sleep_forever(3600)
51
+
52
+ except asyncio.CancelledError:
53
+ logger.debug(f"Task for '{task.name}' cancelled")
54
+
55
+ if task.task is not None:
56
+ task_wrapper = update_wrapper(task_wrapper, task.task) # type: ignore[arg-type]
57
+ else:
58
+ task_wrapper = update_wrapper(task_wrapper, sleep_forever)
59
+
60
+ return create_task_fn(task_wrapper(), name=task.name)
61
+
62
+
63
+ def _get_name(plugin_factory: PluginFactory) -> str:
64
+ # if plugin_factory is e.g. a functools.partial instance, get at the actual
65
+ # function, but if not, then set func to the factory
66
+ func = getattr(plugin_factory, "func", None) or plugin_factory
67
+
68
+ if (nameattr := getattr(func, "__name__", None)) is not None:
69
+ return str(nameattr)
70
+
71
+ return str(func)
72
+
73
+
74
+ async def setup_plugins(
75
+ plugin_factories: list[PluginFactory],
76
+ *args: Any,
77
+ stack: AsyncExitStack,
78
+ **kwargs: Any,
79
+ ) -> list[TaskWithName]:
80
+ tasks: list[TaskWithName] = []
81
+ for plugin_factory in plugin_factories:
82
+ plugin_fn = plugin_factory(*args, **kwargs)
83
+ name = _get_name(plugin_factory)
84
+ logger.debug(f"Setting up task for '{name}'")
85
+ task = await stack.enter_async_context(plugin_fn)
86
+ tasks.append(TaskWithName(task=task, name=name))
87
+
88
+ return tasks
89
+
90
+
91
+ async def run_tasks(tasks: list[TaskWithName]) -> None:
92
+ try:
93
+ async with asyncio.TaskGroup() as tg:
94
+ plugin_task = partial(create_plugin_task, create_task_fn=tg.create_task)
95
+ for task in tasks:
96
+ plugin_task(task)
97
+
98
+ except* asyncio.CancelledError:
99
+ pass
100
+
101
+ finally:
102
+ logger.debug("Terminating…")
103
+
104
+
105
+ async def run_plugins(
106
+ plugin_factories: list[PluginFactory], *args: Any, **kwargs: Any
107
+ ) -> None:
108
+ async with AsyncExitStack() as stack:
109
+ tasks = await setup_plugins(plugin_factories, *args, stack=stack, **kwargs)
110
+ await run_tasks(tasks)
111
+
112
+ logger.debug("Finished.")
113
+
114
+
115
+ type UpdateCallbackType[T] = Callable[[T], Coroutine[None, None, None]]
116
+
117
+
118
+ async def react_to_data_update[T](
119
+ updates_gen: AsyncGenerator[T], *, callback: UpdateCallbackType[T]
120
+ ) -> None:
121
+ try:
122
+ async for update in updates_gen:
123
+ if update is not None:
124
+ await callback(update)
125
+
126
+ except asyncio.CancelledError:
127
+ pass
@@ -0,0 +1,74 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ import click
5
+
6
+ from click_async_plugins import (
7
+ CliContext,
8
+ PluginLifespan,
9
+ cli_core,
10
+ pass_clictx,
11
+ plugin,
12
+ )
13
+ from click_async_plugins.debug import debug
14
+
15
+ logging.basicConfig(level=logging.DEBUG)
16
+ logger = logging.getLogger()
17
+ logging.getLogger("asyncio").setLevel(logging.WARNING)
18
+
19
+
20
+ @cli_core.plugin_command
21
+ @click.option(
22
+ "--from",
23
+ "-f",
24
+ "start",
25
+ type=click.IntRange(min=1),
26
+ default=3,
27
+ help="Count down from this number",
28
+ )
29
+ @click.option(
30
+ "--sleep", "-s", type=float, default=1, help="Sleep this long between counts"
31
+ )
32
+ @pass_clictx
33
+ async def countdown(
34
+ clictx: CliContext, start: int = 3, sleep: float = 1
35
+ ) -> PluginLifespan:
36
+ async def counter(start: int, sleep: float) -> None:
37
+ cur = start
38
+ while cur > 0:
39
+ logger.info(f"Counting down… {cur}")
40
+ clictx.itc.set("countdown", cur)
41
+ cur = await asyncio.sleep(sleep, cur - 1)
42
+
43
+ logger.info("Finished counting down")
44
+
45
+ yield counter(start, sleep)
46
+
47
+ logger.debug("Lifespan over for countdown")
48
+
49
+
50
+ # For fun, let's add_command the second plugin, instead of using a decorator:
51
+ @plugin
52
+ @click.option(
53
+ "--immediately",
54
+ is_flag=True,
55
+ help="Don't wait for first update but echo right upon start",
56
+ )
57
+ @pass_clictx
58
+ async def echo(clictx: CliContext, immediately: bool) -> PluginLifespan:
59
+ async def reactor() -> None:
60
+ async for cur in clictx.itc.updates("countdown", yield_immediately=immediately):
61
+ logger.info(f"Countdown currently at {cur}")
62
+
63
+ yield reactor()
64
+
65
+ logger.debug("Lifespan over for echo")
66
+
67
+
68
+ cli_core.add_command(echo)
69
+ cli_core.add_command(debug)
70
+
71
+ if __name__ == "__main__":
72
+ import sys
73
+
74
+ sys.exit(cli_core())
@@ -0,0 +1,70 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [tool.setuptools_scm]
6
+ write_to = "_version.py"
7
+
8
+ [project]
9
+ name = "click-async-plugins"
10
+ version = "0.7.3"
11
+ authors = [
12
+ { name = "martin f. krafft", email = "click-async-plugins@pobox.madduck.net" },
13
+ ]
14
+ description = "An architecture to easily run asyncio tasks from Click"
15
+ readme = "README.md"
16
+ requires-python = ">=3.12"
17
+ classifiers = [
18
+ "Programming Language :: Python :: 3",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ ]
22
+ dependencies = ["click"]
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "ipdb",
27
+ "pre-commit",
28
+ "ruff",
29
+ "mypy",
30
+ "pytest",
31
+ "pytest-asyncio",
32
+ "pytest-ruff",
33
+ "pytest-cov",
34
+ "coverage",
35
+ ]
36
+
37
+ [tool.setuptools.package-data]
38
+ "click-async-plugins" = ["click_async_plugins/py.typed"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ addopts = "-ra --cov=click_async_plugins --cov-report term-missing:skip-covered --no-cov-on-fail --pdbcls=IPython.terminal.debugger:TerminalPdb --ruff --ruff-format"
43
+ asyncio_default_fixture_loop_scope = "function"
44
+ log_level = "DEBUG"
45
+ log_format = "%(name)s %(levelname)s %(message)s"
46
+
47
+ [tool.ruff]
48
+ line-length = 88
49
+
50
+ [tool.ruff.lint]
51
+ select = ["B", "C", "E", "F", "I", "W", "B9"]
52
+
53
+ [tool.mypy]
54
+ strict = true
55
+ warn_unreachable = true
56
+
57
+ [[tool.mypy.overrides]]
58
+ module = "ipdb"
59
+ ignore_missing_imports = true
60
+
61
+ [tool.coverage.report]
62
+ exclude_also = [
63
+ 'def __repr__',
64
+ 'if TYPE_CHECKING:',
65
+ 'class .*\bProtocol\):',
66
+ '@overload',
67
+ '@(abc\.)?abstractmethod',
68
+ 'raise NotImplementedError',
69
+ 'raise AssertionError',
70
+ ]
@@ -0,0 +1 @@
1
+ import click_async_plugins.typedefs # noqa: F401