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.
- ask_shell-0.0.2/.gitignore +23 -0
- ask_shell-0.0.2/PKG-INFO +9 -0
- ask_shell-0.0.2/ask_shell/__init__.py +46 -0
- ask_shell-0.0.2/ask_shell/__main__.py +6 -0
- ask_shell-0.0.2/ask_shell/_constants.py +1 -0
- ask_shell-0.0.2/ask_shell/_run.py +521 -0
- ask_shell-0.0.2/ask_shell/_run_env.py +42 -0
- ask_shell-0.0.2/ask_shell/global_callbacks.py +20 -0
- ask_shell-0.0.2/ask_shell/interactive.py +392 -0
- ask_shell-0.0.2/ask_shell/models.py +555 -0
- ask_shell-0.0.2/ask_shell/rich_live.py +203 -0
- ask_shell-0.0.2/ask_shell/rich_live_callback.py +38 -0
- ask_shell-0.0.2/ask_shell/rich_progress.py +220 -0
- ask_shell-0.0.2/ask_shell/rich_run_state.py +148 -0
- ask_shell-0.0.2/ask_shell/settings.py +132 -0
- ask_shell-0.0.2/ask_shell/typer_command.py +164 -0
- ask_shell-0.0.2/pyproject.toml +24 -0
|
@@ -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/
|
ask_shell-0.0.2/PKG-INFO
ADDED
|
@@ -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 @@
|
|
|
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
|