bear-utils 0.7.11__py3-none-any.whl

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 (83) hide show
  1. bear_utils/__init__.py +13 -0
  2. bear_utils/ai/__init__.py +30 -0
  3. bear_utils/ai/ai_helpers/__init__.py +130 -0
  4. bear_utils/ai/ai_helpers/_common.py +19 -0
  5. bear_utils/ai/ai_helpers/_config.py +24 -0
  6. bear_utils/ai/ai_helpers/_parsers.py +188 -0
  7. bear_utils/ai/ai_helpers/_types.py +20 -0
  8. bear_utils/cache/__init__.py +119 -0
  9. bear_utils/cli/__init__.py +4 -0
  10. bear_utils/cli/commands.py +59 -0
  11. bear_utils/cli/prompt_helpers.py +166 -0
  12. bear_utils/cli/shell/__init__.py +0 -0
  13. bear_utils/cli/shell/_base_command.py +74 -0
  14. bear_utils/cli/shell/_base_shell.py +390 -0
  15. bear_utils/cli/shell/_common.py +19 -0
  16. bear_utils/config/__init__.py +11 -0
  17. bear_utils/config/config_manager.py +92 -0
  18. bear_utils/config/dir_manager.py +64 -0
  19. bear_utils/config/settings_manager.py +232 -0
  20. bear_utils/constants/__init__.py +16 -0
  21. bear_utils/constants/_exceptions.py +3 -0
  22. bear_utils/constants/_lazy_typing.py +15 -0
  23. bear_utils/constants/date_related.py +36 -0
  24. bear_utils/constants/time_related.py +22 -0
  25. bear_utils/database/__init__.py +6 -0
  26. bear_utils/database/_db_manager.py +104 -0
  27. bear_utils/events/__init__.py +16 -0
  28. bear_utils/events/events_class.py +52 -0
  29. bear_utils/events/events_module.py +65 -0
  30. bear_utils/extras/__init__.py +17 -0
  31. bear_utils/extras/_async_helpers.py +15 -0
  32. bear_utils/extras/_tools.py +178 -0
  33. bear_utils/extras/platform_utils.py +53 -0
  34. bear_utils/extras/wrappers/__init__.py +0 -0
  35. bear_utils/extras/wrappers/add_methods.py +98 -0
  36. bear_utils/files/__init__.py +4 -0
  37. bear_utils/files/file_handlers/__init__.py +3 -0
  38. bear_utils/files/file_handlers/_base_file_handler.py +93 -0
  39. bear_utils/files/file_handlers/file_handler_factory.py +278 -0
  40. bear_utils/files/file_handlers/json_file_handler.py +44 -0
  41. bear_utils/files/file_handlers/log_file_handler.py +33 -0
  42. bear_utils/files/file_handlers/txt_file_handler.py +34 -0
  43. bear_utils/files/file_handlers/yaml_file_handler.py +57 -0
  44. bear_utils/files/ignore_parser.py +298 -0
  45. bear_utils/graphics/__init__.py +4 -0
  46. bear_utils/graphics/bear_gradient.py +140 -0
  47. bear_utils/graphics/image_helpers.py +39 -0
  48. bear_utils/gui/__init__.py +3 -0
  49. bear_utils/gui/gui_tools/__init__.py +5 -0
  50. bear_utils/gui/gui_tools/_settings.py +37 -0
  51. bear_utils/gui/gui_tools/_types.py +12 -0
  52. bear_utils/gui/gui_tools/qt_app.py +145 -0
  53. bear_utils/gui/gui_tools/qt_color_picker.py +119 -0
  54. bear_utils/gui/gui_tools/qt_file_handler.py +138 -0
  55. bear_utils/gui/gui_tools/qt_input_dialog.py +306 -0
  56. bear_utils/logging/__init__.py +25 -0
  57. bear_utils/logging/logger_manager/__init__.py +0 -0
  58. bear_utils/logging/logger_manager/_common.py +47 -0
  59. bear_utils/logging/logger_manager/_console_junk.py +131 -0
  60. bear_utils/logging/logger_manager/_styles.py +91 -0
  61. bear_utils/logging/logger_manager/loggers/__init__.py +0 -0
  62. bear_utils/logging/logger_manager/loggers/_base_logger.py +238 -0
  63. bear_utils/logging/logger_manager/loggers/_base_logger.pyi +50 -0
  64. bear_utils/logging/logger_manager/loggers/_buffer_logger.py +55 -0
  65. bear_utils/logging/logger_manager/loggers/_console_logger.py +249 -0
  66. bear_utils/logging/logger_manager/loggers/_console_logger.pyi +64 -0
  67. bear_utils/logging/logger_manager/loggers/_file_logger.py +141 -0
  68. bear_utils/logging/logger_manager/loggers/_level_sin.py +58 -0
  69. bear_utils/logging/logger_manager/loggers/_logger.py +18 -0
  70. bear_utils/logging/logger_manager/loggers/_sub_logger.py +110 -0
  71. bear_utils/logging/logger_manager/loggers/_sub_logger.pyi +38 -0
  72. bear_utils/logging/loggers.py +76 -0
  73. bear_utils/monitoring/__init__.py +10 -0
  74. bear_utils/monitoring/host_monitor.py +350 -0
  75. bear_utils/time/__init__.py +16 -0
  76. bear_utils/time/_helpers.py +91 -0
  77. bear_utils/time/_time_class.py +316 -0
  78. bear_utils/time/_timer.py +80 -0
  79. bear_utils/time/_tools.py +17 -0
  80. bear_utils/time/time_manager.py +218 -0
  81. bear_utils-0.7.11.dist-info/METADATA +260 -0
  82. bear_utils-0.7.11.dist-info/RECORD +83 -0
  83. bear_utils-0.7.11.dist-info/WHEEL +4 -0
@@ -0,0 +1,166 @@
1
+ from typing import Any, overload
2
+
3
+ from prompt_toolkit import prompt
4
+ from prompt_toolkit.completion import WordCompleter
5
+ from prompt_toolkit.validation import ValidationError, Validator
6
+
7
+ from ..constants._exceptions import UserCancelled
8
+ from ..constants._lazy_typing import LitBool, LitFloat, LitInt, LitStr, OptBool, OptFloat, OptInt, OptStr
9
+ from ..logging.loggers import get_console
10
+
11
+
12
+ @overload
13
+ def ask_question(question: str, expected_type: LitInt, default: OptInt = None, **kwargs) -> int: ...
14
+
15
+
16
+ @overload
17
+ def ask_question(question: str, expected_type: LitFloat, default: OptFloat = None, **kwargs) -> float: ...
18
+
19
+
20
+ @overload
21
+ def ask_question(question: str, expected_type: LitStr, default: OptStr = None, **kwargs) -> str: ...
22
+
23
+
24
+ @overload
25
+ def ask_question(question: str, expected_type: LitBool, default: OptBool = None, **kwargs) -> bool: ...
26
+
27
+
28
+ def ask_question(question: str, expected_type: Any, default: Any = None, **kwargs) -> Any:
29
+ """
30
+ Ask a question and return the answer, ensuring the entered type is correct and a value is entered.
31
+
32
+ This function will keep asking until it gets a valid response or the user cancels with Ctrl+C.
33
+ If the user cancels, a UserCancelled is raised.
34
+
35
+ Args:
36
+ question: The prompt question to display
37
+ expected_type: The expected type of the answer (int, float, str, bool)
38
+ default: Default value if no input is provided
39
+
40
+ Returns:
41
+ The user's response in the expected type
42
+
43
+ Raises:
44
+ UserCancelled: If the user cancels input with Ctrl+C
45
+ ValueError: If an unsupported type is specified
46
+ """
47
+ console, sub = get_console("prompt_helpers.py")
48
+ try:
49
+ while True:
50
+ console.info(question)
51
+ response: str = prompt("> ")
52
+ if response == "":
53
+ if default is not None:
54
+ return default
55
+ else:
56
+ continue
57
+ match expected_type:
58
+ case "int":
59
+ try:
60
+ result = int(response)
61
+ sub.verbose("int detected")
62
+ return result
63
+ except ValueError:
64
+ sub.error("Invalid input. Please enter a valid integer.")
65
+ case "float":
66
+ try:
67
+ result = float(response)
68
+ sub.verbose("float detected")
69
+ return result
70
+ except ValueError:
71
+ sub.error("Invalid input. Please enter a valid float.")
72
+ case "str":
73
+ sub.verbose("str detected")
74
+ return response
75
+ case "bool":
76
+ lower_response = response.lower()
77
+ if lower_response in ("true", "t", "yes", "y", "1"):
78
+ return True
79
+ elif lower_response in ("false", "f", "no", "n", "0"):
80
+ return False
81
+ else:
82
+ sub.error("Invalid input. Please enter a valid boolean (true/false, yes/no, etc).")
83
+ case _:
84
+ raise ValueError(f"Unsupported type: {expected_type}")
85
+ except KeyboardInterrupt:
86
+ raise UserCancelled("User cancelled input")
87
+
88
+
89
+ def ask_yes_no(question, default=None, **kwargs) -> None | bool:
90
+ """
91
+ Ask a yes or no question and return the answer.
92
+
93
+ Args:
94
+ question: The prompt question to display
95
+ default: Default value if no input is provided
96
+
97
+ Returns:
98
+ True for yes, False for no, or None if no valid response is given
99
+ """
100
+ sub, console = get_console("prompt_helpers.py")
101
+ try:
102
+ while True:
103
+ console.info(question)
104
+ response = prompt("> ")
105
+
106
+ if response == "":
107
+ if default is not None:
108
+ return default
109
+ else:
110
+ continue
111
+
112
+ if response.lower() in ["yes", "y"]:
113
+ return True
114
+ elif response.lower() in ["no", "n"]:
115
+ return False
116
+ elif response.lower() in ["exit", "quit"]:
117
+ return None
118
+ else:
119
+ console.error("Invalid input. Please enter 'yes' or 'no' or exit.")
120
+ continue
121
+ except KeyboardInterrupt:
122
+ console.warning("KeyboardInterrupt: Exiting the prompt.")
123
+ return None
124
+
125
+
126
+ def restricted_prompt(question, valid_options, exit_command="exit", **kwargs):
127
+ """
128
+ Continuously prompt the user until they provide a valid response or exit.
129
+
130
+ Args:
131
+ question: The prompt question to display
132
+ valid_options: List of valid responses
133
+ exit_command: Command to exit the prompt (default: "exit")
134
+
135
+ Returns:
136
+ The user's response or None if they chose to exit
137
+ """
138
+ sub, console = get_console("prompt_helpers.py")
139
+ completer_options = valid_options + [exit_command]
140
+ completer = WordCompleter(completer_options)
141
+
142
+ class OptionValidator(Validator):
143
+ def validate(self, document):
144
+ text = document.text.lower()
145
+ if text != exit_command and text not in valid_options:
146
+ raise ValidationError(f'Please enter one of: {", ".join(valid_options)} (or "{exit_command}" to quit)') # type: ignore
147
+
148
+ try:
149
+ while True:
150
+ if console is not None:
151
+ console.info(question)
152
+ response = prompt("> ", completer=completer, validator=OptionValidator(), complete_while_typing=True)
153
+ response = response.lower()
154
+ else:
155
+ response = prompt(
156
+ question, completer=completer, validator=OptionValidator(), complete_while_typing=True
157
+ )
158
+ response = response.lower()
159
+
160
+ if response == exit_command or response == "":
161
+ return None
162
+ elif response in valid_options:
163
+ return response
164
+ except KeyboardInterrupt:
165
+ sub.warning("KeyboardInterrupt: Exiting the prompt.")
166
+ return None
File without changes
@@ -0,0 +1,74 @@
1
+ from typing import ClassVar, Generic, Self, TypeVar
2
+
3
+ T = TypeVar("T", bound=str)
4
+
5
+
6
+ class BaseShellCommand(Generic[T]):
7
+ """Base class for typed shell commands compatible with session systems"""
8
+
9
+ command_name: ClassVar[str] = ""
10
+
11
+ def __init__(self, *args, **kwargs):
12
+ self.sub_command: str = kwargs.get("sub_command", "")
13
+ self.args = args
14
+ self.kwargs = kwargs
15
+ self.suffix = kwargs.get("suffix", "")
16
+ self.result = None
17
+
18
+ def __str__(self) -> str:
19
+ """String representation of the command"""
20
+ return self.cmd
21
+
22
+ def value(self, v: str) -> Self:
23
+ """Add value to the export command"""
24
+ self.suffix: str = v
25
+ return self
26
+
27
+ @classmethod
28
+ def adhoc(cls, name: T, *args, **kwargs) -> "BaseShellCommand[T]":
29
+ """
30
+ Create an ad-hoc command class for a specific command
31
+
32
+ Args:
33
+ name (str): The name of the command to create
34
+
35
+ Returns:
36
+ BaseShellCommand: An instance of the ad-hoc command class.
37
+ """
38
+ AdHocCommand = type(
39
+ f"AdHoc{name.title()}Command",
40
+ (cls,),
41
+ {"command_name": name},
42
+ )
43
+ return AdHocCommand(*args, **kwargs)
44
+
45
+ @classmethod
46
+ def sub(cls, s: str, *args, **kwargs) -> Self:
47
+ """Set a sub-command for the shell command"""
48
+ return cls(sub_command=s, *args, **kwargs)
49
+
50
+ @property
51
+ def cmd(self) -> str:
52
+ """Return the full command as a string"""
53
+ cmd_parts = [self.command_name, self.sub_command] + list(self.args)
54
+ joined = " ".join(cmd_parts).strip()
55
+ if self.suffix:
56
+ return f"{joined} {self.suffix}"
57
+ return joined
58
+
59
+ def do(self) -> Self:
60
+ """Run the command using subprocess"""
61
+ from ._base_shell import shell_session
62
+
63
+ with shell_session() as session:
64
+ session.add(str(self))
65
+ self.result = session.run()
66
+ return self
67
+
68
+ def get(self) -> str:
69
+ """Get the result of the command execution"""
70
+ if self.result is None:
71
+ self.do()
72
+ if self.result is None:
73
+ raise RuntimeError("Command execution failed for some reason.")
74
+ return str(self.result.stdout).strip()
@@ -0,0 +1,390 @@
1
+ import asyncio
2
+ import os
3
+ import subprocess
4
+ from asyncio.streams import StreamReader
5
+ from asyncio.subprocess import Process
6
+ from collections import deque
7
+ from collections.abc import AsyncGenerator, Callable, Generator
8
+ from contextlib import asynccontextmanager, contextmanager
9
+ from io import StringIO
10
+ from logging import INFO
11
+ from pathlib import Path
12
+ from subprocess import CompletedProcess
13
+ from typing import Self, override
14
+
15
+ from ._base_command import BaseShellCommand
16
+ from ._common import DEFAULT_SHELL
17
+
18
+ EXIT_CODES: dict[int, str] = {
19
+ 0: "Success",
20
+ 1: "General error",
21
+ 2: "Misuse of shell command",
22
+ 126: "Command invoked cannot execute",
23
+ 127: "Command not found",
24
+ 128: "Invalid argument to exit",
25
+ 130: "Script terminated by Control-C",
26
+ 137: "Process killed by SIGKILL (9)",
27
+ 139: "Segmentation fault (core dumped)",
28
+ 143: "Process terminated by SIGTERM (15)",
29
+ 255: "Exit status out of range",
30
+ }
31
+
32
+
33
+ class FancyCompletedProcess(CompletedProcess[str]):
34
+ def __init__(self, args, returncode, stdout=None, stderr=None):
35
+ super().__init__(args=args, returncode=returncode, stdout=stdout, stderr=stderr)
36
+
37
+ def __repr__(self):
38
+ args = [
39
+ f"args={self.args!r}",
40
+ f"returncode={self.returncode!r}",
41
+ f"exit_message={self.exit_message!r}",
42
+ f"stdout={self.stdout!r}" if self.stdout is not None else "",
43
+ f"stderr={self.stderr!r}" if self.stderr is not None else "",
44
+ ]
45
+ return f"{type(self).__name__}({', '.join(filter(None, args))})"
46
+
47
+ @property
48
+ def exit_message(self) -> str:
49
+ """Get a human-readable message for the exit code"""
50
+ return EXIT_CODES.get(self.returncode, EXIT_CODES.get(1, "Unknown error"))
51
+
52
+
53
+ class CommandList(deque[CompletedProcess[str]]):
54
+ """A list to hold previous commands with their timestamps and results"""
55
+
56
+ def __init__(self, maxlen=10, *args, **kwargs):
57
+ super().__init__(maxlen=maxlen, *args, **kwargs)
58
+
59
+ def add(self, command: CompletedProcess[str]) -> None:
60
+ """Add a command to the list"""
61
+ self.append(command)
62
+
63
+ def get(self, index: int) -> CompletedProcess[str] | None:
64
+ """Get a command by index"""
65
+ return self[index] if 0 <= index < len(self) else None
66
+
67
+ def get_most_recent(self) -> CompletedProcess[str] | None:
68
+ """Get the most recent command"""
69
+ return self[-1] if self else None
70
+
71
+
72
+ class SimpleShellSession:
73
+ """Simple shell session using subprocess with command chaining"""
74
+
75
+ def __init__(self, env=None, cwd=None, shell: str = DEFAULT_SHELL, logger=None, verbose: bool = False):
76
+ self.shell: str = shell
77
+ self.cwd: Path = Path.cwd() if cwd is None else Path(cwd)
78
+ self.env: dict[str, str] = os.environ.copy() if env is None else env
79
+ self.cmd_buffer: StringIO = StringIO()
80
+ self.previous_commands: CommandList = CommandList()
81
+ self.result: CompletedProcess[str] | None = None
82
+ self.verbose: bool = verbose
83
+ self.logger = self.set_logger(logger)
84
+
85
+ def set_logger(self, passed_logger=None):
86
+ """Set the logger for the session, defaulting to a base logger if none is provided"""
87
+ from ...logging.loggers import VERBOSE, BaseLogger, SubConsoleLogger
88
+
89
+ if passed_logger is not None:
90
+ return passed_logger
91
+
92
+ if BaseLogger.has_instance():
93
+ logger = BaseLogger.get_instance().get_sub_logger(namespace="shell_session")
94
+ else:
95
+ temp = BaseLogger.get_instance(init=True)
96
+ logger: SubConsoleLogger[BaseLogger] = temp.get_sub_logger(namespace="shell_session")
97
+ if self.verbose:
98
+ logger.set_sub_level(VERBOSE)
99
+ else:
100
+ logger.set_sub_level(INFO)
101
+ return logger
102
+
103
+ def add_to_env(self, env: dict[str, str], key: str | None = None, value=None) -> Self:
104
+ """Populate the environment for the session"""
105
+ _env = {}
106
+ if isinstance(env, str) and key is not None and value is not None:
107
+ _env[key] = value
108
+ elif isinstance(env, dict):
109
+ for k, v in env.items():
110
+ _env[k] = v
111
+ self.env.update(_env)
112
+ return self
113
+
114
+ def add(self, c: str | BaseShellCommand) -> Self:
115
+ """Add a command to the current session, return self for chaining"""
116
+ self.cmd_buffer.write(str(c))
117
+ return self
118
+
119
+ def amp(self, c: str | BaseShellCommand) -> Self:
120
+ """Combine a command with the current session: &&, return self for chaining"""
121
+ if self.empty_history:
122
+ raise ValueError("No command to combine with")
123
+ self.cmd_buffer.write(" && ")
124
+ self.cmd_buffer.write(str(c))
125
+ return self
126
+
127
+ def piped(self, c: str | BaseShellCommand) -> Self:
128
+ """Combine a command with the current session: |, return self for chaining"""
129
+ if self.empty_history:
130
+ raise ValueError("No command to pipe from")
131
+ self.cmd_buffer.write(" | ")
132
+ self.cmd_buffer.write(str(c))
133
+ return self
134
+
135
+ def _run(self, command: str) -> CompletedProcess[str] | Process:
136
+ """Internal method to run the accumulated command"""
137
+ self.logger.verbose(f"Executing: {command}")
138
+ self.next_cmd()
139
+ self.result = subprocess.run(
140
+ command,
141
+ shell=True,
142
+ cwd=self.cwd,
143
+ env=self.env,
144
+ capture_output=True,
145
+ text=True,
146
+ )
147
+ if self.result.returncode != 0:
148
+ self.logger.error(f"Command failed with return code {self.result.returncode} {self.result.stderr.strip()}")
149
+
150
+ self.reset_buffer()
151
+ return self.result
152
+
153
+ def run(self, cmd: str | BaseShellCommand | None = None, *args) -> CompletedProcess[str] | Process:
154
+ """Run the accumulated command history"""
155
+ if self.empty_history and cmd is None:
156
+ raise ValueError("No commands to run")
157
+
158
+ if self.has_history and cmd is not None:
159
+ raise ValueError(
160
+ "If you want to add a command to a chain, use `amp` instead of `run`, `run` is for executing the full command history"
161
+ )
162
+
163
+ if self.has_history and cmd is None:
164
+ result = self._run(self.cmd)
165
+ elif self.empty_history and cmd is not None:
166
+ self.cmd_buffer.write(f"{cmd} ")
167
+ if args:
168
+ self.cmd_buffer.write(" ".join(map(str, args)))
169
+ result = self._run(self.cmd)
170
+ else:
171
+ raise ValueError("Unexpected state")
172
+ self.reset_buffer()
173
+ return result
174
+
175
+ @property
176
+ def empty_history(self) -> bool:
177
+ """Check if the command history is empty"""
178
+ return not self.cmd_buffer.getvalue()
179
+
180
+ @property
181
+ def has_history(self) -> bool:
182
+ """Check if there is any command in the history"""
183
+ return not self.empty_history
184
+
185
+ @property
186
+ def cmd(self) -> str:
187
+ """Return the combined command as a string"""
188
+ if not self.cmd_buffer:
189
+ raise ValueError("No commands have been run yet")
190
+ full_command: str = f'{self.shell} -c "{self.cmd_buffer.getvalue()}"'
191
+ return full_command
192
+
193
+ @property
194
+ def returncode(self) -> bool:
195
+ """Return the last command's return code"""
196
+ if self.result is None:
197
+ raise ValueError("No command has been run yet")
198
+ return self.result.returncode == 0
199
+
200
+ @property
201
+ def stdout(self) -> str:
202
+ """Return the standard output of the last command"""
203
+ if self.result is None:
204
+ raise ValueError("No command has been run yet")
205
+ return self.result.stdout.strip() if self.result.stdout is not None else "None"
206
+
207
+ @property
208
+ def stderr(self) -> str:
209
+ """Return the standard error of the last command"""
210
+ if self.result is None:
211
+ raise ValueError("No command has been run yet")
212
+ return self.result.stderr.strip() if self.result.stderr is not None else "None"
213
+
214
+ @property
215
+ def pretty_result(self) -> str:
216
+ """Return a formatted string of the command result"""
217
+ if self.result is None:
218
+ raise ValueError("No command has been run yet")
219
+ return (
220
+ f"Command: {self.result.args}\n"
221
+ f"Return Code: {self.result.returncode}\n"
222
+ f"Standard Output: {self.result.stdout.strip()}\n"
223
+ f"Standard Error: {self.result.stderr.strip()}\n"
224
+ )
225
+
226
+ def reset_buffer(self) -> None:
227
+ """Reset the command buffer"""
228
+ self.cmd_buffer.seek(0)
229
+ self.cmd_buffer.truncate(0)
230
+
231
+ def reset(self) -> None:
232
+ """Reset the session state"""
233
+ self.previous_commands.clear()
234
+ self.result = None
235
+
236
+ def next_cmd(self) -> None:
237
+ """Store the current command in the history before running a new one"""
238
+ if self.result is not None:
239
+ self.previous_commands.add(command=self.result)
240
+ self.result = None
241
+
242
+ def get_cmd(self, index: int | None = None) -> CompletedProcess[str] | None:
243
+ """Get a previous command by index or the most recent one if index is None"""
244
+ if index is None:
245
+ return self.previous_commands.get_most_recent()
246
+ return self.previous_commands.get(index)
247
+
248
+ def __enter__(self) -> Self:
249
+ """Enter the context manager"""
250
+ return self
251
+
252
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
253
+ """Exit the context manager"""
254
+ self.reset()
255
+ self.reset_buffer()
256
+
257
+
258
+ class AsyncShellSession(SimpleShellSession):
259
+ """Shell session using Popen for more control over the subprocess"""
260
+
261
+ def __init__(self, env=None, cwd=None, shell: str = DEFAULT_SHELL, logger=None, verbose: bool = False):
262
+ super().__init__(env=env, cwd=cwd, shell=shell, logger=logger, verbose=verbose)
263
+ self.process: Process | None = None
264
+ self._callbacks: list[Callable[[CompletedProcess], None]] = []
265
+
266
+ @override
267
+ async def _run(self, command: str, **kwargs) -> Process: # type: ignore[override]
268
+ """Run the command using Popen for better control"""
269
+ self.logger.verbose(f"Executing: {command}")
270
+ self.next_cmd()
271
+ self.process = await asyncio.create_subprocess_shell(
272
+ command,
273
+ stdout=asyncio.subprocess.PIPE,
274
+ stderr=asyncio.subprocess.PIPE,
275
+ cwd=self.cwd,
276
+ env=self.env,
277
+ **kwargs,
278
+ )
279
+ return self.process
280
+
281
+ @override
282
+ async def run(self, cmd: str | BaseShellCommand | None = None, *args, **kwargs) -> Process: # type: ignore[override]
283
+ """Async version of run that returns Process for streaming"""
284
+ if self.empty_history and cmd is None:
285
+ raise ValueError("No commands to run")
286
+
287
+ self.logger.debug(f"Running command: {cmd} with args: {args}")
288
+ if self.has_history and cmd is not None:
289
+ raise ValueError("Use `amp` to chain commands, not `run`")
290
+ if self.has_history and cmd is None:
291
+ command = self.cmd
292
+ elif self.empty_history and cmd is not None:
293
+ self.cmd_buffer.write(f"{cmd}")
294
+ self.logger.info(f"{self.cmd_buffer.getvalue()}")
295
+ if args:
296
+ self.cmd_buffer.write(" ".join(map(str, args)))
297
+
298
+ command = self.cmd
299
+ else:
300
+ raise ValueError("Unexpected state")
301
+ process: Process = await self._run(command, **kwargs)
302
+ return process
303
+
304
+ async def communicate(self, stdin: str = "") -> CompletedProcess[str]:
305
+ """Communicate with the process, sending input and waiting for completion"""
306
+ if self.process is None:
307
+ raise ValueError("No process has been started yet")
308
+ stdout, stderr = await self.process.communicate(input=stdin.encode("utf-8"))
309
+ return_code = await self.process.wait()
310
+
311
+ self.result = FancyCompletedProcess(
312
+ args=self.cmd,
313
+ returncode=return_code,
314
+ stdout=stdout.decode() if stdout else "",
315
+ stderr=stderr.decode() if stderr else "",
316
+ )
317
+
318
+ if return_code != 0:
319
+ self.logger.error(f"Command failed with return code {return_code} {stderr.strip()}")
320
+
321
+ for callback in self._callbacks:
322
+ callback(self.result)
323
+
324
+ await self.after_process()
325
+ return self.result
326
+
327
+ @staticmethod
328
+ async def read_stream(stream: StreamReader) -> AsyncGenerator[str, None]:
329
+ while True:
330
+ try:
331
+ line: bytes = await stream.readline()
332
+ if not line: # EOF
333
+ break
334
+ yield line.decode("utf-8").rstrip("\n")
335
+ except Exception as e:
336
+ break
337
+
338
+ async def stream_stdout(self) -> AsyncGenerator[str, None]:
339
+ """Stream output line by line as it comes"""
340
+ if self.process is None:
341
+ raise ValueError("No process has been started yet")
342
+ if not self.process.stdout:
343
+ raise ValueError("Process has no stdout")
344
+
345
+ async for line in self.read_stream(self.process.stdout):
346
+ yield line
347
+
348
+ async def stream_stderr(self) -> AsyncGenerator[str, None]:
349
+ """Stream error output line by line as it comes"""
350
+ if self.process is None:
351
+ raise ValueError("No process has been started yet")
352
+ if not self.process.stderr:
353
+ raise ValueError("Process has no stderr")
354
+ async for line in self.read_stream(self.process.stderr):
355
+ yield line
356
+
357
+ async def after_process(self):
358
+ """Run after process completion, can be overridden for custom behavior"""
359
+ self.process = None
360
+ self._callbacks.clear()
361
+ self.reset_buffer()
362
+
363
+ def on_completion(self, callback):
364
+ """Add callback for when process completes"""
365
+ self._callbacks.append(callback)
366
+
367
+ @property
368
+ def is_running(self) -> bool:
369
+ """Check if process is still running"""
370
+ return self.process is not None and self.process.returncode is None
371
+
372
+
373
+ @contextmanager
374
+ def shell_session(shell: str = DEFAULT_SHELL, **kwargs) -> Generator[SimpleShellSession, None, None]:
375
+ """Context manager for simple shell sessions"""
376
+ session = SimpleShellSession(shell=shell, **kwargs)
377
+ try:
378
+ yield session
379
+ finally:
380
+ pass
381
+
382
+
383
+ @asynccontextmanager
384
+ async def async_shell_session(shell: str = DEFAULT_SHELL, **kwargs) -> AsyncGenerator[AsyncShellSession, None]:
385
+ """Asynchronous context manager for shell sessions"""
386
+ session = AsyncShellSession(shell=shell, **kwargs)
387
+ try:
388
+ yield session
389
+ finally:
390
+ pass
@@ -0,0 +1,19 @@
1
+ """Shared shell utilities.
2
+
3
+ ``DEFAULT_SHELL`` attempts to locate ``zsh`` on the host using :func:`shutil.which`.
4
+ If that fails, ``/bin/sh`` is used instead.
5
+ """
6
+
7
+ from shutil import which
8
+
9
+ DEFAULT_SHELL: str = which("zsh") or which("bash") or "/bin/sh"
10
+ """Dynamically detected shell path, falling back to ``/bin/sh``."""
11
+
12
+ BASH: str | None = which("bash")
13
+ """Path to the Bash shell, falling back to ``/bin/bash`` if not found."""
14
+
15
+ ZSH: str | None = which("zsh") or which("/bin/zsh")
16
+ """Path to the Zsh shell, falling back to ``/bin/zsh`` if not found."""
17
+
18
+ SH: str | None = which("sh") or "bin/sh"
19
+ """Path to the Bourne shell, falling back to ``/bin/sh`` if not found."""
@@ -0,0 +1,11 @@
1
+ from .config_manager import ConfigManager
2
+ from .dir_manager import DirectoryManager
3
+ from .settings_manager import SettingsManager, get_settings_manager, settings
4
+
5
+ __all__ = [
6
+ "ConfigManager",
7
+ "SettingsManager",
8
+ "settings",
9
+ "get_settings_manager",
10
+ "DirectoryManager",
11
+ ]