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.
- bear_utils/__init__.py +13 -0
- bear_utils/ai/__init__.py +30 -0
- bear_utils/ai/ai_helpers/__init__.py +130 -0
- bear_utils/ai/ai_helpers/_common.py +19 -0
- bear_utils/ai/ai_helpers/_config.py +24 -0
- bear_utils/ai/ai_helpers/_parsers.py +188 -0
- bear_utils/ai/ai_helpers/_types.py +20 -0
- bear_utils/cache/__init__.py +119 -0
- bear_utils/cli/__init__.py +4 -0
- bear_utils/cli/commands.py +59 -0
- bear_utils/cli/prompt_helpers.py +166 -0
- bear_utils/cli/shell/__init__.py +0 -0
- bear_utils/cli/shell/_base_command.py +74 -0
- bear_utils/cli/shell/_base_shell.py +390 -0
- bear_utils/cli/shell/_common.py +19 -0
- bear_utils/config/__init__.py +11 -0
- bear_utils/config/config_manager.py +92 -0
- bear_utils/config/dir_manager.py +64 -0
- bear_utils/config/settings_manager.py +232 -0
- bear_utils/constants/__init__.py +16 -0
- bear_utils/constants/_exceptions.py +3 -0
- bear_utils/constants/_lazy_typing.py +15 -0
- bear_utils/constants/date_related.py +36 -0
- bear_utils/constants/time_related.py +22 -0
- bear_utils/database/__init__.py +6 -0
- bear_utils/database/_db_manager.py +104 -0
- bear_utils/events/__init__.py +16 -0
- bear_utils/events/events_class.py +52 -0
- bear_utils/events/events_module.py +65 -0
- bear_utils/extras/__init__.py +17 -0
- bear_utils/extras/_async_helpers.py +15 -0
- bear_utils/extras/_tools.py +178 -0
- bear_utils/extras/platform_utils.py +53 -0
- bear_utils/extras/wrappers/__init__.py +0 -0
- bear_utils/extras/wrappers/add_methods.py +98 -0
- bear_utils/files/__init__.py +4 -0
- bear_utils/files/file_handlers/__init__.py +3 -0
- bear_utils/files/file_handlers/_base_file_handler.py +93 -0
- bear_utils/files/file_handlers/file_handler_factory.py +278 -0
- bear_utils/files/file_handlers/json_file_handler.py +44 -0
- bear_utils/files/file_handlers/log_file_handler.py +33 -0
- bear_utils/files/file_handlers/txt_file_handler.py +34 -0
- bear_utils/files/file_handlers/yaml_file_handler.py +57 -0
- bear_utils/files/ignore_parser.py +298 -0
- bear_utils/graphics/__init__.py +4 -0
- bear_utils/graphics/bear_gradient.py +140 -0
- bear_utils/graphics/image_helpers.py +39 -0
- bear_utils/gui/__init__.py +3 -0
- bear_utils/gui/gui_tools/__init__.py +5 -0
- bear_utils/gui/gui_tools/_settings.py +37 -0
- bear_utils/gui/gui_tools/_types.py +12 -0
- bear_utils/gui/gui_tools/qt_app.py +145 -0
- bear_utils/gui/gui_tools/qt_color_picker.py +119 -0
- bear_utils/gui/gui_tools/qt_file_handler.py +138 -0
- bear_utils/gui/gui_tools/qt_input_dialog.py +306 -0
- bear_utils/logging/__init__.py +25 -0
- bear_utils/logging/logger_manager/__init__.py +0 -0
- bear_utils/logging/logger_manager/_common.py +47 -0
- bear_utils/logging/logger_manager/_console_junk.py +131 -0
- bear_utils/logging/logger_manager/_styles.py +91 -0
- bear_utils/logging/logger_manager/loggers/__init__.py +0 -0
- bear_utils/logging/logger_manager/loggers/_base_logger.py +238 -0
- bear_utils/logging/logger_manager/loggers/_base_logger.pyi +50 -0
- bear_utils/logging/logger_manager/loggers/_buffer_logger.py +55 -0
- bear_utils/logging/logger_manager/loggers/_console_logger.py +249 -0
- bear_utils/logging/logger_manager/loggers/_console_logger.pyi +64 -0
- bear_utils/logging/logger_manager/loggers/_file_logger.py +141 -0
- bear_utils/logging/logger_manager/loggers/_level_sin.py +58 -0
- bear_utils/logging/logger_manager/loggers/_logger.py +18 -0
- bear_utils/logging/logger_manager/loggers/_sub_logger.py +110 -0
- bear_utils/logging/logger_manager/loggers/_sub_logger.pyi +38 -0
- bear_utils/logging/loggers.py +76 -0
- bear_utils/monitoring/__init__.py +10 -0
- bear_utils/monitoring/host_monitor.py +350 -0
- bear_utils/time/__init__.py +16 -0
- bear_utils/time/_helpers.py +91 -0
- bear_utils/time/_time_class.py +316 -0
- bear_utils/time/_timer.py +80 -0
- bear_utils/time/_tools.py +17 -0
- bear_utils/time/time_manager.py +218 -0
- bear_utils-0.7.11.dist-info/METADATA +260 -0
- bear_utils-0.7.11.dist-info/RECORD +83 -0
- 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
|
+
]
|