From 882c01260324d31310930f22a6a4d287a16b748a Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Tue, 19 May 2026 09:52:25 +0100 Subject: [PATCH 1/9] adds example for add_renderable --- ask_shell/test_docs.py | 2 +- docs/console/index.md | 1 + docs/examples/console/add_renderable.md | 146 +++++++++++++++++ examples/apply_live/__init__.py | 0 examples/apply_live/cli.py | 73 +++++++++ examples/apply_live/stream_handler.py | 177 +++++++++++++++++++++ examples/apply_live/stream_handler_test.py | 78 +++++++++ examples/apply_live/workspace/.gitignore | 4 + examples/apply_live/workspace/main.tf | 21 +++ examples/apply_live/workspace/versions.tf | 10 ++ justfile | 3 + mkdocs.yml | 5 +- pyproject.toml | 5 + 13 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 docs/examples/console/add_renderable.md create mode 100644 examples/apply_live/__init__.py create mode 100644 examples/apply_live/cli.py create mode 100644 examples/apply_live/stream_handler.py create mode 100644 examples/apply_live/stream_handler_test.py create mode 100644 examples/apply_live/workspace/.gitignore create mode 100644 examples/apply_live/workspace/main.tf create mode 100644 examples/apply_live/workspace/versions.tf diff --git a/ask_shell/test_docs.py b/ask_shell/test_docs.py index b919971..c2c3bb6 100644 --- a/ask_shell/test_docs.py +++ b/ask_shell/test_docs.py @@ -8,7 +8,7 @@ @pytest.mark.parametrize("example", find_examples(EXAMPLES_DIR), ids=str) def test_examples(example: CodeExample, eval_example: EvalExample): - eval_example.set_config(line_length=120, target_version="py313", ruff_ignore=["I001", "T"]) # pyright: ignore[reportArgumentType] + eval_example.set_config(line_length=120, target_version="py313", ruff_ignore=["T"]) # pyright: ignore[reportArgumentType] prefix = example.prefix_settings() if prefix.get("test", "").startswith("skip"): pytest.skip(prefix["test"]) diff --git a/docs/console/index.md b/docs/console/index.md index e013767..9361108 100644 --- a/docs/console/index.md +++ b/docs/console/index.md @@ -41,6 +41,7 @@ class RemoveLivePart: ### function: `add_renderable` - [source](../../ask_shell/_internal/rich_live.py#L152) +- [Example: Mount a dynamic Rich renderable in ask-shell's live region with add_renderable, plus an apply-live demo with CI heartbeats](../examples/console/add_renderable.md) > **Since:** 0.3.0 ```python diff --git a/docs/examples/console/add_renderable.md b/docs/examples/console/add_renderable.md new file mode 100644 index 0000000..86676a5 --- /dev/null +++ b/docs/examples/console/add_renderable.md @@ -0,0 +1,146 @@ + +# add_renderable + +`add_renderable` mounts any [Rich renderable](https://rich.readthedocs.io/en/stable/protocol.html) inside ask-shell's global `Live` region and returns a `RemoveLivePart` callable to detach it. Use it for status that should stay pinned while shell runs progress, instead of [`print_to_live`](../../console/index.md#print_to_live_def) (which lives in scrollback) or [`new_task`](../../console/index.md#new_task_def) (which renders a `rich.progress.Progress` bar). + +The live region is a sorted `Group` of all attached parts. Each part has a `name` (used for stable sort and removal) and an `order` (vertical placement; shell-run progress uses `-100`, so any non-negative `order` stays below it). + +## Dynamic renderable via `__rich_console__` + +The cheapest way to get live updates is to implement [`__rich_console__`](https://rich.readthedocs.io/en/stable/protocol.html#console-render) on a small class that reads mutable state. Rich's auto-refresh thread re-invokes the method ~4 times per second while the live region is up, so updates from any thread show without calling a refresh helper yourself. + +```python +from io import StringIO + +from rich.console import Console +from rich.text import Text + + +class JobStatus: + def __init__(self) -> None: + self.done = 0 + self.in_flight = 0 + self.elapsed_s = 0.0 + + def __rich_console__(self, console, options): + yield Text(f"refresh: {self.done} complete, {self.in_flight} in progress ({self.elapsed_s:.0f}s)") + + +status = JobStatus() +status.done = 5 +status.in_flight = 3 +status.elapsed_s = 12.0 + +probe = Console(file=StringIO(), width=80, color_system=None, legacy_windows=False) +probe.print(status) +print(probe.file.getvalue().strip()) +#> refresh: 5 complete, 3 in progress (12s) +``` + +The example renders against a captured console so it can be tested without a real terminal. In production code you would pass the same instance to `add_renderable` (see below). + +## Wiring it into the live region + +Pass the instance once, then mutate its fields from any thread. The 4 Hz auto-refresh picks up the new state. Always release the part in a `finally` so a crash does not leave the live region pinned. + +```python test="skip" +from ask_shell.console import add_renderable + + +class JobStatus: ... # as above + + +status = JobStatus() +remove = add_renderable(status, name="job-status", order=10) +try: + # mutate status.done / status.in_flight from your event loop; + # Rich re-renders automatically on its 4 Hz tick + ... +finally: + remove(print_after_removing=True) +``` + +`remove(print_after_removing=True)` flushes one final render into scrollback before detaching, which is useful for converting a transient status into a permanent log entry. + +## Apply-live demo (`terraform apply -json`) + +The `apply-live` CLI demonstrates the same pattern tfdo uses for streaming Terraform output: a Typer app with [`configure_logging`](../../console/index.md#configure_logging_def), a shell run with `skip_progress_output=True`, and an NDJSON handler wired through `message_callbacks`. + +The demo lives at `examples/apply_live/`: + +- `workspace/` holds four chained `time_sleep` resources (`create_duration = "5s"`, each depends on the previous). A fresh `terraform apply -json -auto-approve` takes ~20s as resources are created one after another. +- The CLI always runs `terraform init -input=false` first so a clean checkout works in CI without a separate init step. +- Re-run after the first apply is a no-op and finishes quickly. Run `terraform destroy -auto-approve` in `workspace/` to reset state. + +Run from the ask-shell repo (`code/ask-shell/`): + +```sh +just apply-live +``` + +Locally you should see a pinned `refresh · N done · M running · …` line above the shell progress row. In CI the panel is invisible; the handler emits `logger.info` heartbeats every five seconds instead: + +```sh +CI=true just apply-live +``` + +The handler is a minimal copy of tfdo's plan stream path (no tfdo import): + +```python test="skip" +from pathlib import Path + +from ask_shell.shell import run_and_wait +from apply_live.stream_handler import PlanStreamHandler, plan_stream_callback + +handler = PlanStreamHandler() +callback = plan_stream_callback(handler) +workspace = Path("code/ask-shell/examples/apply_live/workspace") +run = run_and_wait( + "terraform apply -json -auto-approve", + cwd=workspace, + allow_non_zero_exit=True, + skip_progress_output=True, + message_callbacks=[callback], +) +handler.flush() +print(run.exit_code) +#> 0 +``` + +## When to pick which API + +- **`add_renderable`**: dynamic status that must stay visible while shell commands and tasks update. Disappears with the live region when no parts remain (`transient=True`). +- **`print_to_live` / `log_to_live`**: one-shot lines that belong in scrollback (warnings, phase changes, diagnostics). +- **`new_task`**: discrete units of work with a progress bar, elapsed timer, and an automatic `INFO` log on completion. + +Mixing them is normal. A typical pattern keeps a `add_renderable` panel for live counters, calls `print_to_live` once per diagnostic, and lets ask-shell's own `_RunState` manage the per-process `new_task`. + +## CI and non-TTY rendering + +[`interactive_shell()`](../../console/index.md#interactive_shell_def) returns `False` when any of these are true: + +- `CI=true` in the environment. +- `TERM` is `dumb` or `unknown`. +- `stdout` is not a TTY. +- The process runs under pytest. +- The process runs inside a container. + +When the console is not interactive, Rich's `Live.process_renderables` does not append the live render and `Live.refresh` is a no-op on non-terminals. That means **`add_renderable` content is invisible in CI logs**. What still reaches the log stream: + +- `print_to_live(...)` and `log_to_live(...)` write through `console.print()` directly to stdout, no live overlay involved. +- `logger.info(...)` wired by `configure_logging(...)` emits through `RichHandler` onto the same console. +- `new_task` completion: `log_task_done` calls `logger.info`, so the `Running: '...' completed in Ns` line appears even in CI. + +### Avoiding frozen GitHub Actions logs + +GitHub Actions streams a step's log by line. A long shell command whose only signal is an `add_renderable` panel produces no stdout, so the step appears stalled until it exits. Mitigations, in order of impact: + +- **Set `PYTHONUNBUFFERED=1`** in the workflow `env`. Without it, Python falls back to block buffering when stdout is not a TTY and short bursts of output may sit in the buffer for minutes. +- **Mirror the dynamic state into `print_to_live` or `logger.info`** on a heartbeat (every few seconds). The renderable still drives the local UX; the heartbeat keeps the CI stream alive. The apply-live demo uses `logger.info("refresh: N complete, M in progress (…)")` when `interactive_shell()` is false. +- **Set `log_updates=True` on `new_task`** when you want every `task.update(...)` call to emit a log line. +- **Set `include_log_time=True` on `ShellConfig`** so each captured shell line carries a `[hh:mm:ss]` prefix. Stalls become easier to spot in CI output. +- **Pass `skip_progress_output=True` to `run_and_wait`** for verbose commands like `terraform plan -json`. The shell-run progress collapses to `"..."`, which keeps the live region short on local runs and stops Rich's cursor positioning from drifting out of the viewport. + +To reproduce CI semantics locally without pushing a workflow: `CI=true just apply-live` from `code/ask-shell/`. `interactive_shell()` flips to `False` and the rendering path matches the GitHub runner. diff --git a/examples/apply_live/__init__.py b/examples/apply_live/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/apply_live/cli.py b/examples/apply_live/cli.py new file mode 100644 index 0000000..991f97a --- /dev/null +++ b/examples/apply_live/cli.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +import typer + +from ask_shell import console +from ask_shell.shell import run_and_wait +from apply_live.stream_handler import PlanStreamHandler, plan_stream_callback + +logger = logging.getLogger(__name__) + +app = typer.Typer( + name="apply-live", + help="Demo terraform apply -json streaming with ask-shell live rendering.", +) + +_DEFAULT_WORKSPACE = Path(__file__).resolve().parent / "workspace" + + +def _run_terraform(command: str, *, workspace: Path) -> int: + run = run_and_wait( + command, + cwd=workspace, + allow_non_zero_exit=True, + skip_progress_output=True, + ) + return run.exit_code or 0 + + +def _terraform_init(*, workspace: Path) -> None: + logger.info("running terraform init") + if (exit_code := _run_terraform("terraform init -input=false", workspace=workspace)) != 0: + raise typer.Exit(exit_code) + logger.info("terraform init complete") + + +@app.command() +def apply( + workspace: Path = typer.Option( + _DEFAULT_WORKSPACE, + "--workspace", + "-w", + help="Terraform workspace with chained time_sleep resources.", + exists=True, + file_okay=False, + ), +) -> None: + _terraform_init(workspace=workspace) + + handler = PlanStreamHandler() + callback = plan_stream_callback(handler) + run = run_and_wait( + "terraform apply -json -auto-approve", + cwd=workspace, + allow_non_zero_exit=True, + skip_progress_output=True, + message_callbacks=[callback], + ) + handler.flush() + if (exit_code := run.exit_code or 0) != 0: + raise typer.Exit(exit_code) + logger.info("apply complete") + + +def main() -> None: + console.configure_logging(app) + app() + + +if __name__ == "__main__": + main() diff --git a/examples/apply_live/stream_handler.py b/examples/apply_live/stream_handler.py new file mode 100644 index 0000000..8897806 --- /dev/null +++ b/examples/apply_live/stream_handler.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import json +import logging +import time +from enum import StrEnum + +from rich.console import Console, ConsoleOptions, RenderResult +from rich.text import Text + +from ask_shell import console as ask_console +from ask_shell.console import RemoveLivePart, interactive_shell +from ask_shell.shell_events import ShellRunStdOutput + +logger = logging.getLogger(__name__) + +REFRESH_HEARTBEAT_INTERVAL_S = 5.0 + + +class _Phase(StrEnum): + REFRESH = "refresh" + PLANNING = "planning" + DONE = "done" + + +class _PlanStatusRenderable: + def __init__(self, handler: PlanStreamHandler) -> None: + self._handler = handler + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + status = self._handler._status_line() + if status: + yield status + + +class PlanStreamHandler: + def __init__(self) -> None: + self._started = time.monotonic() + self._phase = _Phase.REFRESH + self._in_flight: set[str] = set() + self._done = 0 + self._planning_emitted = False + self._diagnostic_emitted = False + self._last_heartbeat = self._started + self._carry = "" + self._status = _PlanStatusRenderable(self) + self._remove_panel: RemoveLivePart | None = ask_console.add_renderable( + self._status, order=10, name="plan-status" + ) + + def feed_line(self, chunk: str) -> None: + self._carry += chunk + while "\n" in self._carry: + line, self._carry = self._carry.split("\n", 1) + stripped = line.strip() + if stripped: + self._handle_line(stripped) + + def flush(self) -> None: + if self._carry.strip(): + self._handle_line(self._carry.strip()) + self._carry = "" + self._remove_status_panel() + + def _handle_line(self, line: str) -> None: + try: + data = json.loads(line) + except json.JSONDecodeError: + logger.debug(f"skipping non-json plan stream line: {line[:120]!r}") + return + msg_type = data.get("type") + match msg_type: + case "diagnostic": + self._emit_diagnostic(data) + case "refresh_start" | "apply_start": + self._on_refresh_start(data) + case "refresh_complete" | "apply_complete": + self._on_refresh_complete(data) + case "change_summary": + if (data.get("changes") or {}).get("operation") == "apply": + self._phase = _Phase.DONE + case "resource_drift" | "outputs" | "log": + self._leave_refresh_phase() + case _: + pass + self._maybe_heartbeat() + + def _emit_diagnostic(self, data: dict) -> None: + diagnostic = data.get("diagnostic") or {} + summary = diagnostic.get("summary") + if not summary: + return + if not self._diagnostic_emitted: + self._diagnostic_emitted = True + ask_console.print_to_live("") + severity = (diagnostic.get("severity") or "error").capitalize() + ask_console.print_to_live(f"{severity}: {summary}") + if detail := diagnostic.get("detail"): + ask_console.print_to_live(detail) + + def _on_refresh_start(self, data: dict) -> None: + if addr := _resource_addr(data): + self._in_flight.add(addr) + + def _on_refresh_complete(self, data: dict) -> None: + if addr := _resource_addr(data): + self._in_flight.discard(addr) + self._done += 1 + + def _leave_refresh_phase(self) -> None: + if self._phase != _Phase.REFRESH: + return + self._phase = _Phase.PLANNING + self._emit_planning_once() + + def _emit_planning_once(self) -> None: + if self._planning_emitted: + return + self._planning_emitted = True + ask_console.print_to_live("plan: computing changes…") + + def _status_line(self) -> Text | None: + if self._phase == _Phase.DONE: + return None + if self._phase == _Phase.PLANNING: + return Text("planning…", style="cyan") + return self._refresh_status(time.monotonic() - self._started) + + def _refresh_status(self, elapsed_s: float) -> Text: + mins, secs = divmod(int(elapsed_s), 60) + elapsed = f"{mins}m {secs:02d}s" if mins else f"{secs}s" + return Text.assemble( + ("refresh", "cyan"), + " · ", + (str(self._done), "bold"), + " done · ", + (str(len(self._in_flight)), "bold"), + " running · ", + (elapsed, "dim"), + ) + + def _maybe_heartbeat(self) -> None: + if interactive_shell() or self._phase != _Phase.REFRESH: + return + now = time.monotonic() + if now - self._last_heartbeat < REFRESH_HEARTBEAT_INTERVAL_S: + return + self._last_heartbeat = now + logger.info(self._heartbeat_message(now - self._started)) + + def _heartbeat_message(self, elapsed_s: float) -> str: + mins, secs = divmod(int(elapsed_s), 60) + elapsed = f"{mins}m {secs:02d}s" if mins else f"{secs}s" + return f"refresh: {self._done} complete, {len(self._in_flight)} in progress ({elapsed})" + + def _remove_status_panel(self) -> None: + if self._remove_panel is None: + return + self._remove_panel() + self._remove_panel = None + + +def _resource_addr(data: dict) -> str | None: + hook = data.get("hook") or {} + resource = hook.get("resource") or {} + addr = resource.get("addr") + return addr if isinstance(addr, str) else None + + +def plan_stream_callback(handler: PlanStreamHandler): + def callback(message) -> bool: + match message: + case ShellRunStdOutput(is_stdout=True, content=content): + handler.feed_line(content) + return False + + return callback diff --git a/examples/apply_live/stream_handler_test.py b/examples/apply_live/stream_handler_test.py new file mode 100644 index 0000000..fa9cbc2 --- /dev/null +++ b/examples/apply_live/stream_handler_test.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +from apply_live.stream_handler import PlanStreamHandler + +_MODULE = PlanStreamHandler.__module__ + + +def _line(payload: dict) -> str: + return json.dumps(payload) + "\n" + + +@patch(f"{_MODULE}.ask_console.add_renderable") +def test_panel_removed_on_flush_only(add_mock: MagicMock) -> None: + remove_mock = MagicMock() + add_mock.return_value = remove_mock + handler = PlanStreamHandler() + handler.feed_line(_line({"type": "change_summary", "changes": {"add": 0, "operation": "apply"}})) + remove_mock.assert_not_called() + handler.flush() + remove_mock.assert_called_once() + + +@patch(f"{_MODULE}.ask_console.add_renderable", return_value=MagicMock()) +@patch(f"{_MODULE}.interactive_shell", return_value=False) +def test_ci_heartbeat_during_refresh(_interactive_mock: MagicMock, _add_mock: MagicMock) -> None: + handler = PlanStreamHandler() + handler._last_heartbeat = 0.0 + with patch(f"{_MODULE}.logger") as logger_mock: + handler.feed_line(_line({"type": "refresh_start", "hook": {"resource": {"addr": "a"}}})) + handler.feed_line(_line({"type": "refresh_complete", "hook": {"resource": {"addr": "a"}}})) + heartbeat = logger_mock.info.call_args_list[0][0][0] + assert "refresh:" in heartbeat + assert "complete" in heartbeat + assert "in progress" in heartbeat + + +@patch(f"{_MODULE}.ask_console.add_renderable", return_value=MagicMock()) +def test_diagnostic_immediate(_add_mock: MagicMock) -> None: + handler = PlanStreamHandler() + with patch(f"{_MODULE}.ask_console.print_to_live") as print_mock: + handler.feed_line( + _line( + { + "type": "diagnostic", + "diagnostic": {"severity": "error", "summary": "bad config", "detail": "line 1"}, + } + ) + ) + rendered = [str(c[0][0]) for c in print_mock.call_args_list] + assert rendered[0] == "" + assert "Error: bad config" in rendered[1] + assert "line 1" in rendered[2] + + +@patch(f"{_MODULE}.ask_console.add_renderable", return_value=MagicMock()) +def test_apply_events_and_planning_status(_add_mock: MagicMock) -> None: + handler = PlanStreamHandler() + with patch(f"{_MODULE}.ask_console.print_to_live") as print_mock: + handler.feed_line(_line({"type": "apply_start", "hook": {"resource": {"addr": "a"}}})) + status = handler._status_line() + handler.feed_line(_line({"type": "apply_complete", "hook": {"resource": {"addr": "a"}}})) + after_complete = handler._status_line() + handler.feed_line(_line({"type": "log", "@message": "planning"})) + assert status is not None + plain = status.plain + assert "refresh" in plain + assert "0 done" in plain + assert "1 running" in plain + assert after_complete is not None + assert "1 done" in after_complete.plain + assert "0 running" in after_complete.plain + planning_status = handler._status_line() + assert planning_status is not None + assert planning_status.plain == "planning…" + assert "plan: computing changes…" in [c[0][0] for c in print_mock.call_args_list] diff --git a/examples/apply_live/workspace/.gitignore b/examples/apply_live/workspace/.gitignore new file mode 100644 index 0000000..34939a4 --- /dev/null +++ b/examples/apply_live/workspace/.gitignore @@ -0,0 +1,4 @@ +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* diff --git a/examples/apply_live/workspace/main.tf b/examples/apply_live/workspace/main.tf new file mode 100644 index 0000000..d19c574 --- /dev/null +++ b/examples/apply_live/workspace/main.tf @@ -0,0 +1,21 @@ +resource "time_sleep" "wait_1" { + create_duration = "5s" +} + +resource "time_sleep" "wait_2" { + create_duration = "5s" + + depends_on = [time_sleep.wait_1] +} + +resource "time_sleep" "wait_3" { + create_duration = "5s" + + depends_on = [time_sleep.wait_2] +} + +resource "time_sleep" "wait_4" { + create_duration = "5s" + + depends_on = [time_sleep.wait_3] +} diff --git a/examples/apply_live/workspace/versions.tf b/examples/apply_live/workspace/versions.tf new file mode 100644 index 0000000..450de76 --- /dev/null +++ b/examples/apply_live/workspace/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + time = { + source = "hashicorp/time" + version = "~> 0.12" + } + } +} diff --git a/justfile b/justfile index 18ebcbd..5c06af6 100644 --- a/justfile +++ b/justfile @@ -80,3 +80,6 @@ vulture: # === OK_EDIT: path-sync vulture === # Custom recipes below + +apply-live: + PYTHONPATH=examples uv run python -m apply_live.cli diff --git a/mkdocs.yml b/mkdocs.yml index eda23e5..ef7b874 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,7 +22,10 @@ nav: - SelectOptions: ask/selectoptions.md - question_patcher: ask/question_patcher.md - raise_on_question: ask/raise_on_question.md - - console: console/index.md + - console: + - Overview: console/index.md + - Examples: + - add_renderable: examples/console/add_renderable.md - shell: - Overview: shell/index.md - ShellConfig: shell/shellconfig.md diff --git a/pyproject.toml b/pyproject.toml index 54ad9d0..711bc01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,11 @@ examples_include = [ "AbortRetryError", ] +[tool.pkg-ext.groups.console] +examples_include = [ + "add_renderable", +] + # === DO_NOT_EDIT: path-sync ruff === [tool.ruff] From 89535c84fb1d1dc953bef5fbcbdc287bcecabddf Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Tue, 19 May 2026 09:53:48 +0100 Subject: [PATCH 2/9] adds ci job to test apply-live example --- .github/workflows/ci.yaml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6b791d4..3479b70 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,3 @@ -# path-sync copy -n python-template name: CI on: @@ -87,3 +86,19 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - run: just pkg-pre-commit + + apply-live: + needs: pre_job + if: needs.pre_job.outputs.should_skip != 'true' + runs-on: ubuntu-latest + env: + CI: true + PYTHONUNBUFFERED: 1 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd + with: + terraform_version: 1.12.1 + terraform_wrapper: false + - run: just apply-live From 6b4a2c04376d240bc42662f08289d47c245f9a0d Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Tue, 19 May 2026 10:48:22 +0100 Subject: [PATCH 3/9] self review --- docs/examples/shell/AbortRetryError.md | 1 + examples/apply_live/cli.py | 2 +- examples/apply_live/stream_handler.py | 13 ++++++++----- uv.lock | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/examples/shell/AbortRetryError.md b/docs/examples/shell/AbortRetryError.md index b28ca7b..0b5b70b 100644 --- a/docs/examples/shell/AbortRetryError.md +++ b/docs/examples/shell/AbortRetryError.md @@ -12,6 +12,7 @@ The exception propagates as `ShellError.base_error`. import sys import pytest + from ask_shell.shell import ( AbortRetryError, ShellConfig, diff --git a/examples/apply_live/cli.py b/examples/apply_live/cli.py index 991f97a..1acd712 100644 --- a/examples/apply_live/cli.py +++ b/examples/apply_live/cli.py @@ -5,9 +5,9 @@ import typer +from apply_live.stream_handler import PlanStreamHandler, plan_stream_callback from ask_shell import console from ask_shell.shell import run_and_wait -from apply_live.stream_handler import PlanStreamHandler, plan_stream_callback logger = logging.getLogger(__name__) diff --git a/examples/apply_live/stream_handler.py b/examples/apply_live/stream_handler.py index 8897806..d03bd42 100644 --- a/examples/apply_live/stream_handler.py +++ b/examples/apply_live/stream_handler.py @@ -10,7 +10,7 @@ from ask_shell import console as ask_console from ask_shell.console import RemoveLivePart, interactive_shell -from ask_shell.shell_events import ShellRunStdOutput +from ask_shell.shell_events import ShellRunCallbackT, ShellRunStdOutput logger = logging.getLogger(__name__) @@ -163,12 +163,15 @@ def _remove_status_panel(self) -> None: def _resource_addr(data: dict) -> str | None: hook = data.get("hook") or {} resource = hook.get("resource") or {} - addr = resource.get("addr") - return addr if isinstance(addr, str) else None + match resource.get("addr"): + case str(addr): + return addr + case _: + return None -def plan_stream_callback(handler: PlanStreamHandler): - def callback(message) -> bool: +def plan_stream_callback(handler: PlanStreamHandler) -> ShellRunCallbackT: + def callback(message: object) -> bool: match message: case ShellRunStdOutput(is_stdout=True, content=content): handler.feed_line(content) diff --git a/uv.lock b/uv.lock index 342b785..2a65c48 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "ask-shell" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "model-lib", extra = ["toml"] }, From 8d7538711470a1ee54b5476d11c55cec2a355bcc Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Tue, 19 May 2026 10:55:59 +0100 Subject: [PATCH 4/9] chore: Updates ask_shell/_internal/_run.py so both functions accept list[ShellRunCallbackT] | None, and removed the now-unused ShellRunEventT import. Pyright passes cleanly. --- ask_shell/_internal/_run.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ask_shell/_internal/_run.py b/ask_shell/_internal/_run.py index 1280973..c57dc90 100644 --- a/ask_shell/_internal/_run.py +++ b/ask_shell/_internal/_run.py @@ -34,6 +34,7 @@ from ask_shell._internal.events import ( ShellRunAfter, ShellRunBefore, + ShellRunCallbackT, ShellRunPOpenStarted, ShellRunRetryAttempt, ShellRunStdOutput, @@ -45,7 +46,6 @@ RunIncompleteError, ShellConfig, ShellRun, - ShellRunEventT, ShellRunQueueT, ) from ask_shell.settings import AskShellSettings, _global_settings @@ -467,7 +467,7 @@ def run( env: dict[str, str] | None = None, extra_popen_kwargs: dict | None = None, is_binary_call: bool | None = None, - message_callbacks: list[Callable[[ShellRunEventT], bool]] | None = None, + message_callbacks: list[ShellRunCallbackT] | None = None, print_prefix: str | None = None, retry_initial_wait: float | None = None, retry_jitter: float | None = None, @@ -534,7 +534,7 @@ def run_and_wait( env: dict[str, str] | None = None, extra_popen_kwargs: dict | None = None, is_binary_call: bool | None = None, - message_callbacks: list[Callable[[ShellRunEventT], bool]] | None = None, + message_callbacks: list[ShellRunCallbackT] | None = None, print_prefix: str | None = None, retry_initial_wait: float | None = None, retry_jitter: float | None = None, From 9500a17c17670b55a52c9be21573ee4d495fe7a1 Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Tue, 19 May 2026 10:57:48 +0100 Subject: [PATCH 5/9] chore: regen --- .changelog/015.yaml | 17 +++++++++++++++++ docs/shell/run.md | 3 ++- docs/shell/run_and_wait.md | 3 ++- 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 .changelog/015.yaml diff --git a/.changelog/015.yaml b/.changelog/015.yaml new file mode 100644 index 0000000..e13fe72 --- /dev/null +++ b/.changelog/015.yaml @@ -0,0 +1,17 @@ +name: run +ts: 2026-05-19 09:57:42.377983+00:00 +type: breaking_change +auto_generated: true +change_kind: param_type_changed +details: 'param ''message_callbacks'' type: list[Callable[[ShellRunBefore | ShellRunPOpenStarted | ShellRunStdStarted | ShellRunStdReadError | ShellRunStdOutput | ShellRunRetryAttempt | ShellRunAfter], bool]] | None -> list[Callable[[ShellRunBefore | ShellRunPOpenStarted | ShellRunStdStarted | ShellRunStdReadError | ShellRunStdOutput | ShellRunRetryAttempt | ShellRunAfter], bool | None]] | None' +field_name: message_callbacks +group: shell +--- +name: run_and_wait +ts: 2026-05-19 09:57:42.378001+00:00 +type: breaking_change +auto_generated: true +change_kind: param_type_changed +details: 'param ''message_callbacks'' type: list[Callable[[ShellRunBefore | ShellRunPOpenStarted | ShellRunStdStarted | ShellRunStdReadError | ShellRunStdOutput | ShellRunRetryAttempt | ShellRunAfter], bool]] | None -> list[Callable[[ShellRunBefore | ShellRunPOpenStarted | ShellRunStdStarted | ShellRunStdReadError | ShellRunStdOutput | ShellRunRetryAttempt | ShellRunAfter], bool | None]] | None' +field_name: message_callbacks +group: shell diff --git a/docs/shell/run.md b/docs/shell/run.md index 1eb417b..3a693c6 100644 --- a/docs/shell/run.md +++ b/docs/shell/run.md @@ -6,7 +6,7 @@ > **Since:** 0.3.0 ```python -def run(config: ShellConfig | str, *, allow_non_zero_exit: bool | None = None, ansi_content: bool | None = None, attempts: int | None = None, cwd: str | Path | None = None, env: dict[str, str] | None = None, extra_popen_kwargs: dict | None = None, is_binary_call: bool | None = None, message_callbacks: list[Callable[[ShellRunBefore | ShellRunPOpenStarted | ShellRunStdStarted | ShellRunStdReadError | ShellRunStdOutput | ShellRunRetryAttempt | ShellRunAfter], bool]] | None = None, print_prefix: str | None = None, retry_initial_wait: float | None = None, retry_jitter: float | None = None, retry_max_wait: float | None = None, run_log_stem_prefix: str | None = None, run_output_dir: Path | None = None, settings: AskShellSettings | None = None, should_retry: Callable[[ShellRun], bool] | None = None, skip_binary_check: bool | None = None, skip_html_log_files: bool | None = None, skip_progress_output: bool | None = None, include_log_time: bool | None = None, skip_os_env: bool | None = None, start_timeout: float | None = None, terminal_width: int | None = None, skip_interactive_check: bool | None = None, mute_shell_summary: bool | None = None) -> ShellRun: +def run(config: ShellConfig | str, *, allow_non_zero_exit: bool | None = None, ansi_content: bool | None = None, attempts: int | None = None, cwd: str | Path | None = None, env: dict[str, str] | None = None, extra_popen_kwargs: dict | None = None, is_binary_call: bool | None = None, message_callbacks: list[Callable[[ShellRunBefore | ShellRunPOpenStarted | ShellRunStdStarted | ShellRunStdReadError | ShellRunStdOutput | ShellRunRetryAttempt | ShellRunAfter], bool | None]] | None = None, print_prefix: str | None = None, retry_initial_wait: float | None = None, retry_jitter: float | None = None, retry_max_wait: float | None = None, run_log_stem_prefix: str | None = None, run_output_dir: Path | None = None, settings: AskShellSettings | None = None, should_retry: Callable[[ShellRun], bool] | None = None, skip_binary_check: bool | None = None, skip_html_log_files: bool | None = None, skip_progress_output: bool | None = None, include_log_time: bool | None = None, skip_os_env: bool | None = None, start_timeout: float | None = None, terminal_width: int | None = None, skip_interactive_check: bool | None = None, mute_shell_summary: bool | None = None) -> ShellRun: ... ``` @@ -16,6 +16,7 @@ def run(config: ShellConfig | str, *, allow_non_zero_exit: bool | None = None, a | Version | Change | |---------|--------| +| unreleased | param 'message_callbacks' type: list[Callable[[ShellRunBefore | ShellRunPOpenStarted | ShellRunStdStarted | ShellRunStdReadError | ShellRunStdOutput | ShellRunRetryAttempt | ShellRunAfter], bool]] | None -> list[Callable[[ShellRunBefore | ShellRunPOpenStarted | ShellRunStdStarted | ShellRunStdReadError | ShellRunStdOutput | ShellRunRetryAttempt | ShellRunAfter], bool | None]] | None | | 0.7.0 | added optional param 'mute_shell_summary' (default: None) | | 0.5.1 | added optional param 'retry_max_wait' (default: None) | | 0.5.1 | added optional param 'retry_jitter' (default: None) | diff --git a/docs/shell/run_and_wait.md b/docs/shell/run_and_wait.md index 2a13e10..ae3bacd 100644 --- a/docs/shell/run_and_wait.md +++ b/docs/shell/run_and_wait.md @@ -6,7 +6,7 @@ > **Since:** 0.3.0 ```python -def run_and_wait(script: ShellConfig | str, timeout: float | None = None, *, allow_non_zero_exit: bool | None = None, ansi_content: bool | None = None, attempts: int | None = None, cwd: str | Path | None = None, env: dict[str, str] | None = None, extra_popen_kwargs: dict | None = None, is_binary_call: bool | None = None, message_callbacks: list[Callable[[ShellRunBefore | ShellRunPOpenStarted | ShellRunStdStarted | ShellRunStdReadError | ShellRunStdOutput | ShellRunRetryAttempt | ShellRunAfter], bool]] | None = None, print_prefix: str | None = None, retry_initial_wait: float | None = None, retry_jitter: float | None = None, retry_max_wait: float | None = None, run_log_stem_prefix: str | None = None, run_output_dir: Path | None = None, settings: AskShellSettings | None = None, should_retry: Callable[[ShellRun], bool] | None = None, skip_binary_check: bool | None = None, skip_progress_output: bool | None = None, skip_html_log_files: bool | None = None, include_log_time: bool | None = None, skip_os_env: bool | None = None, user_input: bool | None = None, terminal_width: int | None = None, skip_interactive_check: bool | None = None, mute_shell_summary: bool | None = None) -> ShellRun: +def run_and_wait(script: ShellConfig | str, timeout: float | None = None, *, allow_non_zero_exit: bool | None = None, ansi_content: bool | None = None, attempts: int | None = None, cwd: str | Path | None = None, env: dict[str, str] | None = None, extra_popen_kwargs: dict | None = None, is_binary_call: bool | None = None, message_callbacks: list[Callable[[ShellRunBefore | ShellRunPOpenStarted | ShellRunStdStarted | ShellRunStdReadError | ShellRunStdOutput | ShellRunRetryAttempt | ShellRunAfter], bool | None]] | None = None, print_prefix: str | None = None, retry_initial_wait: float | None = None, retry_jitter: float | None = None, retry_max_wait: float | None = None, run_log_stem_prefix: str | None = None, run_output_dir: Path | None = None, settings: AskShellSettings | None = None, should_retry: Callable[[ShellRun], bool] | None = None, skip_binary_check: bool | None = None, skip_progress_output: bool | None = None, skip_html_log_files: bool | None = None, include_log_time: bool | None = None, skip_os_env: bool | None = None, user_input: bool | None = None, terminal_width: int | None = None, skip_interactive_check: bool | None = None, mute_shell_summary: bool | None = None) -> ShellRun: ... ``` @@ -16,6 +16,7 @@ def run_and_wait(script: ShellConfig | str, timeout: float | None = None, *, all | Version | Change | |---------|--------| +| unreleased | param 'message_callbacks' type: list[Callable[[ShellRunBefore | ShellRunPOpenStarted | ShellRunStdStarted | ShellRunStdReadError | ShellRunStdOutput | ShellRunRetryAttempt | ShellRunAfter], bool]] | None -> list[Callable[[ShellRunBefore | ShellRunPOpenStarted | ShellRunStdStarted | ShellRunStdReadError | ShellRunStdOutput | ShellRunRetryAttempt | ShellRunAfter], bool | None]] | None | | 0.7.0 | added optional param 'mute_shell_summary' (default: None) | | 0.5.1 | added optional param 'retry_max_wait' (default: None) | | 0.5.1 | added optional param 'retry_jitter' (default: None) | From 2ee8fa2f2987771ce71baac8d14140313949450e Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Tue, 19 May 2026 11:32:50 +0100 Subject: [PATCH 6/9] feat(console): Set CI Rich console to 120x40 via settings --- ask_shell/_internal/_run_env.py | 9 +++++++ ask_shell/_internal/_run_env_test.py | 31 ++++++++++++++++++++++--- ask_shell/_internal/rich_live.py | 11 ++++++++- ask_shell/settings.py | 4 ++++ docs/examples/console/add_renderable.md | 1 + 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/ask_shell/_internal/_run_env.py b/ask_shell/_internal/_run_env.py index cf52650..2caafca 100644 --- a/ask_shell/_internal/_run_env.py +++ b/ask_shell/_internal/_run_env.py @@ -35,3 +35,12 @@ def interactive_shell() -> bool: logger.debug(f"Interactive shell not available: {non_interactive_reason}") return False return True + + +def resolve_terminal_dimensions( + settings: AskShellSettings | None = None, +) -> tuple[int | None, int | None]: + if interactive_shell(): + return None, None + settings = settings or AskShellSettings.from_env() + return settings.terminal_width, settings.terminal_height diff --git a/ask_shell/_internal/_run_env_test.py b/ask_shell/_internal/_run_env_test.py index 8a81354..48e6d7d 100644 --- a/ask_shell/_internal/_run_env_test.py +++ b/ask_shell/_internal/_run_env_test.py @@ -1,5 +1,30 @@ -from ask_shell._internal._run_env import interactive_shell +from unittest.mock import patch +import pytest -def test_interactive_shell(): - assert not interactive_shell() +from ask_shell._internal import _run_env +from ask_shell._internal._run_env import interactive_shell, resolve_terminal_dimensions +from ask_shell.settings import AskShellSettings + + +@pytest.fixture(autouse=True) +def _clear_interactive_cache(): + interactive_shell.cache_clear() + yield + interactive_shell.cache_clear() + + +def test_resolve_terminal_dimensions_interactive(): + with patch.object(_run_env, "interactive_shell", return_value=True): + assert resolve_terminal_dimensions() == (None, None) + + +def test_resolve_terminal_dimensions_non_interactive(): + with patch.object(_run_env, "interactive_shell", return_value=False): + assert resolve_terminal_dimensions(AskShellSettings()) == (120, 40) + + +def test_resolve_terminal_dimensions_env_override(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(AskShellSettings.ENV_NAME_TERMINAL_WIDTH, "100") + with patch.object(_run_env, "interactive_shell", return_value=False): + assert resolve_terminal_dimensions() == (100, 40) diff --git a/ask_shell/_internal/rich_live.py b/ask_shell/_internal/rich_live.py index dfac866..fc571e0 100644 --- a/ask_shell/_internal/rich_live.py +++ b/ask_shell/_internal/rich_live.py @@ -13,6 +13,8 @@ from zero_3rdparty.error import error_and_traceback from zero_3rdparty.id_creator import simple_id +from ask_shell._internal._run_env import resolve_terminal_dimensions + _live: Live | None = None _lock = RLock() @@ -61,13 +63,20 @@ def wrapper(*args, **kwargs): return wrapper +def _live_console() -> Console: + width, height = resolve_terminal_dimensions() + if width is not None and height is not None: + return Console(width=width, height=height) + return Console() + + def get_live() -> Live: global _live if _live is not None: return _live with _lock: if _live is None: - _live = Live(transient=True) + _live = Live(transient=True, console=_live_console()) live_render = _live._live_render old_console = live_render.__rich_console__ live_render.__rich_console__ = _console_hook(old_console) diff --git a/ask_shell/settings.py b/ask_shell/settings.py index 935c172..effab31 100644 --- a/ask_shell/settings.py +++ b/ask_shell/settings.py @@ -147,6 +147,10 @@ class AskShellSettings(StaticSettings): default=ShellRunSummary.ALL, alias=ENV_NAME_SHELL_RUN_SUMMARY, ) + ENV_NAME_TERMINAL_WIDTH: ClassVar[str] = f"{ENV_PREFIX}TERMINAL_WIDTH" + terminal_width: int = Field(default=120, alias=ENV_NAME_TERMINAL_WIDTH) + ENV_NAME_TERMINAL_HEIGHT: ClassVar[str] = f"{ENV_PREFIX}TERMINAL_HEIGHT" + terminal_height: int = Field(default=40, alias=ENV_NAME_TERMINAL_HEIGHT) @model_validator(mode="after") def ensure_vars_set(self) -> Self: diff --git a/docs/examples/console/add_renderable.md b/docs/examples/console/add_renderable.md index 86676a5..099e979 100644 --- a/docs/examples/console/add_renderable.md +++ b/docs/examples/console/add_renderable.md @@ -138,6 +138,7 @@ When the console is not interactive, Rich's `Live.process_renderables` does not GitHub Actions streams a step's log by line. A long shell command whose only signal is an `add_renderable` panel produces no stdout, so the step appears stalled until it exits. Mitigations, in order of impact: - **Set `PYTHONUNBUFFERED=1`** in the workflow `env`. Without it, Python falls back to block buffering when stdout is not a TTY and short bursts of output may sit in the buffer for minutes. +- **Terminal width in CI**: when `interactive_shell()` is false, ask-shell uses `ASK_SHELL_TERMINAL_WIDTH` and `ASK_SHELL_TERMINAL_HEIGHT` (defaults `120` and `40`) for the shared Rich console. Rich ignores a lone `width`; both must be set. - **Mirror the dynamic state into `print_to_live` or `logger.info`** on a heartbeat (every few seconds). The renderable still drives the local UX; the heartbeat keeps the CI stream alive. The apply-live demo uses `logger.info("refresh: N complete, M in progress (…)")` when `interactive_shell()` is false. - **Set `log_updates=True` on `new_task`** when you want every `task.update(...)` call to emit a log line. - **Set `include_log_time=True` on `ShellConfig`** so each captured shell line carries a `[hh:mm:ss]` prefix. Stalls become easier to spot in CI output. From a30e036be22bf7f8b338947b570ffc3cc085c86d Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Tue, 19 May 2026 11:34:03 +0100 Subject: [PATCH 7/9] regen --- .changelog/015.yaml | 23 +++++++++++++++++++++++ docs/_root/askshellsettings.md | 4 ++++ docs/console/index.md | 10 +++++----- docs/examples/shell/AbortRetryError.md | 1 - 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/.changelog/015.yaml b/.changelog/015.yaml index e13fe72..4f4a904 100644 --- a/.changelog/015.yaml +++ b/.changelog/015.yaml @@ -15,3 +15,26 @@ change_kind: param_type_changed details: 'param ''message_callbacks'' type: list[Callable[[ShellRunBefore | ShellRunPOpenStarted | ShellRunStdStarted | ShellRunStdReadError | ShellRunStdOutput | ShellRunRetryAttempt | ShellRunAfter], bool]] | None -> list[Callable[[ShellRunBefore | ShellRunPOpenStarted | ShellRunStdStarted | ShellRunStdReadError | ShellRunStdOutput | ShellRunRetryAttempt | ShellRunAfter], bool | None]] | None' field_name: message_callbacks group: shell +--- +name: resolve_terminal_dimensions +ts: 2026-05-19 10:33:13.554374+00:00 +type: keep_private +full_path: _internal._run_env.resolve_terminal_dimensions +--- +name: AskShellSettings +ts: 2026-05-19 10:33:13.999068+00:00 +type: additional_change +auto_generated: true +change_kind: optional_field_added +details: 'added optional field ''terminal_height'' (default: 40)' +field_name: terminal_height +group: __ROOT__ +--- +name: AskShellSettings +ts: 2026-05-19 10:33:13.999077+00:00 +type: additional_change +auto_generated: true +change_kind: optional_field_added +details: 'added optional field ''terminal_width'' (default: 120)' +field_name: terminal_width +group: __ROOT__ diff --git a/docs/_root/askshellsettings.md b/docs/_root/askshellsettings.md index 19edcb6..0016da0 100644 --- a/docs/_root/askshellsettings.md +++ b/docs/_root/askshellsettings.md @@ -20,6 +20,8 @@ class AskShellSettings(StaticSettings): run_logs_dir: Path | None = None run_logs_clean: str = 'yesterday' shell_run_summary: ShellRunSummary = + terminal_width: int = 120 + terminal_height: int = 40 ``` @@ -70,6 +72,8 @@ class AskShellSettings(StaticSettings): | Version | Change | |---------|--------| +| unreleased | added optional field 'terminal_width' (default: 120) | +| unreleased | added optional field 'terminal_height' (default: 40) | | 0.7.0 | added optional field 'shell_run_summary' (default: ) | | 0.6.0 | field 'thread_count' default: 50 -> 100 | | 0.3.2 | field 'run_logs_dir' default added: None | diff --git a/docs/console/index.md b/docs/console/index.md index 9361108..d76d197 100644 --- a/docs/console/index.md +++ b/docs/console/index.md @@ -22,7 +22,7 @@ ### class: `RemoveLivePart` -- [source](../../ask_shell/_internal/rich_live.py#L148) +- [source](../../ask_shell/_internal/rich_live.py#L157) > **Since:** 0.3.0 ```python @@ -40,7 +40,7 @@ class RemoveLivePart: ### function: `add_renderable` -- [source](../../ask_shell/_internal/rich_live.py#L152) +- [source](../../ask_shell/_internal/rich_live.py#L161) - [Example: Mount a dynamic Rich renderable in ask-shell's live region with add_renderable, plus an apply-live demo with CI heartbeats](../examples/console/add_renderable.md) > **Since:** 0.3.0 @@ -77,7 +77,7 @@ def configure_logging(app: Typer, *, settings: AskShellSettings | None = None, a ### function: `get_live_console` -- [source](../../ask_shell/_internal/rich_live.py#L170) +- [source](../../ask_shell/_internal/rich_live.py#L179) > **Since:** 0.3.0 ```python @@ -113,7 +113,7 @@ def interactive_shell() -> bool: ### function: `log_to_live` -- [source](../../ask_shell/_internal/rich_live.py#L210) +- [source](../../ask_shell/_internal/rich_live.py#L219) > **Since:** 0.3.0 ```python @@ -167,7 +167,7 @@ class new_task: ### function: `print_to_live` -- [source](../../ask_shell/_internal/rich_live.py#L174) +- [source](../../ask_shell/_internal/rich_live.py#L183) > **Since:** 0.3.0 ```python diff --git a/docs/examples/shell/AbortRetryError.md b/docs/examples/shell/AbortRetryError.md index 0b5b70b..b28ca7b 100644 --- a/docs/examples/shell/AbortRetryError.md +++ b/docs/examples/shell/AbortRetryError.md @@ -12,7 +12,6 @@ The exception propagates as `ShellError.base_error`. import sys import pytest - from ask_shell.shell import ( AbortRetryError, ShellConfig, From a849aff24837cff61d6c656b8911b2cea328dbc4 Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Tue, 19 May 2026 11:39:11 +0100 Subject: [PATCH 8/9] test(ask-shell): Align doc example import lint with CI cwd --- ask_shell/test_docs.py | 8 +++++++- docs/examples/shell/AbortRetryError.md | 1 + pyproject.toml | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/ask_shell/test_docs.py b/ask_shell/test_docs.py index c2c3bb6..254598e 100644 --- a/ask_shell/test_docs.py +++ b/ask_shell/test_docs.py @@ -3,7 +3,13 @@ import pytest from pytest_examples import CodeExample, EvalExample, find_examples -EXAMPLES_DIR = Path(__file__).parent.parent / "docs" / "examples" +PACKAGE_ROOT = Path(__file__).parent.parent +EXAMPLES_DIR = PACKAGE_ROOT / "docs" / "examples" + + +@pytest.fixture(autouse=True) +def _examples_package_cwd(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(PACKAGE_ROOT) @pytest.mark.parametrize("example", find_examples(EXAMPLES_DIR), ids=str) diff --git a/docs/examples/shell/AbortRetryError.md b/docs/examples/shell/AbortRetryError.md index b28ca7b..0b5b70b 100644 --- a/docs/examples/shell/AbortRetryError.md +++ b/docs/examples/shell/AbortRetryError.md @@ -12,6 +12,7 @@ The exception propagates as `ShellError.base_error`. import sys import pytest + from ask_shell.shell import ( AbortRetryError, ShellConfig, diff --git a/pyproject.toml b/pyproject.toml index 711bc01..66341ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,9 @@ target-version = "py313" # UP047: use type parameters instead of `Generic[T]` subclass extend-ignore = ["E501", "UP006", "UP007", "UP035", "UP040", "UP046", "UP047"] extend-select = ["Q", "RUF100", "C90", "UP", "I", "T", "FURB"] + +[tool.ruff.lint.isort] +known-first-party = ["ask_shell"] # === OK_EDIT: path-sync ruff === # === DO_NOT_EDIT: path-sync pytest === From e1e0ab30071f63d3a4e2670e5caf909f28f6d543 Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Tue, 19 May 2026 11:41:46 +0100 Subject: [PATCH 9/9] chore: moves outside the sync --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 66341ff..c961998 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,10 +62,10 @@ target-version = "py313" # UP047: use type parameters instead of `Generic[T]` subclass extend-ignore = ["E501", "UP006", "UP007", "UP035", "UP040", "UP046", "UP047"] extend-select = ["Q", "RUF100", "C90", "UP", "I", "T", "FURB"] +# === OK_EDIT: path-sync ruff === [tool.ruff.lint.isort] known-first-party = ["ask_shell"] -# === OK_EDIT: path-sync ruff === # === DO_NOT_EDIT: path-sync pytest === [tool.pytest.ini_options]