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.
- {ask_shell-0.4.0 → ask_shell-0.5.1}/PKG-INFO +1 -1
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/__init__.py +1 -1
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/_run.py +58 -14
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/interactive.py +1 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/models.py +7 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/shell.py +2 -0
- ask_shell-0.5.1/ask_shell/test_docs.py +20 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/pyproject.toml +36 -19
- {ask_shell-0.4.0 → ask_shell-0.5.1}/.gitignore +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/LICENSE +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/README.md +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/__main__.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/__init__.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/_run_env.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/events.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/global_callbacks.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/rich_live.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/rich_live_callback.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/rich_progress.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/rich_run_state.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/run_pool.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/_internal/typer_command.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/ask.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/console.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/py.typed +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/settings.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/ask_shell/shell_events.py +0 -0
- {ask_shell-0.4.0 → ask_shell-0.5.1}/scripts/fix_source_links.py +0 -0
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|