ask-shell 0.5.3__tar.gz → 0.7.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 (29) hide show
  1. {ask_shell-0.5.3 → ask_shell-0.7.0}/PKG-INFO +1 -1
  2. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/__init__.py +3 -1
  3. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/_internal/_run.py +4 -0
  4. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/_internal/models.py +3 -0
  5. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/_internal/rich_progress.py +7 -1
  6. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/_internal/rich_run_state.py +6 -0
  7. ask_shell-0.7.0/ask_shell/_internal/run_pool.py +121 -0
  8. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/settings.py +12 -1
  9. {ask_shell-0.5.3 → ask_shell-0.7.0}/pyproject.toml +1 -1
  10. ask_shell-0.5.3/ask_shell/_internal/run_pool.py +0 -100
  11. {ask_shell-0.5.3 → ask_shell-0.7.0}/.gitignore +0 -0
  12. {ask_shell-0.5.3 → ask_shell-0.7.0}/LICENSE +0 -0
  13. {ask_shell-0.5.3 → ask_shell-0.7.0}/README.md +0 -0
  14. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/__main__.py +0 -0
  15. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/_internal/__init__.py +0 -0
  16. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/_internal/_run_env.py +0 -0
  17. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/_internal/events.py +0 -0
  18. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/_internal/global_callbacks.py +0 -0
  19. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/_internal/interactive.py +0 -0
  20. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/_internal/rich_live.py +0 -0
  21. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/_internal/rich_live_callback.py +0 -0
  22. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/_internal/typer_command.py +0 -0
  23. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/ask.py +0 -0
  24. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/console.py +0 -0
  25. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/py.typed +0 -0
  26. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/shell.py +0 -0
  27. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/shell_events.py +0 -0
  28. {ask_shell-0.5.3 → ask_shell-0.7.0}/ask_shell/test_docs.py +0 -0
  29. {ask_shell-0.5.3 → ask_shell-0.7.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.5.3
3
+ Version: 0.7.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.5.3"
10
+ VERSION = "0.7.0"
10
11
  __all__ = [
11
12
  "shell",
12
13
  "console",
13
14
  "shell_events",
14
15
  "ask",
15
16
  "AskShellSettings",
17
+ "ShellRunSummary",
16
18
  ]
@@ -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"
@@ -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)
@@ -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
@@ -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
  )
@@ -0,0 +1,121 @@
1
+ import logging
2
+ import time
3
+ from concurrent.futures import Future, ThreadPoolExecutor
4
+ from concurrent.futures import wait as futures_wait
5
+ from dataclasses import dataclass, field
6
+ from math import ceil
7
+ from threading import RLock
8
+ from typing import Any, Callable, Protocol, TypeVar
9
+
10
+ from ask_shell._internal._run import (
11
+ THREADS_PER_RUN,
12
+ get_pool,
13
+ handle_interrupt_wait,
14
+ max_run_count_for_workers,
15
+ wait_if_many_runs,
16
+ )
17
+ from ask_shell._internal.rich_progress import new_task
18
+ from ask_shell.settings import AskShellSettings
19
+
20
+ logger = logging.getLogger(__name__)
21
+ T_co = TypeVar("T_co", covariant=True)
22
+
23
+
24
+ class SubmitFunc(Protocol[T_co]):
25
+ def __call__(self, *args: Any, **kwargs: Any) -> T_co: ...
26
+
27
+
28
+ @dataclass
29
+ class run_pool:
30
+ task_name: str
31
+ total: int = 0
32
+ max_concurrent_submits: int = field(default=4)
33
+ threads_used_per_submit: int = (
34
+ THREADS_PER_RUN + 1
35
+ ) # If you are using `run` or `run_and_wait` this should be `THREADS_PER_RUN` + extra threads for your own tasks
36
+ pool_thread_count: int | None = None
37
+ sleep_time: float = 1
38
+ sleep_callback: Callable[[], Any] | None = None
39
+ exit_wait_timeout: float | None = (
40
+ None # If set, will wait for the pool to finish before exiting the context manager
41
+ )
42
+
43
+ pool: ThreadPoolExecutor = field(init=False)
44
+ _owns_pool: bool = field(init=False, default=False)
45
+ _pool_max_workers: int = field(init=False)
46
+ _max_run_count_with_this_pool: int = field(init=False)
47
+ _lock: RLock = field(init=False, default_factory=RLock)
48
+ _pending_count: int = field(init=False, default=0)
49
+ _task: new_task | None = field(init=False, default=None)
50
+ _futures: list[Future] = field(init=False, default_factory=list)
51
+
52
+ def __post_init__(self):
53
+ if self.pool_thread_count is not None:
54
+ self.pool = ThreadPoolExecutor(max_workers=self.pool_thread_count)
55
+ self._owns_pool = True
56
+ self._pool_max_workers = self.pool_thread_count
57
+ # dedicated pool: each concurrent submit reserves 1 run slot on the global pool
58
+ runs_needed = self.max_concurrent_submits
59
+ runs_available = max_run_count_for_workers()
60
+ logger.debug(
61
+ f"run_pool '{self.task_name}': dedicated pool with {self.pool_thread_count} workers, "
62
+ f"global pool reserves {runs_needed}/{runs_available} run slots"
63
+ )
64
+ else:
65
+ self.pool = get_pool()
66
+ self._pool_max_workers = self.pool._max_workers
67
+ # shared pool: submits + their shell runs share the same threads
68
+ workers_at_full_load = self.max_concurrent_submits * self.threads_used_per_submit
69
+ runs_needed = ceil(workers_at_full_load / THREADS_PER_RUN)
70
+ runs_available = max_run_count_for_workers(self._pool_max_workers)
71
+
72
+ assert runs_needed < runs_available, (
73
+ f"Run slots needed ({runs_needed}) exceed capacity ({runs_available}). "
74
+ f"Adjust {AskShellSettings.ENV_NAME_THREAD_COUNT} or decrease `max_concurrent_submits`."
75
+ )
76
+ self._max_run_count_with_this_pool = runs_available - runs_needed
77
+
78
+ def _on_submit_done(self, _future: Future):
79
+ with self._lock:
80
+ self._pending_count -= 1
81
+ if task := self._task:
82
+ task.update(advance=1)
83
+
84
+ def submit(self, fn: SubmitFunc[T_co], /, *args, **kwargs) -> Future[T_co]:
85
+ """Submit a task to the pool. Blocks if max_concurrent_submits are already in flight."""
86
+ with self._lock:
87
+ self._pending_count += 1
88
+ with handle_interrupt_wait(interrupt_message=f"run_pool submit for {self.task_name}"):
89
+ while self._pending_count > self.max_concurrent_submits:
90
+ if self.sleep_callback:
91
+ self.sleep_callback()
92
+ time.sleep(self.sleep_time)
93
+ wait_if_many_runs(
94
+ max_run_count=self._max_run_count_with_this_pool,
95
+ sleep_time=self.sleep_time,
96
+ sleep_callback=self.sleep_callback,
97
+ )
98
+ future = self.pool.submit(fn, *args, **kwargs)
99
+ future.add_done_callback(self._on_submit_done)
100
+ with self._lock:
101
+ self._futures.append(future)
102
+ return future
103
+
104
+ def __enter__(self):
105
+ self._task = new_task(self.task_name, self.total)
106
+ self._task.__enter__()
107
+ return self
108
+
109
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
110
+ with self._lock:
111
+ futures = list(self._futures)
112
+ if futures:
113
+ with handle_interrupt_wait(interrupt_message=f"interrupt in `run_pool` exit method for {self.task_name}"):
114
+ futures_wait(futures, timeout=self.exit_wait_timeout)
115
+ with self._lock:
116
+ self._futures.clear()
117
+
118
+ if self._owns_pool:
119
+ self.pool.shutdown(wait=True)
120
+ if task := self._task:
121
+ task.__exit__(exc_type, exc_value, traceback)
@@ -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"
@@ -102,7 +108,7 @@ class AskShellSettings(StaticSettings):
102
108
  )
103
109
  ENV_NAME_THREAD_COUNT: ClassVar[str] = f"{ENV_PREFIX}THREAD_COUNT"
104
110
  thread_count: int = Field(
105
- default=50,
111
+ default=100,
106
112
  alias=ENV_NAME_THREAD_COUNT,
107
113
  description="Thread count for ask-shell pool",
108
114
  )
@@ -136,6 +142,11 @@ 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
+ )
139
150
 
140
151
  @model_validator(mode="after")
141
152
  def ensure_vars_set(self) -> Self:
@@ -2,7 +2,7 @@
2
2
 
3
3
  [project]
4
4
  name = "ask-shell"
5
- version = "0.5.3"
5
+ version = "0.7.0"
6
6
  description = "CLIs with prompts, shell runs, and testable flows"
7
7
  requires-python = ">=3.13"
8
8
  license = "MIT"
@@ -1,100 +0,0 @@
1
- import time
2
- from concurrent.futures import Future, ThreadPoolExecutor
3
- from dataclasses import dataclass, field
4
- from math import ceil
5
- from threading import Event, RLock
6
- from typing import Any, Callable, Protocol, TypeVar
7
-
8
- from ask_shell._internal._run import (
9
- THREADS_PER_RUN,
10
- get_pool,
11
- handle_interrupt_wait,
12
- max_run_count_for_workers,
13
- wait_if_many_runs,
14
- )
15
- from ask_shell._internal.rich_progress import new_task
16
- from ask_shell.settings import AskShellSettings
17
-
18
- T_co = TypeVar("T_co", covariant=True)
19
-
20
-
21
- class SubmitFunc(Protocol[T_co]):
22
- def __call__(self, *args: Any, **kwargs: Any) -> T_co: ...
23
-
24
-
25
- @dataclass
26
- class run_pool:
27
- task_name: str
28
- total: int = 0
29
- max_concurrent_submits: int = field(default=4)
30
- threads_used_per_submit: int = (
31
- THREADS_PER_RUN + 1
32
- ) # If you are using `run` or `run_and_wait` this should be `THREADS_PER_RUN` + extra threads for your own tasks
33
- sleep_time: float = 1
34
- sleep_callback: Callable[[], Any] | None = None
35
- exit_wait_timeout: float | None = (
36
- None # If set, will wait for the pool to finish before exiting the context manager
37
- )
38
-
39
- pool: ThreadPoolExecutor = field(init=False, default_factory=get_pool)
40
- _pool_max_workers: int = field(init=False)
41
- _max_run_count_with_this_pool: int = field(init=False)
42
- _lock: RLock = field(init=False, default_factory=RLock)
43
- _current_submit_count: int = field(init=False, default=0)
44
- _task: new_task | None = field(init=False, default=None)
45
- _event: Event = field(init=False, default_factory=Event)
46
-
47
- def __post_init__(self):
48
- self._pool_max_workers = self.pool._max_workers
49
- max_run_count = max_run_count_for_workers(self._pool_max_workers)
50
- workers_required_if_full = self.max_concurrent_submits * self.threads_used_per_submit
51
- run_count_used_by_this_pool = ceil(workers_required_if_full / THREADS_PER_RUN)
52
- assert run_count_used_by_this_pool < max_run_count, (
53
- f"Run count used by this pool ({run_count_used_by_this_pool}) exceeds max run count ({max_run_count}). Adjust {AskShellSettings.ENV_NAME_THREAD_COUNT} environment variable or decrease `max_concurrent_submits` parameter."
54
- )
55
- self._max_run_count_with_this_pool = max_run_count - run_count_used_by_this_pool
56
-
57
- def _on_submit_done(self):
58
- """Callback to be called when a submit is done. This is used to decrement the current submit count."""
59
- with self._lock:
60
- self._current_submit_count -= 1
61
- if task := self._task:
62
- task.update(advance=1)
63
- if self._current_submit_count == 0:
64
- self._event.set()
65
-
66
- def submit(self, fn: SubmitFunc[T_co], /, *args, **kwargs) -> Future[T_co]:
67
- """Submit a task to the pool. Might block if the pool is full."""
68
-
69
- # problem: There is a bit of lag from submit until the run is actually started,
70
- with self._lock:
71
- self._current_submit_count += 1
72
- if self._current_submit_count == 1:
73
- self._event = Event() # reset the event when the first submit is made
74
- with handle_interrupt_wait(interrupt_message=f"run_pool submit for {self.task_name}"):
75
- while self._current_submit_count >= self.max_concurrent_submits:
76
- if self.sleep_callback:
77
- self.sleep_callback()
78
- time.sleep(self.sleep_time)
79
- # in case more runs are already submitted
80
- wait_if_many_runs(
81
- max_run_count=self._max_run_count_with_this_pool,
82
- sleep_time=self.sleep_time,
83
- sleep_callback=self.sleep_callback,
84
- )
85
- future = self.pool.submit(fn, *args, **kwargs)
86
- future.add_done_callback(lambda _: self._on_submit_done())
87
- return future
88
-
89
- def __enter__(self):
90
- self._task = new_task(self.task_name, self.total)
91
- self._task.__enter__()
92
- return self
93
-
94
- def __exit__(self, exc_type, exc_value, traceback) -> None:
95
- # no cleanup necessary, the pool will be cleaned up automatically due to atexit call
96
- with handle_interrupt_wait(interrupt_message=f"interrupt in `run_pool` exit method for {self.task_name}"):
97
- self._event.wait(self.exit_wait_timeout)
98
-
99
- if task := self._task:
100
- task.__exit__(exc_type, exc_value, traceback)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes