Skip to content

Commit b3d55c4

Browse files
committed
Include debug plugin
1 parent b2c8905 commit b3d55c4

File tree

7 files changed

+215
-19
lines changed

7 files changed

+215
-19
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ DEBUG:root:Finished.
6262

6363
I hope you get the idea.
6464

65+
There's also a "debug" plugin included, which allows interaction with the CLI as
66+
its running via key presses. Hit `?` to get an overview of commands available.
67+
6568
Looking forward to your feedback.
6669

6770
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.

click_async_plugins/__init__.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
from .command import plugin
22
from .core import cli_core, runner
33
from .group import plugin_group
4-
from .itc import ITC, pass_itc
4+
from .itc import ITC
55
from .typedefs import PluginFactory, PluginLifespan
6-
from .util import create_plugin_task, run_plugins, run_tasks, setup_plugins
6+
from .util import (
7+
CliContext,
8+
create_plugin_task,
9+
pass_clictx,
10+
run_plugins,
11+
run_tasks,
12+
setup_plugins,
13+
)
714

815
__all__ = [
16+
"CliContext",
917
"cli_core",
1018
"create_plugin_task",
1119
"ITC",
12-
"pass_itc",
20+
"pass_clictx",
1321
"plugin",
14-
"plugin_group",
1522
"PluginFactory",
23+
"plugin_group",
1624
"PluginLifespan",
1725
"runner",
1826
"run_plugins",

click_async_plugins/core.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@
22

33
import click
44

5+
from click_async_plugins.itc import ITC
6+
57
from .group import plugin_group
6-
from .itc import ITC
78
from .typedefs import PluginFactory
8-
from .util import run_plugins
9+
from .util import CliContext, run_plugins
910

1011

1112
@plugin_group
1213
@click.pass_context
1314
def cli_core(ctx: click.Context) -> None:
14-
ctx.ensure_object(ITC)
15+
ctx.obj = CliContext(itc=ITC())
1516

1617

1718
@cli_core.result_callback()

click_async_plugins/debug.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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 Callable
10+
from contextlib import asynccontextmanager
11+
from dataclasses import dataclass
12+
from functools import partial
13+
14+
from click_async_plugins.util import CliContext
15+
16+
from . import PluginLifespan, pass_clictx, plugin
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
def puts(s: str) -> None:
22+
print(s, file=sys.stderr)
23+
24+
25+
def simulate_reload_tpdata(clictx: CliContext) -> None:
26+
"""Simulate event that TPData was reloaded"""
27+
clictx.itc.fire("tpdata")
28+
29+
30+
def echo_newline(_: CliContext) -> None:
31+
"""Outputs a new line"""
32+
puts("")
33+
34+
35+
def terminal_block(_: CliContext) -> None:
36+
"""Outputs a couple of newlines and the current time"""
37+
puts(f"{'\n' * 8}The time is now: {datetime.datetime.now().isoformat(sep=' ')}\n")
38+
39+
40+
def debug_info(clictx: CliContext) -> None:
41+
"""Prints debugging information on tasks and CliContext"""
42+
puts("*** BEGIN DEBUG INFO: ***")
43+
puts("Tasks:")
44+
for i, task in enumerate(asyncio.all_tasks(asyncio.get_event_loop()), 1):
45+
coro = task.get_coro()
46+
puts(
47+
f" {i:02n} {task.get_name():32s} "
48+
f"state={task._state.lower():8s} "
49+
f"coro={None if coro is None else coro.__qualname__}"
50+
)
51+
puts("CliContext:")
52+
maxlen = max([len(k) for k in clictx.__dict__.keys()])
53+
for attr, value in clictx.__dict__.items():
54+
puts(f" {attr:>{maxlen}s} = {value!r}")
55+
puts("*** END DEBUG INFO: ***")
56+
57+
58+
_LOGLEVELS = {
59+
logging.DEBUG: "DEBUG",
60+
logging.INFO: "INFO",
61+
logging.WARN: "WARN",
62+
logging.ERROR: "ERROR",
63+
logging.CRITICAL: "CRITICAL",
64+
}
65+
66+
67+
def adjust_loglevel(_: CliContext, change: int) -> None:
68+
"""Adjusts the log level"""
69+
rootlogger = logging.getLogger()
70+
newlevel = rootlogger.getEffectiveLevel() + change
71+
if newlevel < logging.DEBUG or newlevel > logging.CRITICAL:
72+
return
73+
74+
rootlogger.setLevel(newlevel)
75+
puts(f"Log level now at {_LOGLEVELS[logger.getEffectiveLevel()]}")
76+
77+
78+
@dataclass
79+
class KeyAndFunc:
80+
key: str
81+
func: Callable[[CliContext], None]
82+
83+
84+
type KeyCmdMapType = dict[int, KeyAndFunc]
85+
86+
87+
def print_help(_: CliContext, key_to_cmd: KeyCmdMapType) -> None:
88+
puts("Keys I know about for debugging:")
89+
for keyfunc in key_to_cmd.values():
90+
puts(f" {keyfunc.key:5s} {keyfunc.func.__doc__}")
91+
puts(" ? Print this message")
92+
93+
94+
try:
95+
import fcntl
96+
import termios
97+
import tty
98+
99+
async def _monitor_stdin(clictx: CliContext, key_to_cmd: KeyCmdMapType) -> None:
100+
fd = sys.stdin.fileno()
101+
termios_saved = termios.tcgetattr(fd)
102+
fnctl_flags = fcntl.fcntl(sys.stdin, fcntl.F_GETFL)
103+
104+
try:
105+
logger.debug("Configuring stdin for raw input")
106+
tty.setcbreak(fd)
107+
fcntl.fcntl(sys.stdin, fcntl.F_SETFL, fnctl_flags | os.O_NONBLOCK)
108+
109+
while True:
110+
ch = sys.stdin.read(1)
111+
112+
if len(ch) == 0:
113+
await asyncio.sleep(0.1)
114+
continue
115+
116+
if (key := ord(ch)) == 0x3F:
117+
print_help(clictx, key_to_cmd)
118+
119+
elif (keyfunc := key_to_cmd.get(key)) is not None and callable(
120+
keyfunc.func
121+
):
122+
keyfunc.func(clictx)
123+
124+
else:
125+
logger.debug(f"Ignoring character 0x{key:02x} on stdin")
126+
127+
finally:
128+
logger.debug("Restoring stdin")
129+
termios.tcsetattr(fd, termios.TCSADRAIN, termios_saved)
130+
fcntl.fcntl(sys.stdin, fcntl.F_SETFL, fnctl_flags)
131+
132+
except ImportError:
133+
134+
async def _monitor_stdin(clictx: CliContext, key_to_cmd: KeyCmdMapType) -> None:
135+
_ = clictx, key_to_cmd
136+
logger.warning("The 'debug' plugin does not work on this platform")
137+
return None
138+
139+
140+
@asynccontextmanager
141+
async def monitor_stdin_for_debug_commands(clictx: CliContext) -> PluginLifespan:
142+
increase_loglevel = partial(adjust_loglevel, change=-10)
143+
increase_loglevel.__doc__ = "Increase the logging level"
144+
decrease_loglevel = partial(adjust_loglevel, change=10)
145+
decrease_loglevel.__doc__ = "Decrease the logging level"
146+
147+
key_to_cmd = {
148+
0xA: KeyAndFunc(r"\n", echo_newline),
149+
0x12: KeyAndFunc("^R", simulate_reload_tpdata),
150+
0x1B: KeyAndFunc("<Esc>", terminal_block),
151+
0x4: KeyAndFunc("^D", debug_info),
152+
0x2B: KeyAndFunc("+", increase_loglevel),
153+
0x2D: KeyAndFunc("-", decrease_loglevel),
154+
}
155+
yield _monitor_stdin(clictx, key_to_cmd)
156+
157+
158+
@plugin
159+
@pass_clictx
160+
async def debug(clictx: CliContext) -> PluginLifespan:
161+
"""Monitor stdin for keypresses to trigger debugging functions
162+
163+
Press '?' to get a list of possible keys.
164+
"""
165+
166+
async with monitor_stdin_for_debug_commands(clictx) as task:
167+
yield task

click_async_plugins/itc.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
from collections.abc import AsyncGenerator
55
from typing import Any
66

7-
import click
8-
97
logger = logging.getLogger(__name__)
108

119

@@ -47,6 +45,3 @@ def has_subscribers(self, key: str) -> bool:
4745

4846
def knows_about(self, key: str) -> bool:
4947
return key in self._objects
50-
51-
52-
pass_itc = click.make_pass_decorator(ITC)

click_async_plugins/util.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,26 @@
22
import logging
33
from collections.abc import Callable
44
from contextlib import AsyncExitStack
5+
from dataclasses import dataclass
56
from functools import partial, update_wrapper
67
from typing import Any, Never
78

9+
import click
10+
11+
from .itc import ITC
812
from .typedefs import PluginFactory, PluginTask
913

1014
logger = logging.getLogger(__name__)
1115

1216

17+
@dataclass
18+
class CliContext:
19+
itc: ITC
20+
21+
22+
pass_clictx = click.make_pass_decorator(CliContext)
23+
24+
1325
async def sleep_forever(sleep: float = 1, *, forever: bool = True) -> Never | None:
1426
while await asyncio.sleep(sleep, forever):
1527
pass

demo.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33

44
import click
55

6-
from click_async_plugins import ITC, PluginLifespan, cli_core, pass_itc, plugin
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
714

815
logging.basicConfig(level=logging.DEBUG)
916
logger = logging.getLogger()
@@ -22,13 +29,15 @@
2229
@click.option(
2330
"--sleep", "-s", type=float, default=1, help="Sleep this long between counts"
2431
)
25-
@pass_itc
26-
async def countdown(itc: ITC, start: int = 3, sleep: float = 1) -> PluginLifespan:
32+
@pass_clictx
33+
async def countdown(
34+
clictx: CliContext, start: int = 3, sleep: float = 1
35+
) -> PluginLifespan:
2736
async def counter(start: int, sleep: float) -> None:
2837
cur = start
2938
while cur > 0:
3039
logger.info(f"Counting down… {cur}")
31-
itc.set("countdown", cur)
40+
clictx.itc.set("countdown", cur)
3241
cur = await asyncio.sleep(sleep, cur - 1)
3342

3443
logger.info("Finished counting down")
@@ -45,10 +54,10 @@ async def counter(start: int, sleep: float) -> None:
4554
is_flag=True,
4655
help="Don't wait for first update but echo right upon start",
4756
)
48-
@pass_itc
49-
async def echo(itc: ITC, immediately: bool) -> PluginLifespan:
57+
@pass_clictx
58+
async def echo(clictx: CliContext, immediately: bool) -> PluginLifespan:
5059
async def reactor() -> None:
51-
async for cur in itc.updates("countdown", yield_immediately=immediately):
60+
async for cur in clictx.itc.updates("countdown", yield_immediately=immediately):
5261
logger.info(f"Countdown currently at {cur}")
5362

5463
yield reactor()
@@ -57,6 +66,7 @@ async def reactor() -> None:
5766

5867

5968
cli_core.add_command(echo)
69+
cli_core.add_command(debug)
6070

6171
if __name__ == "__main__":
6272
import sys

0 commit comments

Comments
 (0)