ask-shell 0.5.2__tar.gz → 0.6.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.2 → ask_shell-0.6.0}/PKG-INFO +1 -1
  2. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/__init__.py +1 -1
  3. ask_shell-0.6.0/ask_shell/_internal/run_pool.py +121 -0
  4. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/_internal/typer_command.py +45 -12
  5. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/settings.py +1 -1
  6. {ask_shell-0.5.2 → ask_shell-0.6.0}/pyproject.toml +1 -1
  7. ask_shell-0.5.2/ask_shell/_internal/run_pool.py +0 -100
  8. {ask_shell-0.5.2 → ask_shell-0.6.0}/.gitignore +0 -0
  9. {ask_shell-0.5.2 → ask_shell-0.6.0}/LICENSE +0 -0
  10. {ask_shell-0.5.2 → ask_shell-0.6.0}/README.md +0 -0
  11. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/__main__.py +0 -0
  12. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/_internal/__init__.py +0 -0
  13. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/_internal/_run.py +0 -0
  14. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/_internal/_run_env.py +0 -0
  15. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/_internal/events.py +0 -0
  16. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/_internal/global_callbacks.py +0 -0
  17. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/_internal/interactive.py +0 -0
  18. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/_internal/models.py +0 -0
  19. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/_internal/rich_live.py +0 -0
  20. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/_internal/rich_live_callback.py +0 -0
  21. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/_internal/rich_progress.py +0 -0
  22. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/_internal/rich_run_state.py +0 -0
  23. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/ask.py +0 -0
  24. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/console.py +0 -0
  25. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/py.typed +0 -0
  26. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/shell.py +0 -0
  27. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/shell_events.py +0 -0
  28. {ask_shell-0.5.2 → ask_shell-0.6.0}/ask_shell/test_docs.py +0 -0
  29. {ask_shell-0.5.2 → ask_shell-0.6.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.2
3
+ Version: 0.6.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
@@ -6,7 +6,7 @@ from ask_shell import shell_events
6
6
  from ask_shell import ask
7
7
  from ask_shell.settings import AskShellSettings
8
8
 
9
- VERSION = "0.5.2"
9
+ VERSION = "0.6.0"
10
10
  __all__ = [
11
11
  "shell",
12
12
  "console",
@@ -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)
@@ -88,6 +88,42 @@ def track_progress_decorator(
88
88
  return decorator
89
89
 
90
90
 
91
+ def _wrap_typer_tree_commands(
92
+ app: typer.Typer,
93
+ *,
94
+ settings: AskShellSettings,
95
+ log_path_prefix: str,
96
+ skip_except_hook: bool,
97
+ use_app_name_command_for_logs: bool,
98
+ render_rich_error_on_sys_exit: bool,
99
+ ) -> None:
100
+ for command in app.registered_commands:
101
+ command.callback = track_progress_decorator(
102
+ skip_except_hook=skip_except_hook,
103
+ settings=settings,
104
+ use_app_name_command_for_logs=use_app_name_command_for_logs,
105
+ app_name=log_path_prefix,
106
+ command_name=command.name or command.callback.__name__, # type: ignore
107
+ skip_rich_exception=not render_rich_error_on_sys_exit,
108
+ )(
109
+ command.callback # type: ignore
110
+ )
111
+ for group in app.registered_groups:
112
+ nested = group.typer_instance
113
+ if nested is None:
114
+ continue
115
+ segment = group.name or nested.info.name or "group"
116
+ child_prefix = f"{log_path_prefix}/{segment}"
117
+ _wrap_typer_tree_commands(
118
+ nested,
119
+ settings=settings,
120
+ log_path_prefix=child_prefix,
121
+ skip_except_hook=skip_except_hook,
122
+ use_app_name_command_for_logs=use_app_name_command_for_logs,
123
+ render_rich_error_on_sys_exit=render_rich_error_on_sys_exit,
124
+ )
125
+
126
+
91
127
  def remove_secrets(message: str, secrets: list[str]) -> str:
92
128
  for secret in secrets:
93
129
  message = message.replace(secret, "***")
@@ -141,18 +177,15 @@ def configure_logging(
141
177
  render_rich_error_on_sys_exit: bool = False,
142
178
  ) -> logging.Handler:
143
179
  settings = settings or AskShellSettings.from_env()
144
- app_name = app.info.name or "typer_app"
145
- for command in app.registered_commands:
146
- command.callback = track_progress_decorator(
147
- skip_except_hook=skip_except_hook,
148
- settings=settings,
149
- use_app_name_command_for_logs=use_app_name_command_for_logs,
150
- app_name=app_name,
151
- command_name=command.name or command.callback.__name__, # type: ignore
152
- skip_rich_exception=not render_rich_error_on_sys_exit,
153
- )(
154
- command.callback # type: ignore
155
- )
180
+ root_prefix = app.info.name or "typer_app"
181
+ _wrap_typer_tree_commands(
182
+ app,
183
+ settings=settings,
184
+ log_path_prefix=root_prefix,
185
+ skip_except_hook=skip_except_hook,
186
+ use_app_name_command_for_logs=use_app_name_command_for_logs,
187
+ render_rich_error_on_sys_exit=render_rich_error_on_sys_exit,
188
+ )
156
189
  handler = RichHandler(rich_tracebacks=False, level=settings.log_level, console=get_live_console())
157
190
  logging.basicConfig(
158
191
  level=settings.log_level,
@@ -102,7 +102,7 @@ class AskShellSettings(StaticSettings):
102
102
  )
103
103
  ENV_NAME_THREAD_COUNT: ClassVar[str] = f"{ENV_PREFIX}THREAD_COUNT"
104
104
  thread_count: int = Field(
105
- default=50,
105
+ default=100,
106
106
  alias=ENV_NAME_THREAD_COUNT,
107
107
  description="Thread count for ask-shell pool",
108
108
  )
@@ -2,7 +2,7 @@
2
2
 
3
3
  [project]
4
4
  name = "ask-shell"
5
- version = "0.5.2"
5
+ version = "0.6.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