click-async-plugins 0.8.0__tar.gz → 0.9.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 (24) hide show
  1. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/PKG-INFO +1 -1
  2. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/_version.py +2 -2
  3. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/click_async_plugins/debug.py +7 -2
  4. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/click_async_plugins/itc.py +45 -2
  5. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/tests/test_debug.py +9 -3
  6. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/tests/test_itc.py +114 -0
  7. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/.gitignore +0 -0
  8. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/.pre-commit-config.yaml +0 -0
  9. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/LICENSE +0 -0
  10. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/README.md +0 -0
  11. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/click_async_plugins/__init__.py +0 -0
  12. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/click_async_plugins/command.py +0 -0
  13. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/click_async_plugins/core.py +0 -0
  14. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/click_async_plugins/dependencies.py +0 -0
  15. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/click_async_plugins/group.py +0 -0
  16. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/click_async_plugins/py.typed +0 -0
  17. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/click_async_plugins/typedefs.py +0 -0
  18. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/click_async_plugins/util.py +0 -0
  19. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/demo.py +0 -0
  20. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/pyproject.toml +0 -0
  21. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/tests/test_command_group_core.py +0 -0
  22. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/tests/test_dependencies.py +0 -0
  23. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/tests/test_typedefs.py +0 -0
  24. {click_async_plugins-0.8.0 → click_async_plugins-0.9.0}/tests/test_util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: click-async-plugins
3
- Version: 0.8.0
3
+ Version: 0.9.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
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.8.0'
22
- __version_tuple__ = version_tuple = (0, 8, 0)
21
+ __version__ = version = '0.9.0'
22
+ __version_tuple__ = version_tuple = (0, 9, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -10,7 +10,7 @@ from collections.abc import AsyncGenerator, Callable
10
10
  from contextlib import asynccontextmanager
11
11
  from dataclasses import dataclass
12
12
  from functools import partial
13
- from typing import Any, Coroutine, cast
13
+ from typing import Any, Coroutine, Never, cast
14
14
 
15
15
  from click_async_plugins.util import CliContext
16
16
 
@@ -44,6 +44,10 @@ def _name_for_coro(coro: Coroutine[Any, Any, Any] | None) -> str:
44
44
  return "(unknown)"
45
45
 
46
46
 
47
+ def interrupt(clictx: CliContext) -> Never:
48
+ raise KeyboardInterrupt
49
+
50
+
47
51
  def debug_info(clictx: CliContext) -> str:
48
52
  """Prints debugging information on tasks and CliContext"""
49
53
  ret = "*** BEGIN DEBUG INFO: ***\n"
@@ -58,7 +62,7 @@ def debug_info(clictx: CliContext) -> str:
58
62
  ret += "CliContext:\n"
59
63
  maxlen = max([len(k) for k in clictx.__dict__.keys()])
60
64
  for attr, value in clictx.__dict__.items():
61
- ret += f" {attr:>{maxlen}s} = {value!r}\n"
65
+ ret += f" {attr:>{maxlen}s} = {value!r} (id:{id(value)})\n"
62
66
  return ret + "*** END DEBUG INFO: ***"
63
67
 
64
68
 
@@ -188,6 +192,7 @@ async def monitor_stdin_for_debug_commands[ContextT: CliContext](
188
192
  decrease_loglevel.__doc__ = "Decrease the logging level"
189
193
 
190
194
  map = {
195
+ 0x3: KeyAndFunc(r"^C", interrupt),
191
196
  0xA: KeyAndFunc(r"\n", echo_newline),
192
197
  0xD: KeyAndFunc(r"\n", echo_newline),
193
198
  0x1B: KeyAndFunc("<Esc>", terminal_block),
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import inspect
2
3
  import logging
3
4
  import time
4
5
  from collections import defaultdict
@@ -8,6 +9,31 @@ from typing import Any
8
9
  logger = logging.getLogger(__name__)
9
10
 
10
11
 
12
+ def _caller(depth: int = 1) -> str:
13
+ """Return a description of a calling frame, ``depth`` frames up.
14
+
15
+ ``depth`` counts frames starting at this helper, so ``depth=1`` (the
16
+ default) reports the direct caller of this helper, ``depth=2`` the frame
17
+ above that, and so on. Returns a ``file:lineno in function`` string, or
18
+ a fallback marker if the frame cannot be determined.
19
+
20
+ Raises:
21
+ ValueError: If ``depth`` is less than 1.
22
+ """
23
+ if depth < 1:
24
+ raise ValueError(f"depth must be >= 1, got {depth}")
25
+
26
+ frame = inspect.currentframe()
27
+ try:
28
+ for _ in range(depth):
29
+ frame = frame.f_back if frame is not None else None
30
+ if frame is None:
31
+ return "<unknown>"
32
+ return f"{frame.f_code.co_filename}:{frame.f_lineno} in {frame.f_code.co_name}"
33
+ finally:
34
+ del frame
35
+
36
+
11
37
  class ITC:
12
38
  def __init__(self) -> None:
13
39
  self._events: dict[str, list[asyncio.Event]] = defaultdict(list[asyncio.Event])
@@ -25,13 +51,20 @@ class ITC:
25
51
  return f"{ret}{' ' if i > 0 else ''})"
26
52
 
27
53
  def set(self, key: str, obj: Any) -> None:
54
+ if logger.isEnabledFor(logging.DEBUG):
55
+ logger.debug(f"set('{key}', {obj!r}) called from {_caller(2)}")
28
56
  self._objects[key] = obj
29
57
  self.fire(key)
30
58
 
31
59
  def get(self, key: str, default: Any = None) -> Any:
32
- return self._objects.get(key, default)
60
+ value = self._objects.get(key, default)
61
+ if logger.isEnabledFor(logging.DEBUG):
62
+ logger.debug(f"get('{key}') -> {value!r} called from {_caller(2)}")
63
+ return value
33
64
 
34
65
  def fire(self, key: str) -> None:
66
+ if logger.isEnabledFor(logging.DEBUG):
67
+ logger.debug(f"fire('{key}') called from {_caller(2)}")
35
68
  logger.debug(f"Notifying subscribers of update to '{key}'…")
36
69
  for event in self._events.get(key) or []:
37
70
  event.set()
@@ -47,6 +80,9 @@ class ITC:
47
80
  ) -> AsyncGenerator[Any | None]:
48
81
  at_most_every = 0 if at_most_every is None else at_most_every
49
82
 
83
+ caller = _caller(2) if logger.isEnabledFor(logging.DEBUG) else ""
84
+ logger.debug(f"updates('{key}') subscribing from {caller}")
85
+
50
86
  if timeout is not None and timeout <= 0:
51
87
  logger.warning("Updates timeout <= 0, disabling timeout…")
52
88
  timeout = None
@@ -60,7 +96,12 @@ class ITC:
60
96
 
61
97
  try:
62
98
  if yield_immediately:
63
- yield self._objects.get(key, yield_for_no_value)
99
+ value = self._objects.get(key, yield_for_no_value)
100
+ if logger.isEnabledFor(logging.DEBUG):
101
+ logger.debug(
102
+ f"updates('{key}') yielding initial {value!r} to {caller}"
103
+ )
104
+ yield value
64
105
 
65
106
  timestamp = 0.0
66
107
  while True:
@@ -81,6 +122,8 @@ class ITC:
81
122
  # instead of being wiped out.
82
123
  event.clear()
83
124
  value = self._objects.get(key, yield_for_no_value)
125
+ if logger.isEnabledFor(logging.DEBUG):
126
+ logger.debug(f"updates('{key}') yielding {value!r} to {caller}")
84
127
  yield value
85
128
  timestamp = time.monotonic()
86
129
 
@@ -4,8 +4,8 @@ import os
4
4
  import pty
5
5
  import sys
6
6
  import types
7
- from collections.abc import AsyncGenerator, Iterator
8
- from typing import Any
7
+ from collections.abc import AsyncGenerator, Coroutine, Iterator
8
+ from typing import Any, cast
9
9
 
10
10
  import pytest
11
11
  from click.testing import CliRunner
@@ -21,6 +21,7 @@ from click_async_plugins.debug import (
21
21
  debug_info,
22
22
  echo_newline,
23
23
  getch,
24
+ interrupt,
24
25
  monitor_stdin_for_debug_commands,
25
26
  print_help,
26
27
  puts,
@@ -52,6 +53,11 @@ def test_echo_newline(clictx: CliContext) -> None:
52
53
  assert echo_newline(clictx) == ""
53
54
 
54
55
 
56
+ def test_interrupt(clictx: CliContext) -> None:
57
+ with pytest.raises(KeyboardInterrupt):
58
+ interrupt(clictx)
59
+
60
+
55
61
  def test_terminal_block(clictx: CliContext) -> None:
56
62
  block = terminal_block(clictx)
57
63
  assert block.startswith("\n" * 8 + "The time is now: ")
@@ -79,7 +85,7 @@ def test_name_for_coro_unknown() -> None:
79
85
 
80
86
  def test_name_for_coro_falls_back_to_name() -> None:
81
87
  # an object with __name__ but no __qualname__
82
- obj = types.SimpleNamespace(__name__="named")
88
+ obj = cast(Coroutine[Any, Any, Any], types.SimpleNamespace(__name__="named"))
83
89
  assert _name_for_coro(obj) == "named"
84
90
 
85
91
 
@@ -207,3 +207,117 @@ async def test_updates_does_not_lose_update_during_consumer_work(itc: ITC) -> No
207
207
 
208
208
  assert await asyncio.wait_for(anext(gen), 1) == 2
209
209
  await gen.aclose()
210
+
211
+
212
+ def test_set_logs_caller_and_value(itc: ITC, caplog: pytest.LogCaptureFixture) -> None:
213
+ caplog.set_level(logging.DEBUG)
214
+ itc.set("key", 7)
215
+ record = next(r for r in caplog.records if r.message.startswith("set("))
216
+ assert "set('key', 7) called from" in record.message
217
+ assert "test_itc.py" in record.message
218
+ assert "test_set_logs_caller_and_value" in record.message
219
+
220
+
221
+ def test_get_logs_caller_and_value(itc: ITC, caplog: pytest.LogCaptureFixture) -> None:
222
+ caplog.set_level(logging.DEBUG)
223
+ itc.set("key", 7)
224
+ caplog.clear()
225
+ assert itc.get("key") == 7
226
+ record = next(r for r in caplog.records if r.message.startswith("get("))
227
+ assert "get('key') -> 7 called from" in record.message
228
+ assert "test_get_logs_caller_and_value" in record.message
229
+
230
+
231
+ def test_fire_logs_caller(itc: ITC, caplog: pytest.LogCaptureFixture) -> None:
232
+ caplog.set_level(logging.DEBUG)
233
+ itc.fire("key")
234
+ record = next(r for r in caplog.records if r.message.startswith("fire("))
235
+ assert "fire('key') called from" in record.message
236
+ assert "test_fire_logs_caller" in record.message
237
+
238
+
239
+ @pytest.mark.asyncio
240
+ async def test_updates_logs_caller_and_values(
241
+ itc: ITC, caplog: pytest.LogCaptureFixture
242
+ ) -> None:
243
+ caplog.set_level(logging.DEBUG)
244
+ itc.set("key", 1)
245
+ caplog.clear()
246
+
247
+ gen = itc.updates("key")
248
+ assert await anext(gen) == 1
249
+ await gen.aclose()
250
+
251
+ assert any(
252
+ "updates('key') subscribing from" in r.message
253
+ and "test_updates_logs_caller_and_values" in r.message
254
+ for r in caplog.records
255
+ )
256
+ assert any(
257
+ "updates('key') yielding initial 1 to" in r.message for r in caplog.records
258
+ )
259
+
260
+
261
+ @pytest.mark.asyncio
262
+ async def test_updates_logs_subsequent_values(
263
+ itc: ITC, caplog: pytest.LogCaptureFixture
264
+ ) -> None:
265
+ caplog.set_level(logging.DEBUG)
266
+ gen = itc.updates("key", yield_immediately=False)
267
+ pending = asyncio.ensure_future(anext(gen))
268
+ await asyncio.sleep(0)
269
+ itc.set("key", 42)
270
+ assert await asyncio.wait_for(pending, 1) == 42
271
+ await gen.aclose()
272
+
273
+ assert any("updates('key') yielding 42 to" in r.message for r in caplog.records)
274
+
275
+
276
+ def test_caller_defaults_to_direct_caller() -> None:
277
+ from click_async_plugins.itc import _caller
278
+
279
+ def helper() -> str:
280
+ return _caller()
281
+
282
+ assert helper().endswith(" in helper")
283
+ assert "test_itc.py" in helper()
284
+
285
+
286
+ def test_caller_depth_walks_up_stack() -> None:
287
+ from click_async_plugins.itc import _caller
288
+
289
+ def inner() -> str:
290
+ return _caller(2)
291
+
292
+ def outer() -> str:
293
+ return inner()
294
+
295
+ assert outer().endswith(" in outer")
296
+
297
+
298
+ def test_caller_zero_depth_raises() -> None:
299
+ from click_async_plugins.itc import _caller
300
+
301
+ with pytest.raises(ValueError, match="depth must be >= 1"):
302
+ _caller(0)
303
+
304
+
305
+ def test_caller_negative_depth_raises() -> None:
306
+ from click_async_plugins.itc import _caller
307
+
308
+ with pytest.raises(ValueError, match="depth must be >= 1"):
309
+ _caller(-1)
310
+
311
+
312
+ def test_caller_depth_beyond_stack_returns_unknown() -> None:
313
+ from click_async_plugins.itc import _caller
314
+
315
+ assert _caller(10_000) == "<unknown>"
316
+
317
+
318
+ def test_get_does_not_log_when_debug_disabled(
319
+ itc: ITC, caplog: pytest.LogCaptureFixture
320
+ ) -> None:
321
+ caplog.set_level(logging.INFO)
322
+ itc.get("key")
323
+ assert not any(r.message.startswith("get(") for r in caplog.records)