ask-shell 0.4.0__tar.gz → 0.5.1__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 (28) hide show
  1. {ask_shell-0.4.0 → ask_shell-0.5.1}/PKG-INFO +1 -1
  2. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/__init__.py +1 -1
  3. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/_run.py +58 -14
  4. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/interactive.py +1 -0
  5. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/models.py +7 -0
  6. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/shell.py +2 -0
  7. ask_shell-0.5.1/ask_shell/test_docs.py +20 -0
  8. {ask_shell-0.4.0 → ask_shell-0.5.1}/pyproject.toml +36 -19
  9. {ask_shell-0.4.0 → ask_shell-0.5.1}/.gitignore +0 -0
  10. {ask_shell-0.4.0 → ask_shell-0.5.1}/LICENSE +0 -0
  11. {ask_shell-0.4.0 → ask_shell-0.5.1}/README.md +0 -0
  12. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/__main__.py +0 -0
  13. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/__init__.py +0 -0
  14. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/_run_env.py +0 -0
  15. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/events.py +0 -0
  16. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/global_callbacks.py +0 -0
  17. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/rich_live.py +0 -0
  18. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/rich_live_callback.py +0 -0
  19. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/rich_progress.py +0 -0
  20. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/rich_run_state.py +0 -0
  21. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/run_pool.py +0 -0
  22. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/typer_command.py +0 -0
  23. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/ask.py +0 -0
  24. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/console.py +0 -0
  25. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/py.typed +0 -0
  26. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/settings.py +0 -0
  27. {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/shell_events.py +0 -0
  28. {ask_shell-0.4.0 → ask_shell-0.5.1}/scripts/fix_source_links.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ask-shell
3
- Version: 0.4.0
3
+ Version: 0.5.1
4
4
  Summary: CLIs with prompts, shell runs, and testable flows
5
5
  Author-email: EspenAlbert <espen.albert1@gmail.com>
6
6
  License-Expression: MIT
@@ -6,7 +6,7 @@ from ask_shell import shell_events
6
6
  from ask_shell import ask
7
7
  from ask_shell.settings import AskShellSettings
8
8
 
9
- VERSION = "0.4.0"
9
+ VERSION = "0.5.1"
10
10
  __all__ = [
11
11
  "shell",
12
12
  "console",
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
  import atexit
14
14
  import logging
15
15
  import os
16
+ import random
16
17
  import signal
17
18
  import subprocess
18
19
  import sys
@@ -40,6 +41,7 @@ from ask_shell._internal.events import (
40
41
  ShellRunStdStarted,
41
42
  )
42
43
  from ask_shell._internal.models import (
44
+ AbortRetryError,
43
45
  RunIncompleteError,
44
46
  ShellConfig,
45
47
  ShellRun,
@@ -370,6 +372,49 @@ def _attempt_run(
370
372
  _runs.pop(key, None)
371
373
 
372
374
 
375
+ def _eval_should_retry(config: ShellConfig, result: ShellRun) -> bool:
376
+ """Evaluate should_retry, raising AbortRetryError through if the callback raises it."""
377
+ try:
378
+ return config.should_retry(result)
379
+ except AbortRetryError:
380
+ raise
381
+ except Exception as e:
382
+ logger.warning(f"should_retry callback failed: {e!r}")
383
+ return False
384
+
385
+
386
+ def _backoff_wait(attempt: int, config: ShellConfig) -> float:
387
+ """wait = min(initial * 2^(attempt-2), max_wait) + uniform(0, jitter)"""
388
+ base = min(config.retry_initial_wait * 2 ** (attempt - 2), config.retry_max_wait)
389
+ return base + random.uniform(0, config.retry_jitter)
390
+
391
+
392
+ def _run_attempts(shell_run: ShellRun, output_dir: Path) -> BaseException | None:
393
+ config = shell_run.config
394
+ queue = shell_run._queue
395
+ for attempt in range(1, config.attempts + 1):
396
+ if attempt > 1:
397
+ wait_seconds = _backoff_wait(attempt, config)
398
+ logger.info(f"Backoff {wait_seconds:.2f}s before attempt {attempt}/{config.attempts}")
399
+ time.sleep(wait_seconds)
400
+ queue.put_nowait(ShellRunRetryAttempt(attempt=attempt))
401
+ logger.warning(f"Retrying run {shell_run} attempt {attempt} of {config.attempts}")
402
+ attempt_log_prefix = config.run_log_stem(attempt)
403
+ result = _attempt_run(shell_run, output_dir, attempt_log_prefix)
404
+ match result:
405
+ case ShellRun() if result.clean_complete:
406
+ return None
407
+ case ShellRun():
408
+ try:
409
+ if not _eval_should_retry(config, result):
410
+ return None
411
+ except AbortRetryError as abort:
412
+ return abort
413
+ case BaseException():
414
+ return result
415
+ return None
416
+
417
+
373
418
  def _execute_run(shell_run: ShellRun) -> ShellRun:
374
419
  """
375
420
  Principles:
@@ -406,20 +451,7 @@ def _execute_run(shell_run: ShellRun) -> ShellRun:
406
451
  with _run_completer(shell_run, consumer_future):
407
452
  output_dir = config.run_output_dir_resolved()
408
453
  output_dir.mkdir(parents=True, exist_ok=True)
409
- error: BaseException | None = None
410
- for attempt in range(1, config.attempts + 1):
411
- if attempt > 1:
412
- queue.put_nowait(ShellRunRetryAttempt(attempt=attempt))
413
- logger.warning(f"Retrying run {shell_run} attempt {attempt} of {config.attempts}")
414
- attempt_log_prefix = config.run_log_stem(attempt)
415
-
416
- result = _attempt_run(shell_run, output_dir, attempt_log_prefix)
417
- match result:
418
- case ShellRun() if result.clean_complete or not config.should_retry(result):
419
- break
420
- case BaseException():
421
- error = result
422
- break
454
+ error = _run_attempts(shell_run, output_dir)
423
455
  queue.put_nowait(ShellRunAfter(run=shell_run, error=error))
424
456
  shell_run._complete(error=error, queue_consumer=consumer_future)
425
457
  return shell_run
@@ -437,6 +469,9 @@ def run(
437
469
  is_binary_call: bool | None = None,
438
470
  message_callbacks: list[Callable[[ShellRunEventT], bool]] | None = None,
439
471
  print_prefix: str | None = None,
472
+ retry_initial_wait: float | None = None,
473
+ retry_jitter: float | None = None,
474
+ retry_max_wait: float | None = None,
440
475
  run_log_stem_prefix: str | None = None,
441
476
  run_output_dir: Path | None | None = None,
442
477
  settings: AskShellSettings | None = None,
@@ -461,6 +496,9 @@ def run(
461
496
  is_binary_call=is_binary_call,
462
497
  message_callbacks=message_callbacks,
463
498
  print_prefix=print_prefix,
499
+ retry_initial_wait=retry_initial_wait,
500
+ retry_jitter=retry_jitter,
501
+ retry_max_wait=retry_max_wait,
464
502
  run_log_stem_prefix=run_log_stem_prefix,
465
503
  run_output_dir=run_output_dir,
466
504
  settings=settings,
@@ -496,6 +534,9 @@ def run_and_wait(
496
534
  is_binary_call: bool | None = None,
497
535
  message_callbacks: list[Callable[[ShellRunEventT], bool]] | None = None,
498
536
  print_prefix: str | None = None,
537
+ retry_initial_wait: float | None = None,
538
+ retry_jitter: float | None = None,
539
+ retry_max_wait: float | None = None,
499
540
  run_log_stem_prefix: str | None = None,
500
541
  run_output_dir: Path | None | None = None,
501
542
  settings: AskShellSettings | None = None,
@@ -520,6 +561,9 @@ def run_and_wait(
520
561
  is_binary_call=is_binary_call,
521
562
  message_callbacks=message_callbacks,
522
563
  print_prefix=print_prefix,
564
+ retry_initial_wait=retry_initial_wait,
565
+ retry_jitter=retry_jitter,
566
+ retry_max_wait=retry_max_wait,
523
567
  run_log_stem_prefix=run_log_stem_prefix,
524
568
  run_output_dir=run_output_dir,
525
569
  settings=settings,
@@ -472,6 +472,7 @@ class question_patcher(force_interactive):
472
472
  q.application.renderer.output = output
473
473
 
474
474
  def ask_question(self, q: Question, response_type: type[T]) -> T:
475
+ _ = response_type # binds generic T for return type inference
475
476
  with create_pipe_input() as inp:
476
477
  q.application.input = inp
477
478
  input_response = self._next_response(q)
@@ -151,6 +151,9 @@ class ShellConfig(Entity):
151
151
  # retry args
152
152
  attempts: int = 1
153
153
  should_retry: Callable[[ShellRun], bool] = always_retry
154
+ retry_initial_wait: float = Field(default=5, description="Seconds before first retry, only used when attempts > 1")
155
+ retry_max_wait: float = Field(default=60, description="Cap on wait between retries")
156
+ retry_jitter: float = Field(default=5, description="Max random jitter added to wait")
154
157
 
155
158
  # logging/output
156
159
  print_prefix: str = Field(default=None, description="Use cwd+binary_name+first_arg if not provided") # type: ignore
@@ -517,6 +520,10 @@ class RunIncompleteError(Exception):
517
520
  self.killed = killed
518
521
 
519
522
 
523
+ class AbortRetryError(Exception):
524
+ """Raise from should_retry to stop retrying with a custom error."""
525
+
526
+
520
527
  class EmptyOutputError(Exception):
521
528
  def __init__(self, run: ShellRun, stream: Literal["stdout", "stderr"] = "stdout"):
522
529
  self.run = run
@@ -7,6 +7,7 @@ from ask_shell._internal._run import run_and_wait as _run_and_wait
7
7
  from ask_shell._internal._run import run_error as _run_error
8
8
  from ask_shell._internal._run import stop_runs_and_pool as _stop_runs_and_pool
9
9
  from ask_shell._internal._run import wait_on_ok_errors as _wait_on_ok_errors
10
+ from ask_shell._internal.models import AbortRetryError as _AbortRetryError
10
11
  from ask_shell._internal.models import ShellConfig as _ShellConfig
11
12
  from ask_shell._internal.models import ShellError as _ShellError
12
13
  from ask_shell._internal.models import ShellRun as _ShellRun
@@ -20,6 +21,7 @@ run_and_wait = _run_and_wait
20
21
  run_error = _run_error
21
22
  stop_runs_and_pool = _stop_runs_and_pool
22
23
  wait_on_ok_errors = _wait_on_ok_errors
24
+ AbortRetryError = _AbortRetryError
23
25
  ShellConfig = _ShellConfig
24
26
  ShellError = _ShellError
25
27
  ShellRun = _ShellRun
@@ -0,0 +1,20 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+ from pytest_examples import CodeExample, EvalExample, find_examples
5
+
6
+ EXAMPLES_DIR = Path(__file__).parent.parent / "docs" / "examples"
7
+
8
+
9
+ @pytest.mark.parametrize("example", find_examples(EXAMPLES_DIR), ids=str)
10
+ def test_examples(example: CodeExample, eval_example: EvalExample):
11
+ eval_example.set_config(line_length=120, target_version="py313", ruff_ignore=["I001", "T"]) # pyright: ignore[reportArgumentType]
12
+ prefix = example.prefix_settings()
13
+ if prefix.get("test", "").startswith("skip"):
14
+ pytest.skip(prefix["test"])
15
+ if eval_example.update_examples:
16
+ eval_example.format(example)
17
+ eval_example.run_print_update(example)
18
+ else:
19
+ eval_example.lint(example)
20
+ eval_example.run_print_check(example)
@@ -2,7 +2,7 @@
2
2
 
3
3
  [project]
4
4
  name = "ask-shell"
5
- version = "0.4.0"
5
+ version = "0.5.1"
6
6
  description = "CLIs with prompts, shell runs, and testable flows"
7
7
  requires-python = ">=3.13"
8
8
  license = "MIT"
@@ -10,9 +10,9 @@ license-files = ["LICENSE"]
10
10
  readme = "README.md"
11
11
  authors = [{ name = "EspenAlbert", email = "espen.albert1@gmail.com" }]
12
12
  classifiers = [
13
- "Development Status :: 4 - Beta",
14
- "Programming Language :: Python :: 3.13",
15
- "Programming Language :: Python :: 3.14",
13
+ "Development Status :: 4 - Beta",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Programming Language :: Python :: 3.14",
16
16
  ]
17
17
  dependencies = [
18
18
  "model-lib[toml]",
@@ -28,12 +28,7 @@ build-backend = "hatchling.build"
28
28
 
29
29
  [tool.hatch.build.targets.sdist]
30
30
  include = ["*/**/*.py", "*/py.typed"]
31
- exclude = [
32
- "*_examples.py",
33
- "*_test.py",
34
- "conftest.py",
35
- "test_*.json",
36
- ]
31
+ exclude = ["*_examples.py", "*_test.py", "conftest.py", "test_*.json"]
37
32
 
38
33
  # === DO_NOT_EDIT: path-sync pkg-ext ===
39
34
  [tool.pkg-ext]
@@ -41,6 +36,12 @@ tag_prefix = "v"
41
36
  max_bump_type = "minor"
42
37
  # === OK_EDIT: path-sync pkg-ext ===
43
38
 
39
+ [tool.pkg-ext.groups.shell]
40
+ examples_include = [
41
+ "AbortRetryError",
42
+ ]
43
+
44
+
44
45
  # === DO_NOT_EDIT: path-sync ruff ===
45
46
  [tool.ruff]
46
47
  line-length = 120
@@ -55,12 +56,13 @@ target-version = "py313"
55
56
  # UP046: use type parameters on functions instead of TypeVar
56
57
  # UP047: use type parameters instead of `Generic[T]` subclass
57
58
  extend-ignore = ["E501", "UP006", "UP007", "UP035", "UP040", "UP046", "UP047"]
58
- extend-select = ["Q", "RUF100", "C90", "UP", "I", "T"]
59
+ extend-select = ["Q", "RUF100", "C90", "UP", "I", "T", "FURB"]
59
60
  # === OK_EDIT: path-sync ruff ===
60
61
 
61
62
  # === DO_NOT_EDIT: path-sync pytest ===
62
63
  [tool.pytest.ini_options]
63
- addopts = "-s -vv --log-cli-level=INFO"
64
+ addopts = "-s -vv --log-cli-level=INFO -m 'not manual'"
65
+ markers = ["manual: test requires manual env setup (not run in CI)"]
64
66
  python_files = ["*_test.py", "test_*.py"]
65
67
  asyncio_mode = "auto"
66
68
  # === OK_EDIT: path-sync pytest ===
@@ -71,22 +73,37 @@ omit = ["*_test.py"]
71
73
  exclude_lines = ["no cov", "if __name__ == .__main__.:"]
72
74
  # === OK_EDIT: path-sync coverage ===
73
75
 
76
+ # === DO_NOT_EDIT: path-sync vulture ===
77
+ [tool.vulture]
78
+ min_confidence = 80
79
+ ignore_names = ["mock_*", "Mock*", "*_mock"]
80
+ exclude = [".venv/", "*test.py"]
81
+ # === OK_EDIT: path-sync vulture ===
82
+
83
+ # === DO_NOT_EDIT: path-sync dev-lint ===
74
84
  [dependency-groups]
75
- dev = [
85
+ dev-lint = [
76
86
  "pyright>=1.1",
87
+ "ruff>=0.14",
88
+ "vulture>=2.14",
89
+ ]
90
+ # === OK_EDIT: path-sync dev-lint ===
91
+ dev-test = [
77
92
  "pytest>=9.0",
78
93
  "pytest-asyncio>=0.24",
79
94
  "pytest-cov>=7.0",
95
+ "pytest-examples>=0.0.18",
80
96
  "pytest-model-lib",
81
- "ruff>=0.14",
97
+ "pytest-regressions>=2.6",
82
98
  ]
83
-
99
+ # === DO_NOT_EDIT: path-sync dev ===
100
+ dev = [{include-group = "dev-lint"}, {include-group = "dev-test"}]
101
+ # === OK_EDIT: path-sync dev ===
102
+ # === DO_NOT_EDIT: path-sync release ===
103
+ release = ["pkg-ext"]
104
+ # === OK_EDIT: path-sync release ===
84
105
  # === DO_NOT_EDIT: path-sync docs ===
85
106
  docs = [
86
107
  "mkdocs-material>=9.5",
87
108
  ]
88
109
  # === OK_EDIT: path-sync docs ===
89
-
90
- # === DO_NOT_EDIT: path-sync release ===
91
- release = ["pkg-ext"]
92
- # === OK_EDIT: path-sync release ===
File without changes
File without changes
File without changes
File without changes
File without changes