Skip to content

Commit 1827df2

Browse files
committed
Include debug plugin
1 parent b2c8905 commit 1827df2

File tree

7 files changed

+211
-19
lines changed

7 files changed

+211
-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: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@
33
import click
44

55
from .group import plugin_group
6-
from .itc import ITC
76
from .typedefs import PluginFactory
8-
from .util import run_plugins
7+
from .util import CliContext, run_plugins
98

109

1110
@plugin_group
1211
@click.pass_context
1312
def cli_core(ctx: click.Context) -> None:
14-
ctx.ensure_object(ITC)
13+
ctx.ensure_object(CliContext)
1514

1615

1716
@cli_core.result_callback()

click_async_plugins/debug.py

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