ask-shell 0.6.0__tar.gz → 0.8.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 (31) hide show
  1. {ask_shell-0.6.0 → ask_shell-0.8.0}/PKG-INFO +1 -1
  2. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/__init__.py +3 -1
  3. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/_internal/_run.py +7 -3
  4. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/_internal/_run_env.py +9 -0
  5. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/_internal/models.py +3 -0
  6. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/_internal/rich_live.py +10 -1
  7. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/_internal/rich_progress.py +7 -1
  8. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/_internal/rich_run_state.py +6 -0
  9. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/settings.py +15 -0
  10. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/test_docs.py +8 -2
  11. ask_shell-0.8.0/examples/apply_live/__init__.py +0 -0
  12. ask_shell-0.8.0/examples/apply_live/cli.py +73 -0
  13. ask_shell-0.8.0/examples/apply_live/stream_handler.py +180 -0
  14. {ask_shell-0.6.0 → ask_shell-0.8.0}/pyproject.toml +18 -1
  15. {ask_shell-0.6.0 → ask_shell-0.8.0}/.gitignore +0 -0
  16. {ask_shell-0.6.0 → ask_shell-0.8.0}/LICENSE +0 -0
  17. {ask_shell-0.6.0 → ask_shell-0.8.0}/README.md +0 -0
  18. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/__main__.py +0 -0
  19. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/_internal/__init__.py +0 -0
  20. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/_internal/events.py +0 -0
  21. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/_internal/global_callbacks.py +0 -0
  22. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/_internal/interactive.py +0 -0
  23. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/_internal/rich_live_callback.py +0 -0
  24. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/_internal/run_pool.py +0 -0
  25. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/_internal/typer_command.py +0 -0
  26. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/ask.py +0 -0
  27. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/console.py +0 -0
  28. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/py.typed +0 -0
  29. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/shell.py +0 -0
  30. {ask_shell-0.6.0 → ask_shell-0.8.0}/ask_shell/shell_events.py +0 -0
  31. {ask_shell-0.6.0 → ask_shell-0.8.0}/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.6.0
3
+ Version: 0.8.0
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
@@ -5,12 +5,14 @@ from ask_shell import console
5
5
  from ask_shell import shell_events
6
6
  from ask_shell import ask
7
7
  from ask_shell.settings import AskShellSettings
8
+ from ask_shell.settings import ShellRunSummary
8
9
 
9
- VERSION = "0.6.0"
10
+ VERSION = "0.8.0"
10
11
  __all__ = [
11
12
  "shell",
12
13
  "console",
13
14
  "shell_events",
14
15
  "ask",
15
16
  "AskShellSettings",
17
+ "ShellRunSummary",
16
18
  ]
@@ -34,6 +34,7 @@ from zero_3rdparty.future import add_done_callback, add_error_logging, chain_fut
34
34
  from ask_shell._internal.events import (
35
35
  ShellRunAfter,
36
36
  ShellRunBefore,
37
+ ShellRunCallbackT,
37
38
  ShellRunPOpenStarted,
38
39
  ShellRunRetryAttempt,
39
40
  ShellRunStdOutput,
@@ -45,7 +46,6 @@ from ask_shell._internal.models import (
45
46
  RunIncompleteError,
46
47
  ShellConfig,
47
48
  ShellRun,
48
- ShellRunEventT,
49
49
  ShellRunQueueT,
50
50
  )
51
51
  from ask_shell.settings import AskShellSettings, _global_settings
@@ -467,7 +467,7 @@ def run(
467
467
  env: dict[str, str] | None = None,
468
468
  extra_popen_kwargs: dict | None = None,
469
469
  is_binary_call: bool | None = None,
470
- message_callbacks: list[Callable[[ShellRunEventT], bool]] | None = None,
470
+ message_callbacks: list[ShellRunCallbackT] | None = None,
471
471
  print_prefix: str | None = None,
472
472
  retry_initial_wait: float | None = None,
473
473
  retry_jitter: float | None = None,
@@ -484,6 +484,7 @@ def run(
484
484
  start_timeout: float | None = None,
485
485
  terminal_width: int | None = None,
486
486
  skip_interactive_check: bool | None = None,
487
+ mute_shell_summary: bool | None = None,
487
488
  ) -> ShellRun:
488
489
  config = _as_config(
489
490
  config,
@@ -510,6 +511,7 @@ def run(
510
511
  skip_os_env=skip_os_env,
511
512
  terminal_width=terminal_width,
512
513
  skip_interactive_check=skip_interactive_check,
514
+ mute_shell_summary=mute_shell_summary,
513
515
  )
514
516
  assert not config.user_input, (
515
517
  "run() does not support user_input (only 1 should be active at a time), use run_and_wait() instead"
@@ -532,7 +534,7 @@ def run_and_wait(
532
534
  env: dict[str, str] | None = None,
533
535
  extra_popen_kwargs: dict | None = None,
534
536
  is_binary_call: bool | None = None,
535
- message_callbacks: list[Callable[[ShellRunEventT], bool]] | None = None,
537
+ message_callbacks: list[ShellRunCallbackT] | None = None,
536
538
  print_prefix: str | None = None,
537
539
  retry_initial_wait: float | None = None,
538
540
  retry_jitter: float | None = None,
@@ -549,6 +551,7 @@ def run_and_wait(
549
551
  user_input: bool | None = None,
550
552
  terminal_width: int | None = None,
551
553
  skip_interactive_check: bool | None = None,
554
+ mute_shell_summary: bool | None = None,
552
555
  ) -> ShellRun:
553
556
  config = _as_config(
554
557
  script,
@@ -576,6 +579,7 @@ def run_and_wait(
576
579
  terminal_width=terminal_width,
577
580
  skip_interactive_check=skip_interactive_check,
578
581
  skip_progress_output=skip_progress_output,
582
+ mute_shell_summary=mute_shell_summary,
579
583
  )
580
584
  run = ShellRun(config)
581
585
  future = _pool.submit(_execute_run, run)
@@ -35,3 +35,12 @@ def interactive_shell() -> bool:
35
35
  logger.debug(f"Interactive shell not available: {non_interactive_reason}")
36
36
  return False
37
37
  return True
38
+
39
+
40
+ def resolve_terminal_dimensions(
41
+ settings: AskShellSettings | None = None,
42
+ ) -> tuple[int | None, int | None]:
43
+ if interactive_shell():
44
+ return None, None
45
+ settings = settings or AskShellSettings.from_env()
46
+ return settings.terminal_width, settings.terminal_height
@@ -172,6 +172,9 @@ class ShellConfig(Entity):
172
172
  default=False,
173
173
  description="Skip transitive std out/err output, useful for large outputs that are not needed in the logs when running parallel scripts",
174
174
  )
175
+ mute_shell_summary: bool = Field(
176
+ default=False, description="Skip shell summary logging useful when you want to ✅ or ❌ in the logs"
177
+ )
175
178
  terminal_width: int | None = 999
176
179
 
177
180
  # advanced settings
@@ -13,6 +13,8 @@ from rich.style import Style
13
13
  from zero_3rdparty.error import error_and_traceback
14
14
  from zero_3rdparty.id_creator import simple_id
15
15
 
16
+ from ask_shell._internal._run_env import resolve_terminal_dimensions
17
+
16
18
  _live: Live | None = None
17
19
  _lock = RLock()
18
20
 
@@ -61,13 +63,20 @@ def _console_hook(func: Callable) -> Callable:
61
63
  return wrapper
62
64
 
63
65
 
66
+ def _live_console() -> Console:
67
+ width, height = resolve_terminal_dimensions()
68
+ if width is not None and height is not None:
69
+ return Console(width=width, height=height)
70
+ return Console()
71
+
72
+
64
73
  def get_live() -> Live:
65
74
  global _live
66
75
  if _live is not None:
67
76
  return _live
68
77
  with _lock:
69
78
  if _live is None:
70
- _live = Live(transient=True)
79
+ _live = Live(transient=True, console=_live_console())
71
80
  live_render = _live._live_render
72
81
  old_console = live_render.__rich_console__
73
82
  live_render.__rich_console__ = _console_hook(old_console)
@@ -47,10 +47,16 @@ def log_task_done(
47
47
  error: BaseException | None = None,
48
48
  description_override: str | None = None,
49
49
  extra_parts: list[str] | None = None,
50
+ soft_failure: bool = False,
50
51
  ):
51
52
  exit_comji = "❌" if force_error or error is not None else "✅"
52
53
  description = description_override or task.description
53
- log_call = logger.info if exit_comji == "✅" else logger.error
54
+ if exit_comji == "✅":
55
+ log_call = logger.info
56
+ elif soft_failure:
57
+ log_call = logger.warning
58
+ else:
59
+ log_call = logger.error
54
60
  message_parts = [f"{exit_comji} {description}"]
55
61
  if rich_task := task._rich_task:
56
62
  if finish_time := rich_task.finished_time:
@@ -21,6 +21,7 @@ from ask_shell._internal.models import (
21
21
  ShellRunEventT,
22
22
  )
23
23
  from ask_shell._internal.rich_progress import ProgressManager, log_task_done, new_task
24
+ from ask_shell.settings import ShellRunSummary
24
25
 
25
26
 
26
27
  def _deque_default() -> deque[str]:
@@ -144,6 +145,10 @@ class _RunState:
144
145
  task.update(stderr=run_info.stderr_str)
145
146
  task.__exit__(None, None, None)
146
147
  run = run_info.run
148
+ if run.config.mute_shell_summary:
149
+ return
150
+ if run.config.settings.shell_run_summary == ShellRunSummary.ERRORS_ONLY and run.clean_complete:
151
+ return
147
152
  log_task_done(
148
153
  task,
149
154
  force_error=not run.clean_complete,
@@ -151,4 +156,5 @@ class _RunState:
151
156
  extra_parts=[
152
157
  "" if run.current_attempt == 1 else f"attempt {run.current_attempt}",
153
158
  ],
159
+ soft_failure=run.config.allow_non_zero_exit and not run.clean_complete,
154
160
  )
@@ -1,6 +1,7 @@
1
1
  import logging
2
2
  import os
3
3
  from datetime import datetime, timedelta
4
+ from enum import StrEnum
4
5
  from functools import cached_property, lru_cache
5
6
  from pathlib import Path
6
7
  from pydoc import locate
@@ -90,6 +91,11 @@ LogLevelIgnoredCase: TypeAlias = Annotated[
90
91
  ]
91
92
 
92
93
 
94
+ class ShellRunSummary(StrEnum):
95
+ ALL = "all"
96
+ ERRORS_ONLY = "errors_only"
97
+
98
+
93
99
  class AskShellSettings(StaticSettings):
94
100
  model_config = ConfigDict(populate_by_name=True) # type: ignore
95
101
  log_level: LogLevelIgnoredCase = "UNSET"
@@ -136,6 +142,15 @@ class AskShellSettings(StaticSettings):
136
142
  description="Runs once If `run_logs_dir` is not set. Can be 'yesterday' or a date string like '2023-01-01'. Will clean all logs up until the specified date but not that date itself.",
137
143
  alias=f"{ENV_PREFIX}RUN_LOGS_CLEAN",
138
144
  )
145
+ ENV_NAME_SHELL_RUN_SUMMARY: ClassVar[str] = f"{ENV_PREFIX}SHELL_RUN_SUMMARY"
146
+ shell_run_summary: ShellRunSummary = Field(
147
+ default=ShellRunSummary.ALL,
148
+ alias=ENV_NAME_SHELL_RUN_SUMMARY,
149
+ )
150
+ ENV_NAME_TERMINAL_WIDTH: ClassVar[str] = f"{ENV_PREFIX}TERMINAL_WIDTH"
151
+ terminal_width: int = Field(default=120, alias=ENV_NAME_TERMINAL_WIDTH)
152
+ ENV_NAME_TERMINAL_HEIGHT: ClassVar[str] = f"{ENV_PREFIX}TERMINAL_HEIGHT"
153
+ terminal_height: int = Field(default=40, alias=ENV_NAME_TERMINAL_HEIGHT)
139
154
 
140
155
  @model_validator(mode="after")
141
156
  def ensure_vars_set(self) -> Self:
@@ -3,12 +3,18 @@ from pathlib import Path
3
3
  import pytest
4
4
  from pytest_examples import CodeExample, EvalExample, find_examples
5
5
 
6
- EXAMPLES_DIR = Path(__file__).parent.parent / "docs" / "examples"
6
+ PACKAGE_ROOT = Path(__file__).parent.parent
7
+ EXAMPLES_DIR = PACKAGE_ROOT / "docs" / "examples"
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def _examples_package_cwd(monkeypatch: pytest.MonkeyPatch) -> None:
12
+ monkeypatch.chdir(PACKAGE_ROOT)
7
13
 
8
14
 
9
15
  @pytest.mark.parametrize("example", find_examples(EXAMPLES_DIR), ids=str)
10
16
  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]
17
+ eval_example.set_config(line_length=120, target_version="py313", ruff_ignore=["T"]) # pyright: ignore[reportArgumentType]
12
18
  prefix = example.prefix_settings()
13
19
  if prefix.get("test", "").startswith("skip"):
14
20
  pytest.skip(prefix["test"])
File without changes
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from apply_live.stream_handler import PlanStreamHandler, plan_stream_callback
9
+ from ask_shell import console
10
+ from ask_shell.shell import run_and_wait
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ app = typer.Typer(
15
+ name="apply-live",
16
+ help="Demo terraform apply -json streaming with ask-shell live rendering.",
17
+ )
18
+
19
+ _DEFAULT_WORKSPACE = Path(__file__).resolve().parent / "workspace"
20
+
21
+
22
+ def _run_terraform(command: str, *, workspace: Path) -> int:
23
+ run = run_and_wait(
24
+ command,
25
+ cwd=workspace,
26
+ allow_non_zero_exit=True,
27
+ skip_progress_output=True,
28
+ )
29
+ return run.exit_code or 0
30
+
31
+
32
+ def _terraform_init(*, workspace: Path) -> None:
33
+ logger.info("running terraform init")
34
+ if (exit_code := _run_terraform("terraform init -input=false", workspace=workspace)) != 0:
35
+ raise typer.Exit(exit_code)
36
+ logger.info("terraform init complete")
37
+
38
+
39
+ @app.command()
40
+ def apply(
41
+ workspace: Path = typer.Option(
42
+ _DEFAULT_WORKSPACE,
43
+ "--workspace",
44
+ "-w",
45
+ help="Terraform workspace with chained time_sleep resources.",
46
+ exists=True,
47
+ file_okay=False,
48
+ ),
49
+ ) -> None:
50
+ _terraform_init(workspace=workspace)
51
+
52
+ handler = PlanStreamHandler()
53
+ callback = plan_stream_callback(handler)
54
+ run = run_and_wait(
55
+ "terraform apply -json -auto-approve",
56
+ cwd=workspace,
57
+ allow_non_zero_exit=True,
58
+ skip_progress_output=True,
59
+ message_callbacks=[callback],
60
+ )
61
+ handler.flush()
62
+ if (exit_code := run.exit_code or 0) != 0:
63
+ raise typer.Exit(exit_code)
64
+ logger.info("apply complete")
65
+
66
+
67
+ def main() -> None:
68
+ console.configure_logging(app)
69
+ app()
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import time
6
+ from enum import StrEnum
7
+
8
+ from rich.console import Console, ConsoleOptions, RenderResult
9
+ from rich.text import Text
10
+
11
+ from ask_shell import console as ask_console
12
+ from ask_shell.console import RemoveLivePart, interactive_shell
13
+ from ask_shell.shell_events import ShellRunCallbackT, ShellRunStdOutput
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ REFRESH_HEARTBEAT_INTERVAL_S = 5.0
18
+
19
+
20
+ class _Phase(StrEnum):
21
+ REFRESH = "refresh"
22
+ PLANNING = "planning"
23
+ DONE = "done"
24
+
25
+
26
+ class _PlanStatusRenderable:
27
+ def __init__(self, handler: PlanStreamHandler) -> None:
28
+ self._handler = handler
29
+
30
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
31
+ status = self._handler._status_line()
32
+ if status:
33
+ yield status
34
+
35
+
36
+ class PlanStreamHandler:
37
+ def __init__(self) -> None:
38
+ self._started = time.monotonic()
39
+ self._phase = _Phase.REFRESH
40
+ self._in_flight: set[str] = set()
41
+ self._done = 0
42
+ self._planning_emitted = False
43
+ self._diagnostic_emitted = False
44
+ self._last_heartbeat = self._started
45
+ self._carry = ""
46
+ self._status = _PlanStatusRenderable(self)
47
+ self._remove_panel: RemoveLivePart | None = ask_console.add_renderable(
48
+ self._status, order=10, name="plan-status"
49
+ )
50
+
51
+ def feed_line(self, chunk: str) -> None:
52
+ self._carry += chunk
53
+ while "\n" in self._carry:
54
+ line, self._carry = self._carry.split("\n", 1)
55
+ stripped = line.strip()
56
+ if stripped:
57
+ self._handle_line(stripped)
58
+
59
+ def flush(self) -> None:
60
+ if self._carry.strip():
61
+ self._handle_line(self._carry.strip())
62
+ self._carry = ""
63
+ self._remove_status_panel()
64
+
65
+ def _handle_line(self, line: str) -> None:
66
+ try:
67
+ data = json.loads(line)
68
+ except json.JSONDecodeError:
69
+ logger.debug(f"skipping non-json plan stream line: {line[:120]!r}")
70
+ return
71
+ msg_type = data.get("type")
72
+ match msg_type:
73
+ case "diagnostic":
74
+ self._emit_diagnostic(data)
75
+ case "refresh_start" | "apply_start":
76
+ self._on_refresh_start(data)
77
+ case "refresh_complete" | "apply_complete":
78
+ self._on_refresh_complete(data)
79
+ case "change_summary":
80
+ if (data.get("changes") or {}).get("operation") == "apply":
81
+ self._phase = _Phase.DONE
82
+ case "resource_drift" | "outputs" | "log":
83
+ self._leave_refresh_phase()
84
+ case _:
85
+ pass
86
+ self._maybe_heartbeat()
87
+
88
+ def _emit_diagnostic(self, data: dict) -> None:
89
+ diagnostic = data.get("diagnostic") or {}
90
+ summary = diagnostic.get("summary")
91
+ if not summary:
92
+ return
93
+ if not self._diagnostic_emitted:
94
+ self._diagnostic_emitted = True
95
+ ask_console.print_to_live("")
96
+ severity = (diagnostic.get("severity") or "error").capitalize()
97
+ ask_console.print_to_live(f"{severity}: {summary}")
98
+ if detail := diagnostic.get("detail"):
99
+ ask_console.print_to_live(detail)
100
+
101
+ def _on_refresh_start(self, data: dict) -> None:
102
+ if addr := _resource_addr(data):
103
+ self._in_flight.add(addr)
104
+
105
+ def _on_refresh_complete(self, data: dict) -> None:
106
+ if addr := _resource_addr(data):
107
+ self._in_flight.discard(addr)
108
+ self._done += 1
109
+
110
+ def _leave_refresh_phase(self) -> None:
111
+ if self._phase != _Phase.REFRESH:
112
+ return
113
+ self._phase = _Phase.PLANNING
114
+ self._emit_planning_once()
115
+
116
+ def _emit_planning_once(self) -> None:
117
+ if self._planning_emitted:
118
+ return
119
+ self._planning_emitted = True
120
+ ask_console.print_to_live("plan: computing changes…")
121
+
122
+ def _status_line(self) -> Text | None:
123
+ if self._phase == _Phase.DONE:
124
+ return None
125
+ if self._phase == _Phase.PLANNING:
126
+ return Text("planning…", style="cyan")
127
+ return self._refresh_status(time.monotonic() - self._started)
128
+
129
+ def _refresh_status(self, elapsed_s: float) -> Text:
130
+ mins, secs = divmod(int(elapsed_s), 60)
131
+ elapsed = f"{mins}m {secs:02d}s" if mins else f"{secs}s"
132
+ return Text.assemble(
133
+ ("refresh", "cyan"),
134
+ " · ",
135
+ (str(self._done), "bold"),
136
+ " done · ",
137
+ (str(len(self._in_flight)), "bold"),
138
+ " running · ",
139
+ (elapsed, "dim"),
140
+ )
141
+
142
+ def _maybe_heartbeat(self) -> None:
143
+ if interactive_shell() or self._phase != _Phase.REFRESH:
144
+ return
145
+ now = time.monotonic()
146
+ if now - self._last_heartbeat < REFRESH_HEARTBEAT_INTERVAL_S:
147
+ return
148
+ self._last_heartbeat = now
149
+ logger.info(self._heartbeat_message(now - self._started))
150
+
151
+ def _heartbeat_message(self, elapsed_s: float) -> str:
152
+ mins, secs = divmod(int(elapsed_s), 60)
153
+ elapsed = f"{mins}m {secs:02d}s" if mins else f"{secs}s"
154
+ return f"refresh: {self._done} complete, {len(self._in_flight)} in progress ({elapsed})"
155
+
156
+ def _remove_status_panel(self) -> None:
157
+ if self._remove_panel is None:
158
+ return
159
+ self._remove_panel()
160
+ self._remove_panel = None
161
+
162
+
163
+ def _resource_addr(data: dict) -> str | None:
164
+ hook = data.get("hook") or {}
165
+ resource = hook.get("resource") or {}
166
+ match resource.get("addr"):
167
+ case str(addr):
168
+ return addr
169
+ case _:
170
+ return None
171
+
172
+
173
+ def plan_stream_callback(handler: PlanStreamHandler) -> ShellRunCallbackT:
174
+ def callback(message: object) -> bool:
175
+ match message:
176
+ case ShellRunStdOutput(is_stdout=True, content=content):
177
+ handler.feed_line(content)
178
+ return False
179
+
180
+ return callback
@@ -2,7 +2,7 @@
2
2
 
3
3
  [project]
4
4
  name = "ask-shell"
5
- version = "0.6.0"
5
+ version = "0.8.0"
6
6
  description = "CLIs with prompts, shell runs, and testable flows"
7
7
  requires-python = ">=3.13"
8
8
  license = "MIT"
@@ -41,6 +41,11 @@ examples_include = [
41
41
  "AbortRetryError",
42
42
  ]
43
43
 
44
+ [tool.pkg-ext.groups.console]
45
+ examples_include = [
46
+ "add_renderable",
47
+ ]
48
+
44
49
 
45
50
  # === DO_NOT_EDIT: path-sync ruff ===
46
51
  [tool.ruff]
@@ -59,6 +64,9 @@ extend-ignore = ["E501", "UP006", "UP007", "UP035", "UP040", "UP046", "UP047"]
59
64
  extend-select = ["Q", "RUF100", "C90", "UP", "I", "T", "FURB"]
60
65
  # === OK_EDIT: path-sync ruff ===
61
66
 
67
+ [tool.ruff.lint.isort]
68
+ known-first-party = ["ask_shell"]
69
+
62
70
  # === DO_NOT_EDIT: path-sync pytest ===
63
71
  [tool.pytest.ini_options]
64
72
  addopts = "-s -vv --log-cli-level=INFO -m 'not manual'"
@@ -107,3 +115,12 @@ docs = [
107
115
  "mkdocs-material>=9.5",
108
116
  ]
109
117
  # === OK_EDIT: path-sync docs ===
118
+ # === DO_NOT_EDIT: path-sync wheel ===
119
+ [tool.hatch.build.targets.wheel]
120
+ exclude = [
121
+ "*_examples.py",
122
+ "*_test.py",
123
+ "conftest.py",
124
+ "*_test/",
125
+ ]
126
+ # === OK_EDIT: path-sync wheel ===
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes