devnomads-cli 0.12.1__tar.gz → 0.13.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 (21) hide show
  1. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/PKG-INFO +1 -1
  2. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/devnomads_cli.egg-info/PKG-INFO +1 -1
  3. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/dncli.py +60 -7
  4. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/pyproject.toml +1 -1
  5. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/tests/test_helpers.py +55 -3
  6. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/LICENSE +0 -0
  7. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/README.md +0 -0
  8. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  9. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  10. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/devnomads_cli.egg-info/entry_points.txt +0 -0
  11. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/devnomads_cli.egg-info/requires.txt +0 -0
  12. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/devnomads_cli.egg-info/top_level.txt +0 -0
  13. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/setup.cfg +0 -0
  14. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/tests/test_cert.py +0 -0
  15. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/tests/test_cli.py +0 -0
  16. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/tests/test_config.py +0 -0
  17. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/tests/test_generate.py +0 -0
  18. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/tests/test_generated_cli.py +0 -0
  19. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/tests/test_hook.py +0 -0
  20. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/tests/test_transfer.py +0 -0
  21. {devnomads_cli-0.12.1 → devnomads_cli-0.13.0}/tests/test_versions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.12.1
3
+ Version: 0.13.0
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.12.1
3
+ Version: 0.13.0
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -26,6 +26,7 @@ import hmac
26
26
  import io
27
27
  import json
28
28
  import logging
29
+ import math
29
30
  import os
30
31
  import re
31
32
  import secrets
@@ -36,7 +37,7 @@ import sys
36
37
  import threading
37
38
  import time
38
39
  import urllib.parse
39
- from contextlib import nullcontext
40
+ from contextlib import contextmanager, nullcontext
40
41
  from dataclasses import dataclass
41
42
  from enum import Enum
42
43
  from pathlib import Path
@@ -44,8 +45,8 @@ from typing import (
44
45
  Annotated,
45
46
  Any,
46
47
  Callable,
47
- ContextManager,
48
48
  Iterable,
49
+ Iterator,
49
50
  Literal,
50
51
  Protocol,
51
52
  TypeGuard,
@@ -65,7 +66,7 @@ from rich.markup import escape
65
66
  from rich.table import Table
66
67
  from typer.core import TyperGroup
67
68
 
68
- __version__ = "0.12.1"
69
+ __version__ = "0.13.0"
69
70
 
70
71
  # Distribution names, for reporting versions via `dncli version`.
71
72
  CLI_PACKAGE = "devnomads-cli"
@@ -694,14 +695,66 @@ def _mask(secret: str) -> str:
694
695
  return f"{secret[:4]}...{secret[-4:]}"
695
696
 
696
697
 
697
- def working(message: str) -> ContextManager[Any]:
698
+ _active_status: Any = None # the spinner `working()` is showing, if any
699
+
700
+
701
+ @contextmanager
702
+ def working(message: str) -> Iterator[Any]:
698
703
  """Spinner on stderr while a request is in flight. Inert when stderr
699
704
  is not a terminal (pipelines, CI, redirects), so output stays clean.
700
705
  Never nest two of these: rich allows one live display at a time."""
701
706
 
702
- if err_console.is_terminal:
703
- return err_console.status(message, spinner="dots")
704
- return nullcontext()
707
+ global _active_status
708
+ if not err_console.is_terminal:
709
+ yield None
710
+ return
711
+ with err_console.status(message, spinner="dots") as status:
712
+ _active_status = status
713
+ try:
714
+ yield status
715
+ finally:
716
+ _active_status = None
717
+
718
+
719
+ class _RetryCountdown:
720
+ """Spinner text that counts down to the library's next retry; Rich
721
+ re-renders it on every refresh, so the seconds tick without a thread.
722
+ Once the wait is over it shows what is being retried."""
723
+
724
+ def __init__(self, status: int, path: str, delay: float) -> None:
725
+ self.status = status
726
+ self.path = path
727
+ self.until = time.monotonic() + delay
728
+
729
+ def __rich__(self) -> str:
730
+ left = math.ceil(self.until - time.monotonic())
731
+ if left > 0:
732
+ return f"got {self.status} from {escape(self.path)}, retrying in {left}s"
733
+ return f"retrying {escape(self.path)} (got {self.status})"
734
+
735
+
736
+ # The library logs this right before it sleeps out a 429/5xx backoff.
737
+ _RETRY_LOG_MESSAGE = "got %s from %s, retrying in %.0fs"
738
+
739
+
740
+ def _retry_countdown_filter(record: logging.LogRecord) -> bool:
741
+ """Turn the library's one-off retry warning into a live countdown on the
742
+ spinner. Without a spinner (not a terminal) the warning prints as is."""
743
+
744
+ status = _active_status
745
+ args = record.args
746
+ if status is None or record.msg != _RETRY_LOG_MESSAGE:
747
+ return True
748
+ if not isinstance(args, tuple) or len(args) != 3:
749
+ return True
750
+ code, path, delay = args
751
+ if not isinstance(code, int) or not isinstance(delay, (int, float)):
752
+ return True
753
+ status.update(_RetryCountdown(code, str(path), delay))
754
+ return False
755
+
756
+
757
+ logging.getLogger("devnomads.api").addFilter(_retry_countdown_filter)
705
758
 
706
759
 
707
760
  # ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.12.1"
3
+ version = "0.13.0"
4
4
  description = "Manage your DevNomads services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -334,9 +334,61 @@ def test_flatten_row_without_nesting_is_unchanged():
334
334
 
335
335
 
336
336
  def test_working_is_inert_without_a_terminal():
337
- from contextlib import nullcontext
338
-
339
337
  from dncli import working
340
338
 
341
339
  # test stderr is not a terminal, so no live display may be created
342
- assert isinstance(working("busy"), nullcontext)
340
+ with working("busy") as status:
341
+ assert status is None
342
+
343
+
344
+ def test_retry_warning_becomes_a_countdown_on_the_spinner(monkeypatch):
345
+ import logging
346
+
347
+ import dncli
348
+
349
+ class FakeStatus:
350
+ renderable = None
351
+
352
+ def update(self, renderable):
353
+ self.renderable = renderable
354
+
355
+ status = FakeStatus()
356
+ monkeypatch.setattr(dncli, "_active_status", status)
357
+ clock = [100.0]
358
+ monkeypatch.setattr(dncli.time, "monotonic", lambda: clock[0])
359
+ record = logging.LogRecord(
360
+ "devnomads.api",
361
+ logging.WARNING,
362
+ __file__,
363
+ 0,
364
+ dncli._RETRY_LOG_MESSAGE,
365
+ (429, "/services/databases", 19.0),
366
+ None,
367
+ )
368
+
369
+ # swallowed: the spinner shows it instead of a one-off line
370
+ assert dncli._retry_countdown_filter(record) is False
371
+ countdown = status.renderable
372
+ assert countdown.__rich__() == ("got 429 from /services/databases, retrying in 19s")
373
+ clock[0] += 4.5
374
+ assert countdown.__rich__().endswith("retrying in 15s")
375
+ clock[0] += 15
376
+ assert countdown.__rich__() == "retrying /services/databases (got 429)"
377
+
378
+
379
+ def test_retry_warning_prints_as_is_without_a_spinner(monkeypatch):
380
+ import logging
381
+
382
+ import dncli
383
+
384
+ monkeypatch.setattr(dncli, "_active_status", None)
385
+ record = logging.LogRecord(
386
+ "devnomads.api",
387
+ logging.WARNING,
388
+ __file__,
389
+ 0,
390
+ dncli._RETRY_LOG_MESSAGE,
391
+ (429, "/services", 3.0),
392
+ None,
393
+ )
394
+ assert dncli._retry_countdown_filter(record) is True
File without changes
File without changes
File without changes