ask-shell 0.10.2__tar.gz → 0.11.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 (34) hide show
  1. {ask_shell-0.10.2 → ask_shell-0.11.0}/PKG-INFO +2 -2
  2. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/__init__.py +3 -3
  3. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/_internal/_run.py +18 -19
  4. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/_internal/interactive.py +156 -92
  5. ask_shell-0.11.0/ask_shell/_internal/interactive_models.py +87 -0
  6. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/_internal/models.py +4 -5
  7. ask_shell-0.11.0/ask_shell/_internal/non_interactive.py +268 -0
  8. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/_internal/rich_live.py +5 -6
  9. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/_internal/rich_progress.py +2 -4
  10. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/_internal/run_pool.py +1 -1
  11. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/_internal/typer_command.py +43 -8
  12. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/ask.py +6 -2
  13. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/settings.py +73 -7
  14. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/test_docs.py +1 -1
  15. {ask_shell-0.10.2 → ask_shell-0.11.0}/pyproject.toml +9 -2
  16. {ask_shell-0.10.2 → ask_shell-0.11.0}/.gitignore +0 -0
  17. {ask_shell-0.10.2 → ask_shell-0.11.0}/LICENSE +0 -0
  18. {ask_shell-0.10.2 → ask_shell-0.11.0}/README.md +0 -0
  19. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/__main__.py +0 -0
  20. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/_internal/__init__.py +0 -0
  21. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/_internal/_run_env.py +0 -0
  22. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/_internal/events.py +0 -0
  23. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/_internal/global_callbacks.py +0 -0
  24. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/_internal/live_print_context.py +0 -0
  25. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/_internal/rich_live_callback.py +0 -0
  26. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/_internal/rich_run_state.py +0 -0
  27. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/console.py +0 -0
  28. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/py.typed +0 -0
  29. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/shell.py +0 -0
  30. {ask_shell-0.10.2 → ask_shell-0.11.0}/ask_shell/shell_events.py +0 -0
  31. {ask_shell-0.10.2 → ask_shell-0.11.0}/examples/apply_live/__init__.py +0 -0
  32. {ask_shell-0.10.2 → ask_shell-0.11.0}/examples/apply_live/cli.py +0 -0
  33. {ask_shell-0.10.2 → ask_shell-0.11.0}/examples/apply_live/stream_handler.py +0 -0
  34. {ask_shell-0.10.2 → ask_shell-0.11.0}/scripts/fix_source_links.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: ask-shell
3
- Version: 0.10.2
3
+ Version: 0.11.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
@@ -3,16 +3,16 @@
3
3
  from ask_shell import shell
4
4
  from ask_shell import console
5
5
  from ask_shell import shell_events
6
- from ask_shell import ask
7
6
  from ask_shell.settings import AskShellSettings
8
7
  from ask_shell.settings import ShellRunSummary
8
+ from ask_shell import ask
9
9
 
10
- VERSION = "0.10.2"
10
+ VERSION = "0.11.0"
11
11
  __all__ = [
12
12
  "shell",
13
13
  "console",
14
14
  "shell_events",
15
- "ask",
16
15
  "AskShellSettings",
17
16
  "ShellRunSummary",
17
+ "ask",
18
18
  ]
@@ -278,16 +278,13 @@ def _run(
278
278
  output_dir: Path,
279
279
  file_name: str,
280
280
  ) -> None:
281
- kwargs = (
282
- dict(
283
- stdout=subprocess.PIPE,
284
- stderr=subprocess.PIPE,
285
- start_new_session=True,
286
- universal_newlines=True,
287
- )
288
- | config.popen_kwargs
289
- )
290
- with subprocess.Popen(config.shell_input, shell=True, **kwargs) as proc: # type: ignore
281
+ kwargs = {
282
+ "stdout": subprocess.PIPE,
283
+ "stderr": subprocess.PIPE,
284
+ "start_new_session": True,
285
+ "universal_newlines": True,
286
+ } | config.popen_kwargs
287
+ with subprocess.Popen(config.shell_input, shell=True, **kwargs) as proc:
291
288
  queue.put_nowait(ShellRunPOpenStarted(proc))
292
289
 
293
290
  def stdout_started(is_stdout: bool, console: Console, log_path: Path):
@@ -310,7 +307,7 @@ def _run(
310
307
  is_stdout=True,
311
308
  )
312
309
  except BaseException as e:
313
- logger.exception(e)
310
+ logger.exception("stdout read failed")
314
311
  queue.put_nowait(ShellRunStdReadError(is_stdout=True, error=e))
315
312
 
316
313
  def add_stderr_line(line: str):
@@ -330,7 +327,7 @@ def _run(
330
327
  is_stdout=False,
331
328
  )
332
329
  except BaseException as e:
333
- logger.exception(e)
330
+ logger.exception("stderr read failed")
334
331
  queue.put_nowait(ShellRunStdReadError(is_stdout=False, error=e))
335
332
 
336
333
  fut_stdout = _pool.submit(read_stdout)
@@ -357,7 +354,7 @@ def _attempt_run(
357
354
  return shell_run
358
355
  except BaseException as e:
359
356
  logger.warning(f"Error running {shell_run}: {e!r}")
360
- logger.exception(e)
357
+ logger.exception("run failed")
361
358
  return e
362
359
  finally:
363
360
  _runs.pop(key, None)
@@ -369,7 +366,7 @@ def _eval_should_retry(config: ShellConfig, result: ShellRun) -> bool:
369
366
  return config.should_retry(result)
370
367
  except AbortRetryError:
371
368
  raise
372
- except Exception as e:
369
+ except (TypeError, ValueError, RuntimeError, OSError, AttributeError) as e:
373
370
  logger.warning(f"should_retry callback failed: {e!r}")
374
371
  return False
375
372
 
@@ -423,13 +420,13 @@ def _execute_run(shell_run: ShellRun) -> ShellRun:
423
420
  ctx.run(shell_run._on_event, message)
424
421
  except BaseException as e:
425
422
  logger.warning(f"Error processing message '{type(message).__name__}' for {shell_run}: {e!r}")
426
- logger.exception(e)
423
+ logger.exception("message callback failed")
427
424
 
428
425
  try:
429
426
  shell_run._on_event(
430
427
  ShellRunBefore(run=shell_run)
431
428
  ) # can block, for example if the thread pool doesn't have enough threads free
432
- except BaseException as e:
429
+ except BaseException as e: # noqa: BLE001
433
430
  logger.warning(f"Error before starting run {shell_run}: {e!r}")
434
431
  shell_run._complete(error=e)
435
432
  return shell_run
@@ -466,7 +463,7 @@ def run(
466
463
  retry_jitter: float | None = None,
467
464
  retry_max_wait: float | None = None,
468
465
  run_log_stem_prefix: str | None = None,
469
- run_output_dir: Path | None | None = None,
466
+ run_output_dir: Path | None = None,
470
467
  settings: AskShellSettings | None = None,
471
468
  should_retry: Callable[[ShellRun], bool] | None = None,
472
469
  skip_binary_check: bool | None = None,
@@ -533,7 +530,7 @@ def run_and_wait(
533
530
  retry_jitter: float | None = None,
534
531
  retry_max_wait: float | None = None,
535
532
  run_log_stem_prefix: str | None = None,
536
- run_output_dir: Path | None | None = None,
533
+ run_output_dir: Path | None = None,
537
534
  settings: AskShellSettings | None = None,
538
535
  should_retry: Callable[[ShellRun], bool] | None = None,
539
536
  skip_binary_check: bool | None = None,
@@ -586,7 +583,9 @@ def run_and_wait(
586
583
  def run_error(run: ShellRun, timeout: float | None = 1) -> BaseException | None:
587
584
  try:
588
585
  run._complete_flag.result(timeout=timeout)
589
- except BaseException as e:
586
+ except TimeoutError as e:
587
+ return e
588
+ except Exception as e: # noqa: BLE001
590
589
  return e
591
590
 
592
591
 
@@ -1,5 +1,3 @@
1
- """Inspired by: https://github.com/tmbo/questionary/blob/master/tests/utils.py"""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import asyncio
@@ -7,25 +5,31 @@ import io
7
5
  import logging
8
6
  import os
9
7
  import string
8
+ from collections.abc import Callable, Sequence
10
9
  from dataclasses import dataclass, field
11
- from functools import wraps
12
- from typing import Any, Callable, Generic, Self, TypeVar
10
+ from typing import Any, Generic, Self, TypeVar
13
11
 
14
12
  from prompt_toolkit.input.defaults import create_pipe_input
15
13
  from prompt_toolkit.output import DummyOutput
16
14
  from prompt_toolkit.output.plain_text import PlainTextOutput
17
15
  from pydantic import BaseModel, model_validator
18
- from questionary import Choice, Question, checkbox
16
+ from questionary import Question, checkbox
19
17
  from questionary import confirm as _confirm
20
18
  from questionary import select as _select
21
19
  from questionary import text as _text
22
- from zero_3rdparty.object_name import as_name, func_arg_names
20
+ from zero_3rdparty.object_name import as_name
23
21
  from zero_3rdparty.str_utils import ensure_suffix
24
22
 
25
23
  from ask_shell._internal._run import get_pool
26
24
  from ask_shell._internal._run_env import (
27
25
  interactive_shell,
28
26
  )
27
+ from ask_shell._internal.interactive_models import ChoiceTyped, PromptKind
28
+ from ask_shell._internal.non_interactive import (
29
+ record_answered_row,
30
+ replay_or_dump,
31
+ try_replay_answered,
32
+ )
29
33
  from ask_shell._internal.rich_live import pause_live
30
34
  from ask_shell.settings import AskShellSettings, _global_settings
31
35
 
@@ -48,29 +52,6 @@ def _default_asker(q: Question, _: type[T]) -> T:
48
52
  _question_asker: TypedAsk = _default_asker
49
53
 
50
54
 
51
- FuncT = TypeVar("FuncT", bound=Callable)
52
-
53
-
54
- def return_default_if_not_interactive(func: FuncT) -> FuncT:
55
- assert "default" in func_arg_names(func), f"Function {as_name(func)} must have a 'default' parameter"
56
-
57
- @wraps(func)
58
- def return_default(*args, **kwargs):
59
- if interactive_shell():
60
- return func(*args, **kwargs)
61
- default_value = kwargs.get("default", None)
62
- if default_value is None:
63
- raise ValueError(
64
- f"Function called in non-interactive shell, but no default value provided &func={as_name(func)}"
65
- )
66
- logger.warning(
67
- f"Function {as_name(func)} called in non-interactive shell, returning default value: {default_value}"
68
- )
69
- return default_value
70
-
71
- return return_default # type: ignore
72
-
73
-
74
55
  _PROMPT_TEXT_ATTR_NAME = "__prompt_text__"
75
56
 
76
57
 
@@ -78,16 +59,6 @@ def _set_prompt_text(q: Question, prompt_text: str) -> None:
78
59
  setattr(q, _PROMPT_TEXT_ATTR_NAME, prompt_text)
79
60
 
80
61
 
81
- @pause_live
82
- def confirm(prompt_text: str, *, default: bool | None = None) -> bool:
83
- if default is None:
84
- question = _confirm(prompt_text)
85
- else:
86
- question = _confirm(prompt_text, default=default)
87
- _set_prompt_text(question, prompt_text)
88
- return _question_asker(question, bool)
89
-
90
-
91
62
  _unset = object()
92
63
 
93
64
 
@@ -103,38 +74,78 @@ class NewHandlerChoice(Generic[T]):
103
74
  new_prompt: str
104
75
 
105
76
 
106
- @dataclass
107
- class ChoiceTyped(Generic[T]):
108
- name: str
109
- value: T
110
- description: str | None = None
111
- checked: bool = False
112
-
113
- @classmethod
114
- def from_descriptions(cls, descriptions: dict[str, str]) -> list[ChoiceTyped[str]]:
115
- return [
116
- cls(name=name, value=name, description=description) # type: ignore
117
- for name, description in descriptions.items()
118
- ] # type: ignore
119
-
120
- def as_choice(self) -> Choice:
121
- return Choice(
122
- title=self.name,
123
- value=self.value,
124
- description=self.description,
125
- checked=self.checked,
126
- )
77
+ def _ask_with_session(
78
+ *,
79
+ kind: PromptKind,
80
+ prompt: str,
81
+ choices: Sequence[ChoiceTyped],
82
+ has_usable_default: bool,
83
+ usable_default: Any,
84
+ ask_fn: Callable[[], T],
85
+ ) -> T:
86
+ if _question_asker is not _default_asker:
87
+ return ask_fn()
88
+ settings = AskShellSettings.from_env()
89
+ if not interactive_shell():
90
+ if settings.use_defaults and has_usable_default:
91
+ logger.warning(f"Ask called in non-interactive shell, returning default value: {usable_default}")
92
+ record_answered_row(
93
+ kind=kind,
94
+ prompt=prompt,
95
+ settings=settings,
96
+ choices=choices,
97
+ value=usable_default,
98
+ )
99
+ return usable_default
100
+ return replay_or_dump(kind=kind, prompt=prompt, settings=settings, choices=choices)
101
+ replayed = try_replay_answered(kind=kind, prompt=prompt, settings=settings, choices=choices)
102
+ if replayed is not None:
103
+ return replayed
104
+ value = ask_fn()
105
+ record_answered_row(kind=kind, prompt=prompt, settings=settings, choices=choices, value=value)
106
+ return value
107
+
108
+
109
+ @pause_live
110
+ def confirm(prompt_text: str, *, default: bool | None = None) -> bool:
111
+ def ask_fn() -> bool:
112
+ if default is None:
113
+ question = _confirm(prompt_text)
114
+ else:
115
+ question = _confirm(prompt_text, default=default)
116
+ _set_prompt_text(question, prompt_text)
117
+ return _question_asker(question, bool)
118
+
119
+ return _ask_with_session(
120
+ kind=PromptKind.CONFIRM,
121
+ prompt=prompt_text,
122
+ choices=(),
123
+ has_usable_default=default is not None,
124
+ usable_default=default,
125
+ ask_fn=ask_fn,
126
+ )
127
127
 
128
128
 
129
129
  @pause_live
130
- @return_default_if_not_interactive
131
130
  def text(
132
131
  prompt_text: str,
133
- default: str = "",
132
+ default: str | None = None,
134
133
  ) -> str:
135
- question = _text(prompt_text, default=default)
136
- _set_prompt_text(question, prompt_text)
137
- return _question_asker(question, str)
134
+ questionary_default = default if default is not None else ""
135
+
136
+ def ask_fn() -> str:
137
+ question = _text(prompt_text, default=questionary_default)
138
+ _set_prompt_text(question, prompt_text)
139
+ return _question_asker(question, str)
140
+
141
+ return _ask_with_session(
142
+ kind=PromptKind.TEXT,
143
+ prompt=prompt_text,
144
+ choices=(),
145
+ has_usable_default=default is not None,
146
+ usable_default=default,
147
+ ask_fn=ask_fn,
148
+ )
138
149
 
139
150
 
140
151
  class SelectOptions(BaseModel, Generic[T]):
@@ -194,7 +205,7 @@ class SelectOptions(BaseModel, Generic[T]):
194
205
  )
195
206
  _set_prompt_text(question, prompt_text)
196
207
  try:
197
- return _question_asker(question, T)
208
+ return _question_asker(question, T) # ty: ignore[invalid-argument-type]
198
209
  except KeyboardInterrupt:
199
210
  if not self.allow_new:
200
211
  raise
@@ -228,7 +239,6 @@ class SelectOptions(BaseModel, Generic[T]):
228
239
 
229
240
 
230
241
  @pause_live
231
- @return_default_if_not_interactive
232
242
  def select_list_multiple(
233
243
  prompt_text: str,
234
244
  choices: list[str],
@@ -238,13 +248,23 @@ def select_list_multiple(
238
248
  ) -> list[str]:
239
249
  assert choices, f"choices must not be empty for {as_name(select_list_multiple)}"
240
250
  options = options or SelectOptions()
241
- default = default or []
242
- default_choices = [ChoiceTyped(option, checked=option in default, value=option) for option in choices]
243
- return options._select_multiple(prompt_text, default_choices)
251
+ default_checked = default or []
252
+ typed_choices = [ChoiceTyped(option, checked=option in default_checked, value=option) for option in choices]
253
+
254
+ def ask_fn() -> list[str]:
255
+ return options._select_multiple(prompt_text, typed_choices)
256
+
257
+ return _ask_with_session(
258
+ kind=PromptKind.SELECT_MULTIPLE,
259
+ prompt=prompt_text,
260
+ choices=typed_choices,
261
+ has_usable_default=default is not None,
262
+ usable_default=default_checked,
263
+ ask_fn=ask_fn,
264
+ )
244
265
 
245
266
 
246
267
  @pause_live
247
- @return_default_if_not_interactive
248
268
  def select_list_multiple_choices(
249
269
  prompt_text: str,
250
270
  choices: list[ChoiceTyped[T]],
@@ -255,11 +275,21 @@ def select_list_multiple_choices(
255
275
  ) -> list[T]:
256
276
  assert choices, f"choices must not be empty for {as_name(select_list_multiple_choices)}"
257
277
  options = options or SelectOptions()
258
- return options._select_multiple(prompt_text, choices)
278
+
279
+ def ask_fn() -> list[T]:
280
+ return options._select_multiple(prompt_text, choices)
281
+
282
+ return _ask_with_session(
283
+ kind=PromptKind.SELECT_MULTIPLE,
284
+ prompt=prompt_text,
285
+ choices=choices,
286
+ has_usable_default=default is not None,
287
+ usable_default=default or [],
288
+ ask_fn=ask_fn,
289
+ )
259
290
 
260
291
 
261
292
  @pause_live
262
- @return_default_if_not_interactive
263
293
  def select_dict(
264
294
  prompt_text: str,
265
295
  choices: dict[str, T],
@@ -270,12 +300,23 @@ def select_dict(
270
300
  assert choices, f"choices must not be empty for {as_name(select_dict)}"
271
301
  options = options or SelectOptions()
272
302
  default_safe = default or ""
273
- choices_typed = [ChoiceTyped(name=key, value=value, checked=key == default_safe) for key, value in choices.items()]
274
- return options._select(prompt_text, choices_typed)
303
+ typed_choices = [ChoiceTyped(name=key, value=value, checked=key == default_safe) for key, value in choices.items()]
304
+ default_in_choices = default in choices if default is not None else False
305
+
306
+ def ask_fn() -> T:
307
+ return options._select(prompt_text, typed_choices)
308
+
309
+ return _ask_with_session(
310
+ kind=PromptKind.SELECT,
311
+ prompt=prompt_text,
312
+ choices=typed_choices,
313
+ has_usable_default=default_in_choices,
314
+ usable_default=choices[default] if default is not None and default in choices else None,
315
+ ask_fn=ask_fn,
316
+ )
275
317
 
276
318
 
277
319
  @pause_live
278
- @return_default_if_not_interactive
279
320
  def select_list(
280
321
  prompt_text: str,
281
322
  choices: list[str],
@@ -285,22 +326,34 @@ def select_list(
285
326
  ) -> str:
286
327
  assert choices, f"choices must not be empty for {as_name(select_list)}"
287
328
  options = options or SelectOptions()
288
- chosen = options.set_defaults(len(choices))
289
- return _question_asker(
290
- _select(
291
- prompt_text,
292
- default=default,
293
- choices=choices,
294
- use_jk_keys=chosen.use_jk_keys,
295
- use_shortcuts=chosen.use_shortcuts,
296
- use_search_filter=chosen.use_search_filter,
297
- ),
298
- str,
329
+ typed_choices = [ChoiceTyped(name=choice, value=choice) for choice in choices]
330
+ default_in_choices = default in choices if default is not None else False
331
+
332
+ def ask_fn() -> str:
333
+ chosen = options.set_defaults(len(choices))
334
+ return _question_asker(
335
+ _select(
336
+ prompt_text,
337
+ default=default,
338
+ choices=choices,
339
+ use_jk_keys=chosen.use_jk_keys,
340
+ use_shortcuts=chosen.use_shortcuts,
341
+ use_search_filter=chosen.use_search_filter,
342
+ ),
343
+ str,
344
+ )
345
+
346
+ return _ask_with_session(
347
+ kind=PromptKind.SELECT,
348
+ prompt=prompt_text,
349
+ choices=typed_choices,
350
+ has_usable_default=default_in_choices,
351
+ usable_default=default if default_in_choices else None,
352
+ ask_fn=ask_fn,
299
353
  )
300
354
 
301
355
 
302
356
  @pause_live
303
- @return_default_if_not_interactive
304
357
  def select_list_choice(
305
358
  prompt_text: str,
306
359
  choices: list[ChoiceTyped[T]],
@@ -310,7 +363,20 @@ def select_list_choice(
310
363
  ) -> T:
311
364
  assert choices, f"choices must not be empty for {as_name(select_list_choice)}"
312
365
  options = options or SelectOptions()
313
- return options._select(prompt_text, choices)
366
+ choice_values = {choice.value for choice in choices}
367
+ default_in_choices = default is not None and default in choice_values
368
+
369
+ def ask_fn() -> T:
370
+ return options._select(prompt_text, choices)
371
+
372
+ return _ask_with_session(
373
+ kind=PromptKind.SELECT,
374
+ prompt=prompt_text,
375
+ choices=choices,
376
+ has_usable_default=default_in_choices,
377
+ usable_default=default if default_in_choices else None,
378
+ ask_fn=ask_fn,
379
+ )
314
380
 
315
381
 
316
382
  class KeyInput:
@@ -365,9 +431,7 @@ class PromptMatch:
365
431
  return False
366
432
  if (substr := self.substring) and substr in prompt_text:
367
433
  return True
368
- if (exact := self.exact) and exact == prompt_text:
369
- return True
370
- return False
434
+ return bool((exact := self.exact) and exact == prompt_text)
371
435
 
372
436
  def __eq__(self, value: object) -> bool:
373
437
  if not isinstance(value, PromptMatch):
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from typing import Annotated, Generic, Literal, TypeVar
6
+
7
+ from model_lib import Entity
8
+ from pydantic import Field
9
+ from questionary import Choice
10
+
11
+ _UNDECIDED = "undecided"
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ class PromptKind(StrEnum):
17
+ CONFIRM = "confirm"
18
+ TEXT = "text"
19
+ SELECT = "select"
20
+ SELECT_MULTIPLE = "select_multiple"
21
+
22
+
23
+ class MultiSelectStatus(StrEnum):
24
+ UNDECIDED = "undecided"
25
+ ANSWERED = "answered"
26
+
27
+
28
+ class SelectAlternative(Entity):
29
+ name: str
30
+ description: str | None = None
31
+
32
+
33
+ class ConfirmQuestion(Entity):
34
+ kind: Literal[PromptKind.CONFIRM] = PromptKind.CONFIRM
35
+ prompt: str
36
+ response: Literal["undecided"] | bool = _UNDECIDED
37
+
38
+
39
+ class TextQuestion(Entity):
40
+ kind: Literal[PromptKind.TEXT] = PromptKind.TEXT
41
+ prompt: str
42
+ response: str = _UNDECIDED
43
+
44
+
45
+ class SelectQuestion(Entity):
46
+ kind: Literal[PromptKind.SELECT] = PromptKind.SELECT
47
+ prompt: str
48
+ alternatives: list[SelectAlternative] = Field(default_factory=list)
49
+ chosen: str | None = None
50
+
51
+
52
+ class SelectMultipleQuestion(Entity):
53
+ kind: Literal[PromptKind.SELECT_MULTIPLE] = PromptKind.SELECT_MULTIPLE
54
+ prompt: str
55
+ status: MultiSelectStatus = MultiSelectStatus.UNDECIDED
56
+ alternatives: list[SelectAlternative] = Field(default_factory=list)
57
+ checked: list[str] = Field(default_factory=list)
58
+
59
+
60
+ PromptQuestion = Annotated[
61
+ ConfirmQuestion | TextQuestion | SelectQuestion | SelectMultipleQuestion,
62
+ Field(discriminator="kind"),
63
+ ]
64
+
65
+
66
+ class NonInteractivePromptFile(Entity):
67
+ questions: list[PromptQuestion] = Field(default_factory=list)
68
+
69
+
70
+ @dataclass
71
+ class ChoiceTyped(Generic[T]):
72
+ name: str
73
+ value: T
74
+ description: str | None = None
75
+ checked: bool = False
76
+
77
+ @classmethod
78
+ def from_descriptions(cls, descriptions: dict[str, str]) -> list[ChoiceTyped[str]]:
79
+ return [cls(name=name, value=name, description=description) for name, description in descriptions.items()] # type: ignore
80
+
81
+ def as_choice(self) -> Choice:
82
+ return Choice(
83
+ title=self.name,
84
+ value=self.value,
85
+ description=self.description,
86
+ checked=self.checked,
87
+ )
@@ -342,9 +342,8 @@ class ShellRun:
342
342
  return False
343
343
 
344
344
  def remove_callback():
345
- with self._lock:
346
- with suppress(ValueError): # if the callback was already removed
347
- self.config.message_callbacks.remove(only_on_output_callback)
345
+ with self._lock, suppress(ValueError): # if the callback was already removed
346
+ self.config.message_callbacks.remove(only_on_output_callback)
348
347
 
349
348
  with self._lock:
350
349
  self.config.message_callbacks.append(only_on_output_callback)
@@ -362,7 +361,7 @@ class ShellRun:
362
361
  try:
363
362
  if callback(message):
364
363
  self.config.message_callbacks.remove(callback)
365
- except Exception as e:
364
+ except (TypeError, ValueError, RuntimeError, OSError, AttributeError) as e:
366
365
  logger.error(f"Error in message callback: {e!r}")
367
366
 
368
367
  def _dump_html_logs(self):
@@ -410,7 +409,7 @@ class ShellRun:
410
409
  if no_raise:
411
410
  try:
412
411
  return self._complete_flag.result(timeout)
413
- except Exception:
412
+ except Exception: # noqa: BLE001
414
413
  return self
415
414
  return self._complete_flag.result(timeout)
416
415