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.
- click_async_plugins-0.7.3/.github/workflows/python-package.yml +88 -0
- click_async_plugins-0.7.3/.gitignore +8 -0
- click_async_plugins-0.7.3/.pre-commit-config.yaml +20 -0
- click_async_plugins-0.7.3/LICENSE +21 -0
- click_async_plugins-0.7.3/PKG-INFO +99 -0
- click_async_plugins-0.7.3/README.md +76 -0
- click_async_plugins-0.7.3/click_async_plugins/__init__.py +31 -0
- click_async_plugins-0.7.3/click_async_plugins/command.py +26 -0
- click_async_plugins-0.7.3/click_async_plugins/core.py +20 -0
- click_async_plugins-0.7.3/click_async_plugins/debug.py +211 -0
- click_async_plugins-0.7.3/click_async_plugins/group.py +16 -0
- click_async_plugins-0.7.3/click_async_plugins/itc.py +89 -0
- click_async_plugins-0.7.3/click_async_plugins/py.typed +0 -0
- click_async_plugins-0.7.3/click_async_plugins/typedefs.py +7 -0
- click_async_plugins-0.7.3/click_async_plugins/util.py +127 -0
- click_async_plugins-0.7.3/demo.py +74 -0
- click_async_plugins-0.7.3/pyproject.toml +70 -0
- click_async_plugins-0.7.3/tests/test_typedefs.py +1 -0
|
@@ -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,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
|
+
[](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
|
+
[](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
|