ask-shell 0.0.2__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.
@@ -0,0 +1,23 @@
1
+ # Python files
2
+ __pycache__/
3
+ **/.pytest_cache
4
+ *.pyc
5
+ .venv/
6
+ .venv-ci/
7
+ .ruff_cache
8
+ .coverage
9
+
10
+ # Pants workspace files
11
+ /.pants.d/
12
+ /dist
13
+ **/.pids
14
+ /.pants.workdir.file_lock*
15
+
16
+ # Editors
17
+ .idea/
18
+ *.iml
19
+
20
+ # docs
21
+ site
22
+ .cache
23
+ docs/
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: ask-shell
3
+ Version: 0.0.2
4
+ Requires-Python: >=3.10
5
+ Requires-Dist: model-lib[toml]
6
+ Requires-Dist: questionary>=2.1.0
7
+ Requires-Dist: rich>=13.9.4
8
+ Requires-Dist: typer>=0.16.0
9
+ Requires-Dist: zero-3rdparty
@@ -0,0 +1,46 @@
1
+ # Generated by pkg-ext
2
+ # flake8: noqa
3
+ from ask_shell._run import kill, run, run_and_wait, run_error, wait_on_ok_errors
4
+ from ask_shell._run_env import interactive_shell
5
+ from ask_shell.models import ShellConfig, ShellError, ShellRun
6
+ from ask_shell.interactive import (
7
+ ChoiceTyped,
8
+ SelectOptions,
9
+ confirm,
10
+ select_dict,
11
+ select_list,
12
+ select_list_choice,
13
+ select_list_multiple,
14
+ select_list_multiple_choices,
15
+ text,
16
+ )
17
+ from ask_shell.rich_live import print_to_live
18
+ from ask_shell.rich_progress import new_task
19
+ from ask_shell.settings import AskShellSettings
20
+ from ask_shell.typer_command import configure_logging
21
+
22
+ VERSION = "0.0.2"
23
+ __all__ = [
24
+ "AskShellSettings",
25
+ "ChoiceTyped",
26
+ "SelectOptions",
27
+ "ShellConfig",
28
+ "ShellError",
29
+ "ShellRun",
30
+ "configure_logging",
31
+ "confirm",
32
+ "interactive_shell",
33
+ "kill",
34
+ "new_task",
35
+ "print_to_live",
36
+ "run",
37
+ "run_and_wait",
38
+ "run_error",
39
+ "select_dict",
40
+ "select_list",
41
+ "select_list_choice",
42
+ "select_list_multiple",
43
+ "select_list_multiple_choices",
44
+ "text",
45
+ "wait_on_ok_errors",
46
+ ]
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from ask_shell import run_and_wait
4
+
5
+ _, *script_args = sys.argv
6
+ run_and_wait(" ".join(script_args))
@@ -0,0 +1 @@
1
+ ENV_PREFIX = "ASK_SHELL_"
@@ -0,0 +1,521 @@
1
+ """Design Principles:
2
+ 1. ShellConfig used to provide user configuration of the run.
3
+ 2. A ShellRun is created for each run, which manages the execution and output.
4
+ 3. The events are communicated through a queue, guaranteeing the order of messages.
5
+ 4. You can either run or run_and_wait, where the latter waits for completion while the 1st will exit after command has started.
6
+ 5. Message Callbacks can be used to direct output to the console, by default it is directed to a `.log` file which supports dumping ANSI content at the end of the run to a `.html` file.
7
+ 6. Retries are supported, with a configurable number of attempts and a retry condition.
8
+ 7. Any errors are converted into a `ShellError` which contains the run and base exception. Use `allow_non_zero_exit` to allow runs to complete with a non-zero exit code without raising this error.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import atexit
14
+ import logging
15
+ import os
16
+ import signal
17
+ import subprocess
18
+ import sys
19
+ from concurrent.futures import ThreadPoolExecutor, wait
20
+ from contextlib import suppress
21
+ from dataclasses import dataclass
22
+ from os import getenv, setsid
23
+ from pathlib import Path
24
+ from typing import IO, Any, Callable
25
+
26
+ from model_lib.pydantic_utils import copy_and_validate
27
+ from rich.ansi import AnsiDecoder
28
+ from rich.console import Console
29
+
30
+ from ask_shell.models import (
31
+ RunIncompleteError,
32
+ ShellConfig,
33
+ ShellRun,
34
+ ShellRunAfter,
35
+ ShellRunBefore,
36
+ ShellRunEventT,
37
+ ShellRunPOpenStarted,
38
+ ShellRunQueueT,
39
+ ShellRunRetryAttempt,
40
+ ShellRunStdOutput,
41
+ ShellRunStdReadError,
42
+ ShellRunStdStarted,
43
+ )
44
+ from ask_shell.settings import AskShellSettings
45
+
46
+ logger = logging.getLogger(__name__)
47
+ THREADS_PER_RUN = 4 # Each run will take 4 threads: 1 for stdout, 1 for stderr, 1 for consuming queue messages and 1 for popen wait.
48
+ THREAD_POOL_FULL_WAIT_TIME_SECONDS = float(
49
+ getenv(
50
+ AskShellSettings.ENV_NAME_THREAD_POOL_FULL_WAIT_TIME_SECONDS,
51
+ AskShellSettings.THREAD_POOL_FULL_WAIT_TIME_SECONDS_DEFAULT,
52
+ )
53
+ )
54
+
55
+ _pool = ThreadPoolExecutor(
56
+ max_workers=int(
57
+ getenv(
58
+ AskShellSettings.ENV_NAME_RUN_THREAD_COUNT,
59
+ AskShellSettings.RUN_THREAD_COUNT_DEFAULT,
60
+ )
61
+ )
62
+ )
63
+
64
+
65
+ def get_pool() -> ThreadPoolExecutor:
66
+ """Get the thread pool executor used for running shell commands."""
67
+ return _pool
68
+
69
+
70
+ _runs: dict[
71
+ int, ShellRun
72
+ ] = {} # internal to store running ShellRuns to support stopping them on exit
73
+
74
+
75
+ def current_run_count() -> int:
76
+ return len(_runs)
77
+
78
+
79
+ def stop_runs_and_pool(reason: str = "atexit", immediate: bool = False):
80
+ if _runs:
81
+ logger.warning("STOPPING stop_runs_and_pool")
82
+ kill_all_runs(reason=reason, immediate=immediate)
83
+
84
+ _pool.shutdown(wait=True)
85
+
86
+
87
+ atexit.register(stop_runs_and_pool)
88
+
89
+
90
+ def run(
91
+ config: ShellConfig | str,
92
+ *,
93
+ allow_non_zero_exit: bool | None = None,
94
+ ansi_content: bool | None = None,
95
+ attempts: int | None = None,
96
+ cwd: str | Path | None = None,
97
+ env: dict[str, str] | None = None,
98
+ extra_popen_kwargs: dict | None = None,
99
+ is_binary_call: bool | None = None,
100
+ message_callbacks: list[Callable[[ShellRunEventT], bool]] | None = None,
101
+ print_prefix: str | None = None,
102
+ run_log_stem_prefix: str | None = None,
103
+ run_output_dir: Path | None | None = None,
104
+ settings: AskShellSettings | None = None,
105
+ should_retry: Callable[[ShellRun], bool] | None = None,
106
+ skip_binary_check: bool | None = None,
107
+ skip_html_log_files: bool | None = None,
108
+ include_log_time: bool | None = None,
109
+ skip_os_env: bool | None = None,
110
+ start_timeout: float | None = None,
111
+ terminal_width: int | None = None,
112
+ skip_interactive_check: bool | None = None,
113
+ ) -> ShellRun:
114
+ config = _as_config(
115
+ config,
116
+ allow_non_zero_exit=allow_non_zero_exit,
117
+ ansi_content=ansi_content,
118
+ attempts=attempts,
119
+ cwd=cwd,
120
+ env=env,
121
+ extra_popen_kwargs=extra_popen_kwargs,
122
+ is_binary_call=is_binary_call,
123
+ message_callbacks=message_callbacks,
124
+ print_prefix=print_prefix,
125
+ run_log_stem_prefix=run_log_stem_prefix,
126
+ run_output_dir=run_output_dir,
127
+ settings=settings,
128
+ should_retry=should_retry,
129
+ skip_binary_check=skip_binary_check,
130
+ skip_html_log_files=skip_html_log_files,
131
+ include_log_time=include_log_time,
132
+ skip_os_env=skip_os_env,
133
+ terminal_width=terminal_width,
134
+ skip_interactive_check=skip_interactive_check,
135
+ )
136
+ assert not config.user_input, (
137
+ "run() does not support user_input (only 1 should be active at a time), use run_and_wait() instead"
138
+ )
139
+ run = ShellRun(config)
140
+ _pool.submit(_execute_run, run)
141
+ with handle_interrupt_wait(f"interrupt when starting {run}"):
142
+ return run.wait_on_started(start_timeout)
143
+
144
+
145
+ def run_and_wait(
146
+ script: ShellConfig | str,
147
+ timeout: float | None = None,
148
+ *,
149
+ allow_non_zero_exit: bool | None = None,
150
+ ansi_content: bool | None = None,
151
+ attempts: int | None = None,
152
+ cwd: str | Path | None = None,
153
+ env: dict[str, str] | None = None,
154
+ extra_popen_kwargs: dict | None = None,
155
+ is_binary_call: bool | None = None,
156
+ message_callbacks: list[Callable[[ShellRunEventT], bool]] | None = None,
157
+ print_prefix: str | None = None,
158
+ run_log_stem_prefix: str | None = None,
159
+ run_output_dir: Path | None | None = None,
160
+ settings: AskShellSettings | None = None,
161
+ should_retry: Callable[[ShellRun], bool] | None = None,
162
+ skip_binary_check: bool | None = None,
163
+ skip_html_log_files: bool | None = None,
164
+ include_log_time: bool | None = None,
165
+ skip_os_env: bool | None = None,
166
+ user_input: bool | None = None,
167
+ terminal_width: int | None = None,
168
+ skip_interactive_check: bool | None = None,
169
+ ) -> ShellRun:
170
+ config = _as_config(
171
+ script,
172
+ allow_non_zero_exit=allow_non_zero_exit,
173
+ ansi_content=ansi_content,
174
+ attempts=attempts,
175
+ cwd=cwd,
176
+ env=env,
177
+ extra_popen_kwargs=extra_popen_kwargs,
178
+ is_binary_call=is_binary_call,
179
+ message_callbacks=message_callbacks,
180
+ print_prefix=print_prefix,
181
+ run_log_stem_prefix=run_log_stem_prefix,
182
+ run_output_dir=run_output_dir,
183
+ settings=settings,
184
+ should_retry=should_retry,
185
+ skip_binary_check=skip_binary_check,
186
+ skip_html_log_files=skip_html_log_files,
187
+ include_log_time=include_log_time,
188
+ skip_os_env=skip_os_env,
189
+ user_input=user_input,
190
+ terminal_width=terminal_width,
191
+ skip_interactive_check=skip_interactive_check,
192
+ )
193
+ run = ShellRun(config)
194
+ _pool.submit(_execute_run, run)
195
+ with handle_interrupt_wait(f"interrupt when waiting for {run}"):
196
+ run.wait_until_complete(timeout)
197
+ return run
198
+
199
+
200
+ @dataclass
201
+ class handle_interrupt_wait:
202
+ interrupt_message: str
203
+ immediate_kill: bool = False
204
+
205
+ def __enter__(self):
206
+ """Context manager to ensure that all runs are stopped on exit."""
207
+ return self
208
+
209
+ def __exit__(self, exc_type, exc_value, traceback):
210
+ """Handle exit by stopping all runs."""
211
+ if exc_type is KeyboardInterrupt or exc_type is InterruptedError:
212
+ interrupt_error = f"{self.interrupt_message} {exc_value!r}"
213
+ stop_runs_and_pool(interrupt_error, immediate=self.immediate_kill)
214
+
215
+
216
+ def _as_config(config: ShellConfig | str, **kwargs) -> ShellConfig:
217
+ kwargs_not_none = {k: v for k, v in kwargs.items() if v is not None}
218
+ if isinstance(config, str):
219
+ return ShellConfig(shell_input=config, **kwargs_not_none)
220
+ assert isinstance(config, ShellConfig), f"not a ShellConfig or str: {config!r}"
221
+ return copy_and_validate(config, **kwargs_not_none)
222
+
223
+
224
+ def run_error(run: ShellRun, timeout: float | None = 1) -> BaseException | None:
225
+ try:
226
+ run._complete_flag.result(timeout=timeout)
227
+ except BaseException as e:
228
+ return e
229
+
230
+
231
+ def wait_on_ok_errors(
232
+ *runs: ShellRun, # type: ignore
233
+ timeout: float | None = None,
234
+ skip_kill_timeouts: bool = False, # type: ignore
235
+ ) -> tuple[list[ShellRun], list[tuple[BaseException, ShellRun]]]:
236
+ future_runs = {run._complete_flag: run for run in runs}
237
+ with handle_interrupt_wait("interrupt when waiting for runs"):
238
+ done, not_done = wait(
239
+ [run._complete_flag for run in runs], timeout, return_when="ALL_COMPLETED"
240
+ )
241
+ errors: list[tuple[BaseException, ShellRun]] = []
242
+ oks: list[ShellRun] = []
243
+
244
+ if not_done:
245
+ if skip_kill_timeouts:
246
+ errors.extend(
247
+ (RunIncompleteError(future_runs[run]), future_runs[run])
248
+ for run in not_done
249
+ )
250
+ runs: list[ShellRun] = [future_runs[f] for f in done] # type: ignore
251
+ else:
252
+ for run in runs:
253
+ if run.is_running:
254
+ kill(run, immediate=True, reason="timeout")
255
+
256
+ for run in runs:
257
+ if error := run_error(run):
258
+ errors.append((error, run))
259
+ else:
260
+ oks.append(run)
261
+ return oks, errors
262
+
263
+
264
+ def kill_all_runs(
265
+ immediate: bool = False, reason: str = "", abort_timeout: float = 3.0
266
+ ):
267
+ for run in _runs.values():
268
+ kill(
269
+ run,
270
+ immediate=immediate,
271
+ reason=reason,
272
+ abort_timeout=abort_timeout,
273
+ )
274
+
275
+
276
+ def kill(
277
+ run: ShellRun,
278
+ immediate: bool = False,
279
+ reason: str = "",
280
+ abort_timeout: float = 3.0,
281
+ ):
282
+ """https://stackoverflow.com/questions/4789837/how-to-terminate-a-python-subprocess-launched-with-shell-true"""
283
+ proc = run.p_open
284
+ if not proc or proc.returncode is not None:
285
+ logger.info(f"killing run already completed: {run} {reason}")
286
+ return
287
+
288
+ logger.warning(f"killing starting: {run} {reason}")
289
+ try:
290
+ pgid = os.getpgid(proc.pid)
291
+ # proc.terminate() and proc.send_signal() doesn't work across platforms.
292
+ if immediate:
293
+ os.killpg(pgid, signal.SIGTERM)
294
+ else:
295
+ os.killpg(pgid, signal.SIGINT)
296
+ proc.wait(timeout=abort_timeout)
297
+ logger.info(f"killing completed: {run} {reason}")
298
+ except subprocess.TimeoutExpired:
299
+ logger.warning(
300
+ f"killing timeout after {abort_timeout}s! forcing a kill: {run} {reason}"
301
+ )
302
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
303
+ with suppress(subprocess.TimeoutExpired):
304
+ proc.wait(timeout=1)
305
+ except (OSError, ValueError) as e:
306
+ logger.warning(f"unable to get output when shutting down: {run} {e!r}")
307
+ finally:
308
+ run.wait_until_complete(timeout=1, no_raise=True)
309
+
310
+
311
+ def _execute_run(shell_run: ShellRun) -> ShellRun:
312
+ """
313
+ Principles:
314
+ 1. This function is executed in a separate thread. Interrupts will not affect it.
315
+ 2. It should handle every error condition and not raise exceptions.
316
+ """
317
+ config = shell_run.config
318
+ queue = shell_run._queue
319
+
320
+ def queue_consumer():
321
+ for message in queue:
322
+ try:
323
+ shell_run._on_event(message)
324
+ except BaseException as e:
325
+ logger.warning(f"Error processing message for {shell_run}: {e!r}")
326
+ logger.exception(e)
327
+
328
+ try:
329
+ shell_run._on_event(
330
+ ShellRunBefore(run=shell_run)
331
+ ) # can block, for example if the thread pool doesn't have enough threads free
332
+ except BaseException as e:
333
+ logger.warning(f"Error before starting run {shell_run}: {e!r}")
334
+ shell_run._complete(error=e)
335
+ return shell_run
336
+
337
+ consumer_future = _pool.submit(queue_consumer)
338
+ output_dir = config.run_output_dir_resolved()
339
+ output_dir.mkdir(parents=True, exist_ok=True)
340
+ error: BaseException | None = None
341
+ for attempt in range(1, config.attempts + 1):
342
+ if attempt > 1:
343
+ queue.put_nowait(ShellRunRetryAttempt(attempt=attempt))
344
+ logger.warning(
345
+ f"Retrying run {shell_run} attempt {attempt} of {config.attempts}"
346
+ )
347
+ attempt_log_prefix = config.run_log_stem(attempt)
348
+
349
+ result = _attempt_run(shell_run, output_dir, attempt_log_prefix)
350
+ match result:
351
+ case ShellRun() if result.clean_complete or not config.should_retry(result):
352
+ break
353
+ case BaseException():
354
+ error = result
355
+ break
356
+ queue.put_nowait(ShellRunAfter(run=shell_run, error=error))
357
+ shell_run._complete(error=error, queue_consumer=consumer_future)
358
+ return shell_run
359
+
360
+
361
+ def _attempt_run(
362
+ shell_run: ShellRun,
363
+ output_dir: Path,
364
+ file_name: str,
365
+ ) -> ShellRun | BaseException:
366
+ """Run the shell command and handle the error, never raises an exception."""
367
+ config = shell_run.config
368
+ key = id(shell_run)
369
+ _runs[key] = shell_run
370
+ try:
371
+ _run(
372
+ config,
373
+ shell_run._queue,
374
+ output_dir,
375
+ file_name,
376
+ )
377
+ return shell_run
378
+ except BaseException as e:
379
+ logger.warning(f"Error running {shell_run}: {e!r}")
380
+ logger.exception(e)
381
+ return e
382
+ finally:
383
+ _runs.pop(key, None)
384
+
385
+
386
+ def _run(
387
+ config: ShellConfig,
388
+ queue: ShellRunQueueT,
389
+ output_dir: Path,
390
+ file_name: str,
391
+ ) -> None:
392
+ kwargs = (
393
+ dict(
394
+ stdout=subprocess.PIPE,
395
+ stderr=subprocess.PIPE,
396
+ preexec_fn=setsid,
397
+ universal_newlines=True,
398
+ )
399
+ | config.popen_kwargs
400
+ )
401
+ with subprocess.Popen(config.shell_input, shell=True, **kwargs) as proc: # type: ignore
402
+ queue.put_nowait(ShellRunPOpenStarted(proc))
403
+
404
+ def stdout_started(is_stdout: bool, console: Console, log_path: Path):
405
+ queue.put_nowait(
406
+ ShellRunStdStarted(
407
+ is_stdout=is_stdout, console=console, log_path=log_path
408
+ )
409
+ )
410
+
411
+ def add_stdout_line(line: str):
412
+ queue.put_nowait(ShellRunStdOutput(is_stdout=True, content=line))
413
+
414
+ def read_stdout():
415
+ output_path = output_dir / f"{file_name}.stdout.log"
416
+ try:
417
+ _read_until_complete(
418
+ stream=proc.stdout, # type: ignore
419
+ output_path=output_path,
420
+ on_console_ready=lambda console: stdout_started(
421
+ is_stdout=True, console=console, log_path=output_path
422
+ ),
423
+ on_line=add_stdout_line,
424
+ config=config,
425
+ )
426
+ except BaseException as e:
427
+ queue.put_nowait(ShellRunStdReadError(is_stdout=True, error=e))
428
+
429
+ def add_stderr_line(line: str):
430
+ queue.put_nowait(ShellRunStdOutput(is_stdout=False, content=line))
431
+
432
+ def read_stderr():
433
+ output_path = output_dir / f"{file_name}.stderr.log"
434
+ try:
435
+ _read_until_complete(
436
+ stream=proc.stderr, # type: ignore
437
+ output_path=output_path,
438
+ on_console_ready=lambda console: stdout_started(
439
+ is_stdout=False, console=console, log_path=output_path
440
+ ),
441
+ on_line=add_stderr_line,
442
+ config=config,
443
+ )
444
+ except BaseException as e:
445
+ queue.put_nowait(ShellRunStdReadError(is_stdout=False, error=e))
446
+
447
+ fut_stdout = _pool.submit(read_stdout)
448
+ fut_stderr = _pool.submit(read_stderr)
449
+ # no proc.wait/communicate, not sure if it will work
450
+ wait([fut_stdout, fut_stderr])
451
+
452
+
453
+ def _read_until_complete(
454
+ stream: IO[str],
455
+ output_path: Path,
456
+ on_console_ready: Callable[[Console], None],
457
+ on_line: Callable[[str], None],
458
+ config: ShellConfig,
459
+ ):
460
+ with open(output_path, "w") as f:
461
+ console = Console(
462
+ file=f,
463
+ record=True,
464
+ log_path=False,
465
+ soft_wrap=True,
466
+ log_time=config.include_log_time,
467
+ width=config.terminal_width,
468
+ )
469
+ on_console_ready(console)
470
+ old_write = f.write
471
+
472
+ def write_hook(text: str):
473
+ text_no_extras = [line.strip() for line in text.splitlines()]
474
+ return old_write("\n".join(text_no_extras))
475
+
476
+ decoder = AnsiDecoder()
477
+
478
+ def write_hook_ansi(text: str):
479
+ plain_text = "\n".join(
480
+ decoder.decode_line(line).plain.strip() for line in text.splitlines()
481
+ )
482
+ return old_write(plain_text)
483
+
484
+ f.write = write_hook_ansi if config.ansi_content else write_hook
485
+ if config.user_input:
486
+ out_stream = sys.stdout if ".stdout." in output_path.name else sys.stderr
487
+
488
+ def _on_line(line: str):
489
+ console.log(line, end="")
490
+ on_line(line)
491
+
492
+ def _on_char(char: str):
493
+ out_stream.write(char)
494
+ out_stream.flush()
495
+
496
+ _stream_one_character_at_a_time(
497
+ stream,
498
+ on_line=_on_line,
499
+ on_char=_on_char,
500
+ )
501
+ else:
502
+ for line in iter(stream.readline, ""):
503
+ console.log(line, end="")
504
+ on_line(line)
505
+
506
+
507
+ def _stream_one_character_at_a_time(
508
+ stream: IO[str], on_line: Callable[[str], None], on_char: Callable[[str], Any]
509
+ ):
510
+ buffer = ""
511
+ while True:
512
+ chunk = stream.read(1) # Read one character at a time
513
+ if not chunk:
514
+ break
515
+ buffer += chunk
516
+ on_char(chunk)
517
+ if chunk == "\n":
518
+ on_line(buffer)
519
+ buffer = ""
520
+ if buffer: # Handle any remaining data (incomplete line)
521
+ on_line(buffer)
@@ -0,0 +1,42 @@
1
+ import logging
2
+ import sys
3
+ from functools import lru_cache
4
+ from os import getenv
5
+
6
+ from zero_3rdparty.run_env import in_test_env, running_in_container_environment
7
+
8
+ from ask_shell._constants import ENV_PREFIX
9
+
10
+ ENV_NAME_FORCE_INTERACTIVE_SHELL = f"{ENV_PREFIX}FORCE_INTERACTIVE_SHELL"
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @lru_cache
15
+ def interactive_shell() -> bool:
16
+ if getenv(ENV_NAME_FORCE_INTERACTIVE_SHELL, "false").lower() in (
17
+ "true",
18
+ "1",
19
+ "yes",
20
+ ):
21
+ logger.debug(
22
+ f"Interactive shell forced by environment variable {ENV_NAME_FORCE_INTERACTIVE_SHELL}"
23
+ )
24
+ return True
25
+ if non_interactive_reason := _not_interactive_reason():
26
+ logger.debug(f"Interactive shell not available: {non_interactive_reason}")
27
+ return False
28
+ return True
29
+
30
+
31
+ def _not_interactive_reason() -> str:
32
+ if in_test_env():
33
+ return "Running in test environment"
34
+ if getenv("TERM", "") in ("dumb", "unknown"):
35
+ return "TERM environment variable is set to 'dumb' or 'unknown'"
36
+ if not sys.stdout.isatty():
37
+ return "Standard output is not a TTY"
38
+ if getenv("CI", "false").lower() in ("true", "1", "yes"):
39
+ return "Running in CI environment"
40
+ if running_in_container_environment():
41
+ return "Running in container environment"
42
+ return ""
@@ -0,0 +1,20 @@
1
+ import logging
2
+ import time
3
+
4
+ import ask_shell._run
5
+ from ask_shell.models import ShellRunBefore, ShellRunEventT
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def wait_on_available_threads(message: ShellRunEventT) -> bool:
11
+ if isinstance(message, ShellRunBefore):
12
+ max_workers = ask_shell._run.get_pool()._max_workers
13
+ max_count = max_workers // ask_shell._run.THREADS_PER_RUN - 1
14
+ while ask_shell._run.current_run_count() > max_count:
15
+ logger.warning(
16
+ f"Run count={ask_shell._run.current_run_count()} exceeds max {max_count}. "
17
+ f"Waiting for threads to finish before starting {message.run}..."
18
+ )
19
+ time.sleep(ask_shell._run.THREAD_POOL_FULL_WAIT_TIME_SECONDS)
20
+ return True # always remove the callback from the run, BeforeRunMessage should be the first message