ae-shell 0.3.16__tar.gz → 0.3.18__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.
- {ae_shell-0.3.16/ae_shell.egg-info → ae_shell-0.3.18}/PKG-INFO +4 -4
- {ae_shell-0.3.16 → ae_shell-0.3.18}/README.md +3 -3
- ae_shell-0.3.18/ae/shell.py +334 -0
- {ae_shell-0.3.16 → ae_shell-0.3.18/ae_shell.egg-info}/PKG-INFO +4 -4
- {ae_shell-0.3.16 → ae_shell-0.3.18}/setup.py +1 -1
- {ae_shell-0.3.16 → ae_shell-0.3.18}/tests/test_shell.py +95 -88
- ae_shell-0.3.16/ae/shell.py +0 -276
- {ae_shell-0.3.16 → ae_shell-0.3.18}/LICENSE.md +0 -0
- {ae_shell-0.3.16 → ae_shell-0.3.18}/ae_shell.egg-info/SOURCES.txt +0 -0
- {ae_shell-0.3.16 → ae_shell-0.3.18}/ae_shell.egg-info/dependency_links.txt +0 -0
- {ae_shell-0.3.16 → ae_shell-0.3.18}/ae_shell.egg-info/requires.txt +0 -0
- {ae_shell-0.3.16 → ae_shell-0.3.18}/ae_shell.egg-info/top_level.txt +0 -0
- {ae_shell-0.3.16 → ae_shell-0.3.18}/ae_shell.egg-info/zip-safe +0 -0
- {ae_shell-0.3.16 → ae_shell-0.3.18}/pyproject.toml +0 -0
- {ae_shell-0.3.16 → ae_shell-0.3.18}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ae_shell
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.18
|
|
4
4
|
Summary: ae namespace module portion shell: shell execution and environment helpers
|
|
5
5
|
Home-page: https://gitlab.com/ae-group/ae_shell
|
|
6
6
|
Author: AndiEcker
|
|
@@ -64,13 +64,13 @@ Dynamic: summary
|
|
|
64
64
|
|
|
65
65
|
<!-- THIS FILE IS EXCLUSIVELY MAINTAINED by the project ae.ae v0.3.110 -->
|
|
66
66
|
<!-- THIS FILE IS EXCLUSIVELY MAINTAINED by the project aedev.namespace_root_tpls v0.3.33 -->
|
|
67
|
-
# shell 0.3.
|
|
67
|
+
# shell 0.3.18
|
|
68
68
|
|
|
69
69
|
[](
|
|
70
70
|
https://gitlab.com/ae-group/ae_shell)
|
|
71
71
|
[](
|
|
73
|
+
https://gitlab.com/ae-group/ae_shell/-/tree/release0.3.18)
|
|
74
74
|
[](
|
|
75
75
|
https://pypi.org/project/ae-shell/#history)
|
|
76
76
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<!-- THIS FILE IS EXCLUSIVELY MAINTAINED by the project ae.ae v0.3.110 -->
|
|
2
2
|
<!-- THIS FILE IS EXCLUSIVELY MAINTAINED by the project aedev.namespace_root_tpls v0.3.33 -->
|
|
3
|
-
# shell 0.3.
|
|
3
|
+
# shell 0.3.18
|
|
4
4
|
|
|
5
5
|
[](
|
|
6
6
|
https://gitlab.com/ae-group/ae_shell)
|
|
7
7
|
[](
|
|
9
|
+
https://gitlab.com/ae-group/ae_shell/-/tree/release0.3.18)
|
|
10
10
|
[](
|
|
11
11
|
https://pypi.org/project/ae-shell/#history)
|
|
12
12
|
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""
|
|
2
|
+
shell execution and environment helpers
|
|
3
|
+
=======================================
|
|
4
|
+
|
|
5
|
+
this module is designed to provide a comprehensive set of constants and helper functions
|
|
6
|
+
to manage shell printouts, OS environment variables and to execute shell commands.
|
|
7
|
+
|
|
8
|
+
* :func:`debug_or_verbose`: checks if the application is running in debug or verbose mode.
|
|
9
|
+
* :func:`get_domain_user_var`: retrieves an OS environment variable value for a specific domain and/or user.
|
|
10
|
+
* :func:`hint`: provides a hint message for console printouts based on the provided arguments.
|
|
11
|
+
* :func:`in_os_env`: context manager to temporarily add environment variables from the ``.env`` files onto `os.environ`.
|
|
12
|
+
* :func:`mask_token`: hide/mask tokens in a text block, to prevent to show them in logs and printouts.
|
|
13
|
+
* :func:`output_line_split`: decode and split the specified shell/console output streams into line chunks.
|
|
14
|
+
* :func:`output_zero_split`: decode and split the specified shell/console output streams separated by `NUL` (\\0) chars.
|
|
15
|
+
* :func:`run_cmd`: execute command in the current working directory of the OS console/shell.
|
|
16
|
+
* :func:`run_logged_cmd`: extended version of :func:`run_cmd` with logging and error checking.
|
|
17
|
+
after a command is executed and handles application shutdown/termination gracefully.
|
|
18
|
+
|
|
19
|
+
* :data:`STDERR_BEG_MARKER`: marker used in the console/shell printouts for the beginning of merged-in `stderr` output.
|
|
20
|
+
* :data:`STDERR_END_MARKER`: marker used in the console/shell printouts for the end of merged-in `stderr` output.
|
|
21
|
+
"""
|
|
22
|
+
import os
|
|
23
|
+
import subprocess
|
|
24
|
+
|
|
25
|
+
from collections.abc import Callable, Iterator, MutableMapping
|
|
26
|
+
from contextlib import contextmanager
|
|
27
|
+
from typing import Any, Sequence, cast, overload
|
|
28
|
+
|
|
29
|
+
from ae.base import UNSET, UnsetType, dummy_function, env_str, norm_name # type: ignore
|
|
30
|
+
from ae.system import load_env_var_defaults, os_env_venv # type: ignore
|
|
31
|
+
from ae.core import main_app_instance, AppBase # type: ignore
|
|
32
|
+
from ae.console import MAIN_SECTION_NAME, ConsoleApp # type: ignore
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__version__ = '0.3.18'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
STDERR_BEG_MARKER = 'vvv STDERR vvv' #: :paramref:`ae.shell.run_cmd.output_lines` begin `stderr` lines marker
|
|
39
|
+
STDERR_END_MARKER = '^^^ STDERR ^^^' #: end `stderr` lines marker in :paramref:`ae.shell.run_cmd.output_lines`
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def debug_or_verbose(app_obj: ConsoleApp | UnsetType | None = None) -> bool:
|
|
43
|
+
""" determine if the current app runs in debug|verbose mode, while preventing early .get_option() call an app init.
|
|
44
|
+
|
|
45
|
+
:param app_obj: optional ConsoleApp instance (def=main_app_instance()).
|
|
46
|
+
:return: a boolean False when the main app debug level is :data:`~ae.core.DEBUG_LEVEL_DISABLED`
|
|
47
|
+
and the app option --more_verbose is not specified (in cfg-file or at the command line),
|
|
48
|
+
else True.
|
|
49
|
+
|
|
50
|
+
.. note:: the return value on app startup/initialization, before the command line parsing, is always True.
|
|
51
|
+
|
|
52
|
+
.. hint::
|
|
53
|
+
the debug mode can be activated via the :class:`~ae.console.ConsoleApp` option `debug_level`, specified either
|
|
54
|
+
in a config file or via the command line options. the verbose mode get activated via the `more_verbose` option.
|
|
55
|
+
"""
|
|
56
|
+
if app_obj is None:
|
|
57
|
+
app_obj = main_app_instance()
|
|
58
|
+
|
|
59
|
+
return bool(
|
|
60
|
+
not isinstance(app_obj, AppBase) # prevent exception in early app startup and unit test runs
|
|
61
|
+
or app_obj.debug # main_app.debug_level > DEBUG_LEVEL_DISABLED
|
|
62
|
+
or not isinstance(app_obj, ConsoleApp) # return True for pure ae.core.AppBase instances
|
|
63
|
+
or not getattr(app_obj, '_parsed_arguments', None) # ConsoleApp._parsed_arguments args Namespace not created
|
|
64
|
+
or app_obj.get_option('more_verbose')) # optional ConsoleApp instance verbose option
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_domain_user_var(variable_name: str, domain: str = "", user: str = "") -> Any:
|
|
68
|
+
""" determine the value of an OS environment variable for a specific domain and/or username.
|
|
69
|
+
|
|
70
|
+
:param variable_name: name of the config variable.
|
|
71
|
+
:param domain: name of the domain.
|
|
72
|
+
:param user: name/id of the user to get a user-specific value of.
|
|
73
|
+
:return: domain/user-specific value of the specified config variable.
|
|
74
|
+
"""
|
|
75
|
+
parts = (MAIN_SECTION_NAME, variable_name.lower(), f'AT_{norm_name(domain)}'.lower(), norm_name(user).lower())
|
|
76
|
+
value = None
|
|
77
|
+
if domain:
|
|
78
|
+
if user:
|
|
79
|
+
value = env_str('_'.join(parts), convert_name=True)
|
|
80
|
+
if value is None:
|
|
81
|
+
value = env_str('_'.join(parts[:-1]), convert_name=True)
|
|
82
|
+
elif user:
|
|
83
|
+
value = env_str('_'.join(parts[:2] + parts[-1:]), convert_name=True)
|
|
84
|
+
|
|
85
|
+
if value is None:
|
|
86
|
+
value = env_str('_'.join(parts[:2]), convert_name=True)
|
|
87
|
+
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def hint(command: str, action: Callable | str, message_suffix: str = "") -> str:
|
|
92
|
+
""" return hint string in debug/verbose mode, to be appended onto a shell/console output.
|
|
93
|
+
|
|
94
|
+
:param command: shell command.
|
|
95
|
+
:param action: shell command action function/method.
|
|
96
|
+
:param message_suffix: extra message text, added to the end of the returned console output string.
|
|
97
|
+
:return: in debug/verbose mode return a string with leading line feed to be sent
|
|
98
|
+
to console output, else return an empty string.
|
|
99
|
+
"""
|
|
100
|
+
if not isinstance(action, str):
|
|
101
|
+
action = action.__name__
|
|
102
|
+
return f"{os.linesep} (run: {command} {action}{message_suffix})" if debug_or_verbose() else ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@contextmanager
|
|
106
|
+
def in_os_env(start_dir: str = "") -> Iterator[MutableMapping[str, str]]:
|
|
107
|
+
""" temporarily add environment variables from the ``.env`` files that not exist in :attr:`os.environ` to it.
|
|
108
|
+
|
|
109
|
+
:param start_dir: path to the folder where the first dotenv file (with the highest priority) is stored.
|
|
110
|
+
:return: yielding the OS env variables that got added to os.environ in this temporary context.
|
|
111
|
+
"""
|
|
112
|
+
loaded_env_vars = load_env_var_defaults(start_dir, os.environ)
|
|
113
|
+
try:
|
|
114
|
+
yield loaded_env_vars
|
|
115
|
+
finally:
|
|
116
|
+
for var_name in loaded_env_vars:
|
|
117
|
+
os.environ.pop(var_name)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@overload
|
|
121
|
+
def mask_token(text: str) -> str: ... # type: ignore[overload-overlap]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@overload
|
|
125
|
+
def mask_token(text: Sequence[str]) -> list[str]: ...
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def mask_token(text: str | Sequence[str]) -> str | list[str]:
|
|
129
|
+
""" hide most parts of any Codeberg/GitHub/GitHub URL tokens found in the specified text/-lines.
|
|
130
|
+
|
|
131
|
+
:param text: text, specified either as str object or as a list of str objects (lines),
|
|
132
|
+
each str/line get searched for URL tokens, to hide/mask the most part of them.
|
|
133
|
+
:return: text with masked URL tokens (only leaving the first/last 3 token characters unmasked).
|
|
134
|
+
|
|
135
|
+
.. note:: see also :func:`ae.base.mask_url` of a more generic way to hide passwords and tokens in URLs.
|
|
136
|
+
"""
|
|
137
|
+
if is_str_arg := isinstance(text, str):
|
|
138
|
+
lines = [text]
|
|
139
|
+
else:
|
|
140
|
+
lines = list(text) # copy to not change text list content
|
|
141
|
+
|
|
142
|
+
url_beg = 'https://' # PDV_REPO_HOST_PROTOCOL
|
|
143
|
+
url_beg_len = len(url_beg)
|
|
144
|
+
for tok_beg, tok_end in ((':', '@codeberg.org'), ('glpat-', '@gitlab.com'), ('ghp_', '@github.com')):
|
|
145
|
+
for idx, line in enumerate(lines):
|
|
146
|
+
beg = -1
|
|
147
|
+
while ((beg := line.find(url_beg, beg + 1)) != -1 and
|
|
148
|
+
(beg := line.find(tok_beg, beg + url_beg_len)) != -1 and
|
|
149
|
+
(end := line.find(tok_end, beg)) != -1):
|
|
150
|
+
line = line[:beg + 3] + "***-masked-token-***" + line[end - 3:]
|
|
151
|
+
lines[idx] = line
|
|
152
|
+
|
|
153
|
+
return lines[0] if is_str_arg else lines
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def output_line_split(output: bytes) -> list[str]:
|
|
157
|
+
""" decode and split the specified shell/console output streams into line chunks.
|
|
158
|
+
|
|
159
|
+
:param output: captured `stdout`/`stderr` output from an executed OS shell command.
|
|
160
|
+
:return: list of non-empty shell/console output lines, decoded into string.
|
|
161
|
+
"""
|
|
162
|
+
return [line for line in output.decode().splitlines() if line.strip()]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def output_zero_split(output: bytes) -> list[str]:
|
|
166
|
+
""" decode and split the specified shell/console output streams separated by `NUL` (\\0) characters.
|
|
167
|
+
|
|
168
|
+
:param output: captured `stdout`/`stderr` output from an executed OS shell command (e.g. `env -0`).
|
|
169
|
+
:return: list of non-empty shell/console output chunks, decoded into string.
|
|
170
|
+
"""
|
|
171
|
+
return [line for line in output.decode().split('\0') if line.strip()]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def run_cmd(*cmd_args: str,
|
|
175
|
+
output_lines: list[str] | None = None,
|
|
176
|
+
app_obj: AppBase | UnsetType | None = None,
|
|
177
|
+
decoder_splitter: Callable[[bytes], list[str]] = output_line_split,
|
|
178
|
+
**run_kwargs) -> int:
|
|
179
|
+
""" run command in the current working directory, capturing/logging errors and optionally returning console output.
|
|
180
|
+
|
|
181
|
+
:param cmd_args: command line string or args sequence of command name and arguments
|
|
182
|
+
to be run/executed on the console/shell via :func:`subprocess.run`.
|
|
183
|
+
:param output_lines: specify a list to be extended with the lines printed on the console/shell `stdout`
|
|
184
|
+
stream, and to also hide this output on the console. if and how the `stderr` stream get
|
|
185
|
+
also either hidden, captured or to be redirected to this list can be controlled via the
|
|
186
|
+
value passed in the kwarg :paramref:`~subprocess.Popen.stderr`: if this argument is a
|
|
187
|
+
list, and you specified the argument value :data:`subprocess.PIPE`, then the `stderr`
|
|
188
|
+
output will get added at the end of this list (enclosed between the list items
|
|
189
|
+
:data:`STDERR_BEG_MARKER` and :data:`STDERR_END_MARKER`). if this argument is a list,
|
|
190
|
+
and you specified the argument value :data:`subprocess.STDOUT` then the `stderr` outputs
|
|
191
|
+
will get merged without any markers into this list in the order they get printed.
|
|
192
|
+
specify :data:`subprocess.DEVNULL` in :paramref:`~subprocess.Popen.stderr` to hide any
|
|
193
|
+
`stderr` output onto the console/shell as well as in this list argument.
|
|
194
|
+
specifying `None` as :paramref:`~subprocess.Popen.stderr` argument value
|
|
195
|
+
(the default argument) then the `stderr` output will be printed only on the console.
|
|
196
|
+
:param app_obj: optional :class:`~ae.core.AppBase`/:class:`~ae.console.ConsoleApp` instance, used for
|
|
197
|
+
logging. if not specified or None and if :func:`~ae.core.main_app_instance()` returns
|
|
198
|
+
None then the Python :func:`print` function is used.
|
|
199
|
+
specify :data:`~ae.base.UNSET` to suppress any printing/logging output.
|
|
200
|
+
:param decoder_splitter: callable to decode and split the output from the `stdout`/`stderr` streams into a list
|
|
201
|
+
of string chunks/lines, to be added to and returned by :paramref:`.output_lines`.
|
|
202
|
+
:param run_kwargs: kwargs to be passed onto :func:`subprocess.run`, most of them unchanged, like e.g.
|
|
203
|
+
:paramref:`~subprocess.run.input`. some of them, will get adopted/changed before
|
|
204
|
+
they get passed onto :func:`subprocess.run`:
|
|
205
|
+
|
|
206
|
+
* :paramref:`~subprocess.run.check`: will get passed onto :func:`subprocess.run`
|
|
207
|
+
as `True` if not specified in this kwarg (in order to catch and log the
|
|
208
|
+
:class:`subprocess.CalledProcessError` exception in debug mode).
|
|
209
|
+
* :paramref:`~subprocess.Popen.env`: shell environment variables to be used instead of
|
|
210
|
+
the currently set OS shell variable values.
|
|
211
|
+
only if the value of this argument does not get specified or has the value `UNSET`,
|
|
212
|
+
then an isolated dict copy of :attr:`os.environ` will get passed onto
|
|
213
|
+
:func:`subprocess.run` in order to avoid potential runtime errors, caused by a parent
|
|
214
|
+
process (or a concurrent thread) if it modifies :attr:`os.environ` during the creation
|
|
215
|
+
of this subprocess. additionally, with the convertion of the special :class:`_Environ`
|
|
216
|
+
object into a standard dict, the execution will result slightly faster and avoids
|
|
217
|
+
any special method overrides interfering with the child process creation. it also is
|
|
218
|
+
preventing rare errors with multithread-processes that try to change OS env variable
|
|
219
|
+
(e.g. Conda in relation with pip could lead to raise a `RuntimeError: dictionary
|
|
220
|
+
changed size during iteration` exception). specify `None` as the :paramref:`.env`
|
|
221
|
+
value in order to use the original/unisolated :attr:`os.environ` object.
|
|
222
|
+
* :paramref:`~subprocess.Popen.stdout`: will be passed to :func:`subprocess.run`
|
|
223
|
+
as :data:`subprocess.PIPE` (instead of None) if a list instance got specified
|
|
224
|
+
as the :paramref:`.output_lines` argument.
|
|
225
|
+
|
|
226
|
+
:return: return code of the executed command or 126 if execution raised any other exception.
|
|
227
|
+
"""
|
|
228
|
+
masked_args = mask_token(cmd_args)
|
|
229
|
+
|
|
230
|
+
if app_obj is None:
|
|
231
|
+
app_obj = main_app_instance()
|
|
232
|
+
print_out = dummy_function if app_obj is UNSET else app_obj.print_out if isinstance(app_obj, AppBase) else print
|
|
233
|
+
debug_out = dummy_function if app_obj is UNSET else app_obj.debug_out if isinstance(app_obj, AppBase) else print
|
|
234
|
+
|
|
235
|
+
run_kwargs.setdefault('check', True)
|
|
236
|
+
if run_kwargs.get('env', UNSET) is UNSET:
|
|
237
|
+
run_kwargs['env'] = os.environ.copy()
|
|
238
|
+
if isinstance(output_lines, list):
|
|
239
|
+
run_kwargs.setdefault('stdout', subprocess.PIPE)
|
|
240
|
+
|
|
241
|
+
debug_out(f" . executing {masked_args} at {os.getcwd()=} in {os_env_venv()=}")
|
|
242
|
+
|
|
243
|
+
result: subprocess.CompletedProcess | subprocess.CalledProcessError # having: stdout/stderr/returncode
|
|
244
|
+
try:
|
|
245
|
+
result = subprocess.run(cmd_args, **run_kwargs) # pylint: disable=subprocess-run-check
|
|
246
|
+
except subprocess.CalledProcessError as exc:
|
|
247
|
+
debug_out(f"**** subprocess.run({masked_args}) returned non-zero exit code {exc.returncode}; {exc=}")
|
|
248
|
+
result = exc
|
|
249
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
250
|
+
print_out(f"**** subprocess.run({masked_args}) raised exception {exc}")
|
|
251
|
+
return (126, )[0] # put return/exit code into tuple for global code search
|
|
252
|
+
|
|
253
|
+
if isinstance(output_lines, list):
|
|
254
|
+
if result.stdout:
|
|
255
|
+
output_lines.extend(decoder_splitter(result.stdout))
|
|
256
|
+
if result.stderr and run_kwargs.get('stderr', None) == subprocess.PIPE:
|
|
257
|
+
output_lines.append(STDERR_BEG_MARKER)
|
|
258
|
+
output_lines.extend(decoder_splitter(result.stderr))
|
|
259
|
+
output_lines.append(STDERR_END_MARKER)
|
|
260
|
+
|
|
261
|
+
return result.returncode
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def run_logged_cmd(err_code: int, *cmd_args: str,
|
|
265
|
+
output_lines: list[str] | None = None,
|
|
266
|
+
exit_on_err: bool | str = True,
|
|
267
|
+
app_obj: ConsoleApp | UnsetType | None = None,
|
|
268
|
+
decoder_splitter: Callable[[bytes], list[str]] = output_line_split,
|
|
269
|
+
**run_kwargs) -> int:
|
|
270
|
+
""" run command in the current working directory, optionally capturing/logging console output and exiting on error.
|
|
271
|
+
|
|
272
|
+
:param err_code: error code to be passed onto the console as exit code if the command set an error code
|
|
273
|
+
and the value of the :paramref:`.exit_on_err` argument is `True` or a nonempty string.
|
|
274
|
+
:param cmd_args: command line string or a sequence of command name and separate line arguments
|
|
275
|
+
to be run/executed on the console/shell.
|
|
276
|
+
:param output_lines: optional list extended with the lines printed to `stdout`/`stderr` on execution.
|
|
277
|
+
see the :paramref:`~run_cmd.output_lines` argument of :func:`run_cmd` for more details.
|
|
278
|
+
:param exit_on_err: specifying `True` (the default) or a nonempty string will shut-down/quit/exit the
|
|
279
|
+
currently running app (the Python interpreter) if the executed command set an
|
|
280
|
+
error code. a nonempty string will get printed/logged to `stdout` before the exit.
|
|
281
|
+
pass `False` or an empty string to not exit the app if a command error occurred.
|
|
282
|
+
:param app_obj: optional :class:`~ae.console.ConsoleApp` instance, used for logging and error checking
|
|
283
|
+
(with the option to ignore errors if the app got started with the `--force` option).
|
|
284
|
+
if not specified or None and if :func:`~ae.core.main_app_instance()` returns
|
|
285
|
+
None then the Python :func:`print` function is used for logging.
|
|
286
|
+
specify :data:`~ae.base.UNSET` to suppress any printing/logging output.
|
|
287
|
+
:param decoder_splitter: callable to decode and split the output from the `stdout`/`stderr` streams into a list
|
|
288
|
+
of string chunks/lines, to be added to and returned by :paramref:`.output_lines`.
|
|
289
|
+
:param run_kwargs: extra kwargs to be passed onto :func:`run_cmd` and :func:`subprocess.run`. some kwargs
|
|
290
|
+
like e.g. :paramref:`~subprocess.run.input` and :paramref:`~subprocess.Popen.shell`
|
|
291
|
+
will get passed unchanged onto :func:`run_cmd`, others like
|
|
292
|
+
:paramref:`~run_cmd.env` or :paramref:`~run_cmd.stderr`, will be first processed by
|
|
293
|
+
this function and/or :func:`run_cmd` before they get passed onto :func:`subprocess.run`:
|
|
294
|
+
|
|
295
|
+
* :paramref:`~subprocess.Popen.stderr`: controls how the output onto `stderr` will get
|
|
296
|
+
captured, redirected and/or returned. if this argument is not specified then the value
|
|
297
|
+
:data:`subprocess.DEVNULL` will get passed onto :func:`run_cmd` and
|
|
298
|
+
:func:`subprocess.run`, which is suppressing any output sent onto `stderr` on the
|
|
299
|
+
console as well as any addition of it to the list argument specified in
|
|
300
|
+
:paramref:`.output_lines`. if you specify the value :data:`subprocess.PIPE` or
|
|
301
|
+
:data:`subprocess.STDOUT` together with a list in the :paramref:`.output_lines`
|
|
302
|
+
argument, then any output onto `stderr` will get added to this list.
|
|
303
|
+
see also the description of the :paramref:`~run_cmd.output_lines` parameter of
|
|
304
|
+
:func:`run_cmd` for more details on the supported values of this parameter.
|
|
305
|
+
|
|
306
|
+
:return: 0 on success, or if an error occurred the error number set by the executed command.
|
|
307
|
+
"""
|
|
308
|
+
if output_lines is None:
|
|
309
|
+
output_lines = []
|
|
310
|
+
output_len = 0
|
|
311
|
+
else:
|
|
312
|
+
output_len = len(output_lines)
|
|
313
|
+
|
|
314
|
+
if app_obj is None:
|
|
315
|
+
app_obj = cast(ConsoleApp, main_app_instance()) # calls ConsoleApp./app_obj.chk() method
|
|
316
|
+
|
|
317
|
+
run_kwargs.setdefault('stderr', subprocess.DEVNULL)
|
|
318
|
+
|
|
319
|
+
sh_err = run_cmd(*cmd_args, output_lines=output_lines, app_obj=app_obj, decoder_splitter=decoder_splitter,
|
|
320
|
+
**run_kwargs)
|
|
321
|
+
|
|
322
|
+
if isinstance(app_obj, ConsoleApp) and (app_obj.debug or sh_err and exit_on_err):
|
|
323
|
+
for line in output_lines[output_len:]:
|
|
324
|
+
if app_obj.verbose or not line.startswith("LOG: "): # if verbose show mypy's endless (stderr) log entries
|
|
325
|
+
app_obj.po(" " * 6 + line)
|
|
326
|
+
command = mask_token(cmd_args)
|
|
327
|
+
if sh_err == 0:
|
|
328
|
+
app_obj.dpo(f" = successfully executed {command=}")
|
|
329
|
+
else:
|
|
330
|
+
if isinstance(exit_on_err, str):
|
|
331
|
+
app_obj.po(f" {exit_on_err}")
|
|
332
|
+
app_obj.chk(err_code, not bool(exit_on_err), f"run_logged_cmd({err_code}, {command!r}) error {sh_err}")
|
|
333
|
+
|
|
334
|
+
return sh_err
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ae_shell
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.18
|
|
4
4
|
Summary: ae namespace module portion shell: shell execution and environment helpers
|
|
5
5
|
Home-page: https://gitlab.com/ae-group/ae_shell
|
|
6
6
|
Author: AndiEcker
|
|
@@ -64,13 +64,13 @@ Dynamic: summary
|
|
|
64
64
|
|
|
65
65
|
<!-- THIS FILE IS EXCLUSIVELY MAINTAINED by the project ae.ae v0.3.110 -->
|
|
66
66
|
<!-- THIS FILE IS EXCLUSIVELY MAINTAINED by the project aedev.namespace_root_tpls v0.3.33 -->
|
|
67
|
-
# shell 0.3.
|
|
67
|
+
# shell 0.3.18
|
|
68
68
|
|
|
69
69
|
[](
|
|
70
70
|
https://gitlab.com/ae-group/ae_shell)
|
|
71
71
|
[](
|
|
73
|
+
https://gitlab.com/ae-group/ae_shell/-/tree/release0.3.18)
|
|
74
74
|
[](
|
|
75
75
|
https://pypi.org/project/ae-shell/#history)
|
|
76
76
|
|
|
@@ -6,14 +6,15 @@ import subprocess
|
|
|
6
6
|
from unittest.mock import PropertyMock, patch
|
|
7
7
|
|
|
8
8
|
from ae.base import UNSET, camel_to_snake, norm_name, os_path_join, write_file
|
|
9
|
-
from ae.system import load_env_var_defaults,
|
|
9
|
+
from ae.system import load_env_var_defaults, os_env_venv
|
|
10
10
|
from ae.core import DEBUG_LEVEL_DISABLED, DEBUG_LEVEL_ENABLED, DEBUG_LEVEL_VERBOSE
|
|
11
11
|
from ae.console import MAIN_SECTION_NAME, ConsoleApp
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
from ae.shell import (
|
|
15
15
|
STDERR_BEG_MARKER, STDERR_END_MARKER,
|
|
16
|
-
debug_or_verbose, get_domain_user_var, hint, in_os_env, mask_token,
|
|
16
|
+
debug_or_verbose, get_domain_user_var, hint, in_os_env, mask_token,
|
|
17
|
+
output_line_split, output_zero_split, run_cmd, run_logged_cmd)
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
class TestHelpers:
|
|
@@ -257,68 +258,92 @@ STDERR_LINE = b'std___err'
|
|
|
257
258
|
|
|
258
259
|
|
|
259
260
|
class TestShellExecutions:
|
|
260
|
-
def
|
|
261
|
+
def test_output_line_split(self):
|
|
262
|
+
assert output_line_split(STDOUT_LINE) == [STDOUT_LINE.decode()]
|
|
263
|
+
|
|
264
|
+
assert output_line_split(STDOUT_LINE + b"\n" + STDERR_LINE + b"\n") == [STDOUT_LINE.decode(),
|
|
265
|
+
STDERR_LINE.decode()]
|
|
266
|
+
|
|
267
|
+
assert output_line_split(STDOUT_LINE + b"\n\0" + STDERR_LINE + b"\0") == [
|
|
268
|
+
STDOUT_LINE.decode(), "\0" + STDERR_LINE.decode() + "\0"]
|
|
269
|
+
|
|
270
|
+
assert output_line_split(STDOUT_LINE + b"\r" + STDERR_LINE + b"\r\n\r\0\r") == [
|
|
271
|
+
STDOUT_LINE.decode(), STDERR_LINE.decode(), "\0"]
|
|
272
|
+
|
|
273
|
+
def test_output_zero_split(self):
|
|
274
|
+
assert output_zero_split(STDOUT_LINE) == [STDOUT_LINE.decode()]
|
|
275
|
+
|
|
276
|
+
assert output_zero_split(STDOUT_LINE + b"\0" + STDERR_LINE + b"\0") == [STDOUT_LINE.decode(),
|
|
277
|
+
STDERR_LINE.decode()]
|
|
278
|
+
|
|
279
|
+
assert output_zero_split(STDOUT_LINE + b"\0" + STDERR_LINE + b" = multi\nval\rlines\r\n" + STDERR_LINE) == [
|
|
280
|
+
STDOUT_LINE.decode(), STDERR_LINE.decode() + " = multi\nval\rlines\r\n" + STDERR_LINE.decode()]
|
|
281
|
+
|
|
282
|
+
assert output_zero_split(STDOUT_LINE + b"\n" + STDERR_LINE + b"\r\n\r\0\r") == [
|
|
283
|
+
STDOUT_LINE.decode() + "\n" + STDERR_LINE.decode() + "\r\n\r"]
|
|
284
|
+
|
|
285
|
+
def test_run_cmd_catch_any_exception(self, capsys):
|
|
261
286
|
with patch("subprocess.run", side_effect=Exception('broad tst exception')):
|
|
262
|
-
assert
|
|
287
|
+
assert run_cmd('any_cmd') == (126, )[0]
|
|
263
288
|
|
|
264
289
|
output = capsys.readouterr().out
|
|
265
290
|
assert 'any_cmd' in output
|
|
266
291
|
assert " raised exception " in output
|
|
267
292
|
assert 'broad tst exception' in output
|
|
268
293
|
|
|
269
|
-
def
|
|
270
|
-
assert
|
|
294
|
+
def test_run_cmd_catch_exit_code_exception(self, capsys):
|
|
295
|
+
assert run_cmd("exit 69", shell=True) == 69
|
|
271
296
|
|
|
272
297
|
output = capsys.readouterr().out
|
|
273
298
|
assert " returned non-zero exit code " in output
|
|
274
299
|
|
|
275
|
-
assert
|
|
300
|
+
assert run_cmd(sys.executable, "-c", "import sys; sys.exit(96)") == 96
|
|
276
301
|
|
|
277
302
|
output = capsys.readouterr().out
|
|
278
303
|
assert " returned non-zero exit code 96" in output
|
|
279
304
|
|
|
280
|
-
def
|
|
281
|
-
|
|
305
|
+
def test_run_cmd_console_output(self, capsys, cons_app):
|
|
306
|
+
run_cmd('any command')
|
|
282
307
|
|
|
283
308
|
out, err = capsys.readouterr()
|
|
284
|
-
assert " . executing ['any
|
|
309
|
+
assert " . executing ['any command']" in out
|
|
285
310
|
assert os.getcwd() in out
|
|
286
|
-
assert
|
|
287
|
-
assert "**** subprocess.run(['any
|
|
311
|
+
assert os_env_venv() in out
|
|
312
|
+
assert "**** subprocess.run(['any command']) raised exception" in out
|
|
288
313
|
assert err == ""
|
|
289
314
|
|
|
290
315
|
with patch('ae.console.ConsoleApp.debug_level', new_callable=PropertyMock, return_value=DEBUG_LEVEL_DISABLED):
|
|
291
|
-
|
|
316
|
+
run_cmd('any command')
|
|
292
317
|
|
|
293
318
|
out, err = capsys.readouterr()
|
|
294
319
|
assert " . executing" not in out
|
|
295
|
-
assert "**** subprocess.run(['any
|
|
320
|
+
assert "**** subprocess.run(['any command']) raised exception" in out
|
|
296
321
|
assert err == ""
|
|
297
322
|
|
|
298
323
|
with patch('ae.console.ConsoleApp.debug_level', new_callable=PropertyMock, return_value=DEBUG_LEVEL_DISABLED):
|
|
299
|
-
|
|
324
|
+
run_cmd("any", "command", app_obj=cons_app) # strange: patch('ae.core.AppBase.debug_level') does not work
|
|
300
325
|
|
|
301
326
|
out, err = capsys.readouterr()
|
|
302
327
|
assert " . executing" not in out
|
|
303
328
|
assert "**** subprocess.run(['any', 'command']) raised exception" in out
|
|
304
329
|
assert err == ""
|
|
305
330
|
|
|
306
|
-
|
|
331
|
+
run_cmd('any command', app_obj=UNSET)
|
|
307
332
|
|
|
308
333
|
out, err = capsys.readouterr()
|
|
309
334
|
assert out == ""
|
|
310
335
|
assert err == ""
|
|
311
336
|
|
|
312
|
-
def
|
|
313
|
-
ret =
|
|
337
|
+
def test_run_cmd_console_output_shell(self, capsys):
|
|
338
|
+
ret = run_cmd("echo hello world", shell=True)
|
|
314
339
|
|
|
315
340
|
out, err = capsys.readouterr()
|
|
316
341
|
assert ret == 0
|
|
317
|
-
assert " . executing echo hello world at " in out
|
|
342
|
+
assert " . executing ['echo hello world'] at " in out
|
|
318
343
|
assert out.count("hello world") == 1 # strange: capsys does not get the echo command output w/ shell=True arg
|
|
319
344
|
assert err == ""
|
|
320
345
|
|
|
321
|
-
ret =
|
|
346
|
+
ret = run_cmd("echo hello world", app_obj=UNSET, shell=True)
|
|
322
347
|
|
|
323
348
|
out, err = capsys.readouterr()
|
|
324
349
|
assert ret == 0
|
|
@@ -326,53 +351,43 @@ class TestShellExecutions:
|
|
|
326
351
|
assert err == ""
|
|
327
352
|
|
|
328
353
|
@patch.object(subprocess, 'run', autospec=True)
|
|
329
|
-
def
|
|
354
|
+
def test_run_cmd_run_args(self, mock_method):
|
|
330
355
|
cmd_line = "cmd arg1 arg2"
|
|
331
356
|
extra_args = ['extra_arg1', 'extra_arg2']
|
|
357
|
+
env = os.environ.copy()
|
|
332
358
|
|
|
333
|
-
|
|
359
|
+
run_cmd(*(shlex.split(cmd_line) + extra_args))
|
|
334
360
|
|
|
335
|
-
mock_method.assert_called_with(
|
|
336
|
-
shlex.split(cmd_line) + extra_args, stdout=None, stderr=None, input=b'', check=True, shell=False, env=None)
|
|
361
|
+
mock_method.assert_called_with(tuple(shlex.split(cmd_line) + extra_args), check=True, env=env)
|
|
337
362
|
|
|
338
|
-
|
|
363
|
+
run_cmd(cmd_line, shell=True)
|
|
339
364
|
|
|
340
|
-
mock_method.assert_called_with(
|
|
341
|
-
cmd_line, stdout=None, stderr=None, input=b'', check=True, shell=True, env=None)
|
|
365
|
+
mock_method.assert_called_with((cmd_line, ), check=True, shell=True, env=env)
|
|
342
366
|
|
|
343
|
-
|
|
367
|
+
run_cmd(cmd_line, *extra_args, input='con_inp')
|
|
344
368
|
|
|
345
|
-
mock_method.assert_called_with(
|
|
346
|
-
cmd_line + " " + " ".join(extra_args), stdout=None, stderr=subprocess.DEVNULL, input=b'', check=True,
|
|
347
|
-
shell=True, env=None)
|
|
369
|
+
mock_method.assert_called_with((cmd_line, ) + tuple(extra_args), input='con_inp', check=True, env=env)
|
|
348
370
|
|
|
349
|
-
|
|
371
|
+
run_cmd(cmd_line, *extra_args, output_lines=[])
|
|
350
372
|
|
|
351
|
-
mock_method.assert_called_with(
|
|
352
|
-
shlex.split(cmd_line) + extra_args, stdout=None, stderr=None, input=b'con_inp',
|
|
353
|
-
check=True, shell=False, env=None)
|
|
373
|
+
mock_method.assert_called_with((cmd_line, ) + tuple(extra_args), check=True, stdout=subprocess.PIPE, env=env)
|
|
354
374
|
|
|
355
|
-
|
|
375
|
+
run_cmd(cmd_line, *extra_args, input='con_inp', output_lines=[], stderr=subprocess.STDOUT)
|
|
356
376
|
|
|
357
|
-
mock_method.assert_called_with(
|
|
358
|
-
|
|
359
|
-
check=True, shell=False, env=None)
|
|
377
|
+
mock_method.assert_called_with((cmd_line, ) + tuple(extra_args), input='con_inp',
|
|
378
|
+
stderr=subprocess.STDOUT, stdout=subprocess.PIPE, check=True, env=env)
|
|
360
379
|
|
|
361
|
-
|
|
380
|
+
env_vars = {'A': "1", 'C': "tst_string value"}
|
|
362
381
|
|
|
363
|
-
|
|
364
|
-
shlex.split(cmd_line) + extra_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, input=b'con_inp',
|
|
365
|
-
check=True, shell=False, env=None)
|
|
382
|
+
run_cmd(cmd_line, *extra_args, env=env_vars)
|
|
366
383
|
|
|
367
|
-
|
|
384
|
+
mock_method.assert_called_with((cmd_line, ) + tuple(extra_args), env=env_vars, check=True)
|
|
368
385
|
|
|
369
|
-
|
|
386
|
+
run_cmd(cmd_line, *extra_args, env=None)
|
|
370
387
|
|
|
371
|
-
mock_method.assert_called_with(
|
|
372
|
-
shlex.split(cmd_line) + extra_args, stdout=None, stderr=None, input=b'',
|
|
373
|
-
check=True, shell=False, env=env_vars)
|
|
388
|
+
mock_method.assert_called_with((cmd_line, ) + tuple(extra_args), check=True, env=None)
|
|
374
389
|
|
|
375
|
-
def
|
|
390
|
+
def test_run_cmd_run_returned_values(self):
|
|
376
391
|
def _run_return(*_args, **_kwargs):
|
|
377
392
|
""" mock to simulate subprocess.run return object. """
|
|
378
393
|
class _Return:
|
|
@@ -384,7 +399,7 @@ class TestShellExecutions:
|
|
|
384
399
|
with patch('ae.shell.subprocess.run', new=_run_return): # @patch.object(subprocess, 'run', new=_run_return)
|
|
385
400
|
output_lines = []
|
|
386
401
|
|
|
387
|
-
assert
|
|
402
|
+
assert run_cmd("cmd_line", output_lines=output_lines, stderr=subprocess.PIPE) == RETURN_CODE
|
|
388
403
|
|
|
389
404
|
assert output_lines[0] == STDOUT_LINE.decode()
|
|
390
405
|
assert output_lines[1] == STDERR_BEG_MARKER
|
|
@@ -393,7 +408,7 @@ class TestShellExecutions:
|
|
|
393
408
|
|
|
394
409
|
output_lines = []
|
|
395
410
|
|
|
396
|
-
assert
|
|
411
|
+
assert run_cmd("cmd_line", output_lines=output_lines) == RETURN_CODE
|
|
397
412
|
|
|
398
413
|
assert len(output_lines) == 1
|
|
399
414
|
assert output_lines[0] == STDOUT_LINE.decode()
|
|
@@ -403,7 +418,7 @@ class TestShellExecutions:
|
|
|
403
418
|
|
|
404
419
|
output_lines = ['first line', 'second line']
|
|
405
420
|
|
|
406
|
-
assert
|
|
421
|
+
assert run_cmd("cmd_line", output_lines=output_lines, err_redirect=subprocess.STDOUT) == RETURN_CODE
|
|
407
422
|
|
|
408
423
|
assert len(output_lines) == 3
|
|
409
424
|
assert output_lines[0] == 'first line'
|
|
@@ -413,11 +428,10 @@ class TestShellExecutions:
|
|
|
413
428
|
assert STDERR_BEG_MARKER not in output_lines
|
|
414
429
|
assert STDERR_END_MARKER not in output_lines
|
|
415
430
|
|
|
416
|
-
def
|
|
417
|
-
args =
|
|
418
|
-
'extra_args': ["-c", "import sys; print('tst_std_err', file=sys.stderr); print('tst_std_out')"]}
|
|
431
|
+
def test_run_cmd_stderr_redirection(self, capfd, cons_app): # pytest/capsys replaces sys.stdout/.stderr
|
|
432
|
+
args = [sys.executable, "-c", "import sys; print('tst_std_err', file=sys.stderr); print('tst_std_out')"]
|
|
419
433
|
|
|
420
|
-
assert
|
|
434
|
+
assert run_cmd(*args) == 0
|
|
421
435
|
|
|
422
436
|
out, err = capfd.readouterr()
|
|
423
437
|
assert out.count('tst_std_out') == 2
|
|
@@ -426,7 +440,7 @@ class TestShellExecutions:
|
|
|
426
440
|
|
|
427
441
|
redirected = []
|
|
428
442
|
|
|
429
|
-
assert
|
|
443
|
+
assert run_cmd(*args, output_lines=redirected) == 0
|
|
430
444
|
|
|
431
445
|
out, err = capfd.readouterr()
|
|
432
446
|
assert out.count('tst_std_out') == 1
|
|
@@ -436,7 +450,7 @@ class TestShellExecutions:
|
|
|
436
450
|
|
|
437
451
|
redirected = []
|
|
438
452
|
|
|
439
|
-
assert
|
|
453
|
+
assert run_cmd(*args, output_lines=redirected, stderr=subprocess.DEVNULL) == 0
|
|
440
454
|
|
|
441
455
|
out, err = capfd.readouterr()
|
|
442
456
|
assert err == ''
|
|
@@ -444,7 +458,7 @@ class TestShellExecutions:
|
|
|
444
458
|
|
|
445
459
|
redirected = []
|
|
446
460
|
|
|
447
|
-
assert
|
|
461
|
+
assert run_cmd(*args, output_lines=redirected, stderr=subprocess.PIPE) == 0
|
|
448
462
|
|
|
449
463
|
out, err = capfd.readouterr()
|
|
450
464
|
assert err == ''
|
|
@@ -452,18 +466,17 @@ class TestShellExecutions:
|
|
|
452
466
|
|
|
453
467
|
redirected = []
|
|
454
468
|
|
|
455
|
-
assert
|
|
469
|
+
assert run_cmd(*args, output_lines=redirected, stderr=subprocess.STDOUT) == 0
|
|
456
470
|
|
|
457
471
|
out, err = capfd.readouterr()
|
|
458
472
|
assert err == ''
|
|
459
473
|
assert redirected == ['tst_std_err', 'tst_std_out']
|
|
460
474
|
|
|
461
|
-
def
|
|
475
|
+
def test_run_logged_cmd_any_command(self, capsys, cons_app):
|
|
462
476
|
output = ['old output']
|
|
463
477
|
|
|
464
478
|
with patch('ae.console.ConsoleApp.debug', new_callable=PropertyMock, return_value=True): # with app_obj kwarg
|
|
465
|
-
ret =
|
|
466
|
-
exit_on_err=False, exit_msg='tst exit message', app_obj=cons_app)
|
|
479
|
+
ret = run_logged_cmd(0, "git", "--version", output_lines=output, exit_on_err=False, app_obj=cons_app)
|
|
467
480
|
|
|
468
481
|
assert ret == 0
|
|
469
482
|
assert len(output) >= 2
|
|
@@ -471,49 +484,46 @@ class TestShellExecutions:
|
|
|
471
484
|
assert isinstance(output[1], str) # e.g. == 'git version 2.55.0'
|
|
472
485
|
out, err = capsys.readouterr()
|
|
473
486
|
assert 'old output' not in out
|
|
474
|
-
assert 'git --version' in out
|
|
475
|
-
assert 'tst exit message' not in out
|
|
487
|
+
assert "\n . executing ['git', '--version'] at os.getcwd()='" in out
|
|
476
488
|
assert err == ""
|
|
477
489
|
|
|
478
|
-
def
|
|
479
|
-
ret =
|
|
490
|
+
def test_run_logged_cmd_any_invalid_command(self, capsys, cons_app, patched_shutdown_wrapper):
|
|
491
|
+
ret = run_logged_cmd(693, 'tst_command_line', exit_on_err=False)
|
|
480
492
|
|
|
481
493
|
assert ret == (126, )[0]
|
|
482
494
|
out, err = capsys.readouterr()
|
|
483
495
|
assert out.count('tst_command_line') == 3
|
|
484
|
-
assert 'tst exit message' in out
|
|
485
496
|
assert err == ""
|
|
486
497
|
|
|
487
|
-
def
|
|
488
|
-
ret = patched_shutdown_wrapper(
|
|
498
|
+
def test_run_logged_cmd_caught_shutdown_exception(self, capsys, cons_app, patched_shutdown_wrapper):
|
|
499
|
+
ret = patched_shutdown_wrapper(run_logged_cmd, 693, 'tst_command_line', exit_on_err='tst exit message')
|
|
489
500
|
|
|
490
501
|
assert len(ret) == 1
|
|
491
502
|
assert ret[0]['exit_code'] == 693 # 1st arg == error code
|
|
492
503
|
assert 'tst_command_line' in ret[0]['error_message']
|
|
493
|
-
assert '
|
|
504
|
+
assert 'run_logged_cmd(' in ret[0]['error_message']
|
|
494
505
|
assert "(693, " in ret[0]['error_message']
|
|
495
506
|
out, err = capsys.readouterr()
|
|
496
507
|
assert out.count('tst_command_line') == 3
|
|
497
508
|
assert 'tst exit message' in out
|
|
498
509
|
assert err == ""
|
|
499
510
|
|
|
500
|
-
def
|
|
511
|
+
def test_run_logged_cmd_exception(self, capsys, cons_app):
|
|
501
512
|
output = ['old output']
|
|
502
513
|
|
|
503
|
-
ret =
|
|
514
|
+
ret = run_logged_cmd(693, "", output_lines=output, exit_on_err=False)
|
|
504
515
|
|
|
505
516
|
assert ret == (126, )[0]
|
|
506
517
|
assert output == ['old output']
|
|
507
518
|
out, err = capsys.readouterr()
|
|
508
|
-
assert f". executing [] at os.getcwd()='{os.getcwd()}' in
|
|
509
|
-
assert 'tst exit message' in out
|
|
519
|
+
assert f". executing [''] at os.getcwd()='{os.getcwd()}' in os_env_venv()='{os_env_venv()}'" in out
|
|
510
520
|
assert err == ""
|
|
511
521
|
|
|
512
|
-
def
|
|
522
|
+
def test_run_logged_cmd_with_app(self, capsys, cons_app):
|
|
513
523
|
output = []
|
|
514
524
|
|
|
515
525
|
with patch('ae.console.ConsoleApp.debug', new_callable=PropertyMock, return_value=False):
|
|
516
|
-
ret =
|
|
526
|
+
ret = run_logged_cmd(0, "_err", output_lines=output, exit_on_err=False, stderr=subprocess.STDOUT)
|
|
517
527
|
|
|
518
528
|
assert ret == (126, )[0]
|
|
519
529
|
assert output == []
|
|
@@ -521,26 +531,24 @@ class TestShellExecutions:
|
|
|
521
531
|
assert out.count('_err') == 3
|
|
522
532
|
assert err == ""
|
|
523
533
|
|
|
524
|
-
def
|
|
534
|
+
def test_run_logged_cmd_with_app_kwarg_and_empty_exit_on_err_msg(self, capsys, cons_app):
|
|
525
535
|
output = []
|
|
526
536
|
|
|
527
537
|
with patch('ae.console.ConsoleApp.debug', new_callable=PropertyMock, return_value=True): # with app_obj kwarg
|
|
528
|
-
ret =
|
|
529
|
-
exit_on_err=False, exit_msg='tst exit message', app_obj=cons_app)
|
|
538
|
+
ret = run_logged_cmd(0, 'error-command', output_lines=output, exit_on_err="", app_obj=cons_app)
|
|
530
539
|
|
|
531
540
|
assert ret == (126, )[0]
|
|
532
541
|
assert output == []
|
|
533
542
|
out, err = capsys.readouterr()
|
|
534
|
-
assert out.count('error-command') == 3
|
|
535
|
-
assert 'tst exit message' in out
|
|
543
|
+
assert out.count('error-command') == 3 # extra empty line
|
|
536
544
|
assert err == ""
|
|
537
545
|
|
|
538
|
-
def
|
|
546
|
+
def test_run_logged_cmd_with_app_extending_output(self, capsys, cons_app):
|
|
539
547
|
output = ['any old line output']
|
|
540
548
|
|
|
541
|
-
with (patch('ae.shell.
|
|
549
|
+
with (patch('ae.shell.run_cmd', new=lambda *_, **kwargs: kwargs['output_lines'].append('new out line') or 0),
|
|
542
550
|
patch('ae.console.ConsoleApp.debug', new_callable=PropertyMock, return_value=True)):
|
|
543
|
-
ret =
|
|
551
|
+
ret = run_logged_cmd(369, "any_cmd", output_lines=output) # extended output_lines and ret==0
|
|
544
552
|
|
|
545
553
|
assert ret == 0
|
|
546
554
|
assert output == ['any old line output', 'new out line']
|
|
@@ -551,14 +559,13 @@ class TestShellExecutions:
|
|
|
551
559
|
assert out.count('any_cmd') == 1
|
|
552
560
|
assert err == ""
|
|
553
561
|
|
|
554
|
-
def
|
|
562
|
+
def test_run_logged_cmd_without_app(self, capsys):
|
|
555
563
|
output = []
|
|
556
564
|
|
|
557
|
-
ret =
|
|
565
|
+
ret = run_logged_cmd(0, "_err_cmd", output_lines=output, exit_on_err=False)
|
|
558
566
|
|
|
559
567
|
assert ret == (126, )[0]
|
|
560
568
|
assert output == []
|
|
561
569
|
out, err = capsys.readouterr()
|
|
562
570
|
assert out.count('_err_cmd') == 3
|
|
563
|
-
assert 'tst exit message' not in out
|
|
564
571
|
assert err == ""
|
ae_shell-0.3.16/ae/shell.py
DELETED
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
shell execution and environment helpers
|
|
3
|
-
=======================================
|
|
4
|
-
|
|
5
|
-
this module is designed to provide a comprehensive set of constants and helper functions
|
|
6
|
-
to manage shell printouts, OS environment variables and to execute shell commands.
|
|
7
|
-
|
|
8
|
-
- :func:`debug_or_verbose`: checks if the application is running in debug or verbose mode.
|
|
9
|
-
- :func:`get_domain_user_var`: retrieves an OS environment variable value for a specific domain and/or user.
|
|
10
|
-
- :func:`hint`: provides a hint message for console printouts based on the provided arguments.
|
|
11
|
-
- :func:`in_os_env`: context manager to temporarily add environment variables from the ``.env`` files onto `os.environ`.
|
|
12
|
-
- :func:`mask_token`: hide/mask tokens in a text block, to prevent to show them in logs and printouts.
|
|
13
|
-
- :func:`sh_exec`: execute command in the current working directory of the OS console/shell.
|
|
14
|
-
- :func:`sh_exit_if_exec_err`: extended version of :func:`sh_exec` with automatically checks for errors
|
|
15
|
-
after a command is executed and handles application termination gracefully.
|
|
16
|
-
|
|
17
|
-
- :data:`STDERR_BEG_MARKER`: marker used in the console output for the beginning of merged-in stderr output.
|
|
18
|
-
- :data:`STDERR_END_MARKER`: marker used in the console output for the end of merged-in stderr output.
|
|
19
|
-
"""
|
|
20
|
-
import os
|
|
21
|
-
import shlex
|
|
22
|
-
import subprocess
|
|
23
|
-
|
|
24
|
-
from collections.abc import Callable, Iterable, Iterator, MutableMapping
|
|
25
|
-
from contextlib import contextmanager
|
|
26
|
-
from typing import Any, cast, overload
|
|
27
|
-
|
|
28
|
-
from ae.base import UNSET, dummy_function, env_str, norm_name # type: ignore
|
|
29
|
-
from ae.system import active_venv, load_env_var_defaults # type: ignore
|
|
30
|
-
from ae.core import main_app_instance, AppBase # type: ignore
|
|
31
|
-
from ae.console import MAIN_SECTION_NAME, ConsoleApp # type: ignore
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
__version__ = '0.3.16'
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
STDERR_BEG_MARKER = 'vvv STDERR vvv' #: :paramref:`ae.shell.sh_exec.output_lines` begin stderr lines marker
|
|
38
|
-
STDERR_END_MARKER = '^^^ STDERR ^^^' #: end stderr lines marker in :paramref:`ae.shell.sh_exec.output_lines`
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def debug_or_verbose(app_obj: ConsoleApp | None = None) -> bool:
|
|
42
|
-
""" determine if the current app runs in debug|verbose mode, while preventing early .get_option() call an app init.
|
|
43
|
-
|
|
44
|
-
:param app_obj: optional ConsoleApp instance (def=main_app_instance()).
|
|
45
|
-
:return: a boolean False when the main app debug level is :data:`~ae.core.DEBUG_LEVEL_DISABLED`
|
|
46
|
-
and the app option --more_verbose is not specified (in cfg-file or at the command line),
|
|
47
|
-
else True.
|
|
48
|
-
|
|
49
|
-
.. note:: the return value on app startup/initialization, before the command line parsing, is always True.
|
|
50
|
-
|
|
51
|
-
.. hint::
|
|
52
|
-
the debug mode can be activated via the :class:`~ae.console.ConsoleApp` option `debug_level`, specified either
|
|
53
|
-
in a config file or via the command line options. the verbose mode get activated via the `more_verbose` option.
|
|
54
|
-
"""
|
|
55
|
-
app_obj = app_obj or main_app_instance()
|
|
56
|
-
return bool(
|
|
57
|
-
not app_obj # prevent exception in early app startup and in test runs
|
|
58
|
-
or app_obj.debug # main_app.debug_level > DEBUG_LEVEL_DISABLED
|
|
59
|
-
or not isinstance(app_obj, ConsoleApp) # return True for pure ae.core.AppBase instances
|
|
60
|
-
or not getattr(app_obj, '_parsed_arguments', None) # ConsoleApp._parsed_arguments args Namespace not created
|
|
61
|
-
or app_obj.get_option('more_verbose')) # optional ConsoleApp instance verbose option
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def get_domain_user_var(variable_name: str, domain: str = "", user: str = "") -> Any:
|
|
65
|
-
""" determine the value of an OS environment variable for a specific domain and/or username.
|
|
66
|
-
|
|
67
|
-
:param variable_name: name of the config variable.
|
|
68
|
-
:param domain: name of the domain.
|
|
69
|
-
:param user: name/id of the user to get a user-specific value of.
|
|
70
|
-
:return: domain/user-specific value of the specified config variable.
|
|
71
|
-
"""
|
|
72
|
-
parts = (MAIN_SECTION_NAME, variable_name.lower(), f'AT_{norm_name(domain)}'.lower(), norm_name(user).lower())
|
|
73
|
-
value = None
|
|
74
|
-
if domain:
|
|
75
|
-
if user:
|
|
76
|
-
value = env_str('_'.join(parts), convert_name=True)
|
|
77
|
-
if value is None:
|
|
78
|
-
value = env_str('_'.join(parts[:-1]), convert_name=True)
|
|
79
|
-
elif user:
|
|
80
|
-
value = env_str('_'.join(parts[:2] + parts[-1:]), convert_name=True)
|
|
81
|
-
|
|
82
|
-
if value is None:
|
|
83
|
-
value = env_str('_'.join(parts[:2]), convert_name=True)
|
|
84
|
-
|
|
85
|
-
return value
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def hint(command: str, action: Callable | str, message_suffix: str = "") -> str:
|
|
89
|
-
""" return hint string in debug/verbose mode, to be appended onto a shell/console output.
|
|
90
|
-
|
|
91
|
-
:param command: shell command.
|
|
92
|
-
:param action: shell command action function/method.
|
|
93
|
-
:param message_suffix: extra message text, added to the end of the returned console output string.
|
|
94
|
-
:return: in debug/verbose mode return a string with leading line feed to be sent
|
|
95
|
-
to console output, else return an empty string.
|
|
96
|
-
"""
|
|
97
|
-
if not isinstance(action, str):
|
|
98
|
-
action = action.__name__
|
|
99
|
-
return f"{os.linesep} (run: {command} {action}{message_suffix})" if debug_or_verbose() else ""
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
@contextmanager
|
|
103
|
-
def in_os_env(start_dir: str = "") -> Iterator[MutableMapping[str, str]]:
|
|
104
|
-
""" temporarily add environment variables from the dotenv files that not exist in os.environ to it.
|
|
105
|
-
|
|
106
|
-
:param start_dir: path to the folder where the first dotenv file (with the highest priority) is stored.
|
|
107
|
-
:return: yielding the os env variables that got added to os.environ in this temporary context.
|
|
108
|
-
"""
|
|
109
|
-
loaded_env_vars = load_env_var_defaults(start_dir, os.environ)
|
|
110
|
-
try:
|
|
111
|
-
yield loaded_env_vars
|
|
112
|
-
finally:
|
|
113
|
-
for var_name in loaded_env_vars:
|
|
114
|
-
os.environ.pop(var_name)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
@overload
|
|
118
|
-
def mask_token(text: str) -> str: ...
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
@overload
|
|
122
|
-
def mask_token(text: list[str]) -> list[str]: ...
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def mask_token(text: str | list[str]) -> str | list[str]:
|
|
126
|
-
""" hide most parts of any Codeberg/GitHub/GitHub URL tokens found in the specified text/-lines.
|
|
127
|
-
|
|
128
|
-
:param text: text, specified either as str object or as a list of str objects (lines),
|
|
129
|
-
each str/line get searched for URL tokens, to hide/mask the most part of them.
|
|
130
|
-
:return: text with masked URL tokens (only leaving the first/last 3 token characters unmasked).
|
|
131
|
-
|
|
132
|
-
.. note:: see also :func:`ae.base.mask_url` of a more generic way to hide passwords and tokens in URLs.
|
|
133
|
-
"""
|
|
134
|
-
if is_str_arg := isinstance(text, str):
|
|
135
|
-
lines = [text]
|
|
136
|
-
else:
|
|
137
|
-
lines = list(text) # copy to not change text list content
|
|
138
|
-
|
|
139
|
-
url_beg = 'https://' # PDV_REPO_HOST_PROTOCOL
|
|
140
|
-
url_beg_len = len(url_beg)
|
|
141
|
-
for tok_beg, tok_end in ((':', '@codeberg.org'), ('glpat-', '@gitlab.com'), ('ghp_', '@github.com')):
|
|
142
|
-
for idx, line in enumerate(lines):
|
|
143
|
-
beg = -1
|
|
144
|
-
while ((beg := line.find(url_beg, beg + 1)) != -1 and
|
|
145
|
-
(beg := line.find(tok_beg, beg + url_beg_len)) != -1 and
|
|
146
|
-
(end := line.find(tok_end, beg)) != -1):
|
|
147
|
-
line = line[:beg + 3] + "***-masked-token-***" + line[end - 3:]
|
|
148
|
-
lines[idx] = line
|
|
149
|
-
|
|
150
|
-
return lines[0] if is_str_arg else lines
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
154
|
-
def sh_exec(command_line: str, extra_args: Iterable[str] = (), console_input: str = "",
|
|
155
|
-
output_lines: list[str] | None = None, app_obj: AppBase | None = None, shell: bool = False,
|
|
156
|
-
env_vars: dict[str, str] | None = None, err_redirect: int | None = None) -> int:
|
|
157
|
-
""" execute command in the current working directory of the OS console/shell.
|
|
158
|
-
|
|
159
|
-
:param command_line: command line string to execute on the console/shell. could contain command line args
|
|
160
|
-
separated by whitespace characters (alternatively use :paramref:`.extra_args`).
|
|
161
|
-
:param extra_args: optional iterable with extra command line arguments.
|
|
162
|
-
:param console_input: optional string to be sent to the stdin stream of the console/shell.
|
|
163
|
-
:param output_lines: specify a list to be extended with the lines printed on the console/shell stdout stream,
|
|
164
|
-
and to also hide this output on the console. if and how the stderr stream get also
|
|
165
|
-
hidden/redirected to this list can be controlled via the value passed in the argument
|
|
166
|
-
:paramref:`.err_redirect`.
|
|
167
|
-
:param app_obj: optional :class:`~ae.core.AppBase`/:class:`~ae.console.ConsoleApp` instance, used for
|
|
168
|
-
logging. if not specified or None and if :func:`~ae.core.main_app_instance()` returns
|
|
169
|
-
None then the Python :func:`print` function is used.
|
|
170
|
-
specify :data:`~ae.base.UNSET` to suppress any printing/logging output.
|
|
171
|
-
:param shell: pass True to execute command in the default OS shell (for more info check the
|
|
172
|
-
documentation of the parameter :paramref:`~subprocess.run.shell` of the
|
|
173
|
-
:meth:`subprocess.run` function).
|
|
174
|
-
:param env_vars: OS shell environment variables to be used instead of the console/bash defaults.
|
|
175
|
-
:param err_redirect: this argument controls if and how the output of the executed command onto the console
|
|
176
|
-
stderr stream gets captured/redirected. it gets passed directly onto the
|
|
177
|
-
:paramref:`~subprocess.run.stderr` argument of :func:`subprocess.run` function.
|
|
178
|
-
if the argument of :paramref:`.output_lines` is a list, and you specified the
|
|
179
|
-
argument value :data:`subprocess.PIPE`, then the stderr output will get added at the
|
|
180
|
-
end of this list (enclosed between the list items :data:`STDERR_BEG_MARKER` and
|
|
181
|
-
:data:`STDERR_END_MARKER`). if the argument of :paramref:`.output_lines` is a
|
|
182
|
-
list, and you specified the argument value :data:`subprocess.STDOUT` then the stderr
|
|
183
|
-
output will get merged without any markers into this list in the order they get printed.
|
|
184
|
-
specify :data:`subprocess.DEVNULL` to hide any stderr output onto the console/shell
|
|
185
|
-
as well as in the :paramref:`.output_lines` list. if you specify `None`
|
|
186
|
-
(the default argument) then the stderr output will be printed only on the console.
|
|
187
|
-
:return: return code of the executed command or 126 if execution raised any other exception.
|
|
188
|
-
"""
|
|
189
|
-
all_args = command_line + (" " + " ".join(extra_args) if extra_args else "") if shell else (
|
|
190
|
-
shlex.split(command_line) + list(extra_args))
|
|
191
|
-
# noinspection PyTypeChecker
|
|
192
|
-
masked_args = mask_token(all_args)
|
|
193
|
-
if app_obj is None:
|
|
194
|
-
app_obj = main_app_instance()
|
|
195
|
-
print_out = app_obj.po if app_obj else dummy_function if app_obj is UNSET else print
|
|
196
|
-
debug_out = app_obj.dpo if app_obj else dummy_function if app_obj is UNSET else print
|
|
197
|
-
|
|
198
|
-
debug_out(f" . executing {masked_args} at {os.getcwd()=} in {active_venv()=}")
|
|
199
|
-
result: subprocess.CompletedProcess | subprocess.CalledProcessError # having: stdout/stderr/returncode
|
|
200
|
-
try:
|
|
201
|
-
result = subprocess.run(all_args,
|
|
202
|
-
stdout=subprocess.PIPE if isinstance(output_lines, list) else None,
|
|
203
|
-
stderr=err_redirect,
|
|
204
|
-
input=console_input.encode(),
|
|
205
|
-
check=True,
|
|
206
|
-
shell=shell,
|
|
207
|
-
env=env_vars)
|
|
208
|
-
except subprocess.CalledProcessError as exc:
|
|
209
|
-
debug_out(f"**** subprocess.run({masked_args}) returned non-zero exit code {exc.returncode}; {exc=}")
|
|
210
|
-
result = exc
|
|
211
|
-
except Exception as exc: # pylint: disable=broad-except
|
|
212
|
-
print_out(f"**** subprocess.run({masked_args}) raised exception {exc}")
|
|
213
|
-
return (126, )[0] # put return/exit code into tuple for global code search
|
|
214
|
-
|
|
215
|
-
if isinstance(output_lines, list):
|
|
216
|
-
if result.stdout:
|
|
217
|
-
output_lines.extend([line for line in result.stdout.decode().splitlines() if line.strip()])
|
|
218
|
-
if err_redirect == subprocess.PIPE and result.stderr:
|
|
219
|
-
output_lines.append(STDERR_BEG_MARKER)
|
|
220
|
-
output_lines.extend([line for line in result.stderr.decode().splitlines() if line.strip()])
|
|
221
|
-
output_lines.append(STDERR_END_MARKER)
|
|
222
|
-
|
|
223
|
-
return result.returncode
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
227
|
-
def sh_exit_if_exec_err(err_code: int, command_line: str,
|
|
228
|
-
extra_args: Iterable[str] = (), output_lines: list[str] | None = None, exit_on_err: bool = True,
|
|
229
|
-
exit_msg: str = "", app_obj: ConsoleApp | None = None, shell: bool = False,
|
|
230
|
-
env_vars: dict[str, str] | None = None, err_redirect: int | None = subprocess.DEVNULL) -> int:
|
|
231
|
-
""" execute command in the current working directory, optionally capturing console output and exit app on error.
|
|
232
|
-
|
|
233
|
-
:param err_code: error code to pass to the console as exit code if the command set an error code and
|
|
234
|
-
value of the :paramref:`.exit_on_err` argument is `True`.
|
|
235
|
-
:param command_line: command line string to execute. this argument could contain additional command line
|
|
236
|
-
arguments, separated by whitespace characters. alternatively use the argument
|
|
237
|
-
:paramref:`.extra_args` which allows to pass command line argument values,
|
|
238
|
-
with containing space characters.
|
|
239
|
-
:param extra_args: optional iterable of extra command line arguments.
|
|
240
|
-
:param output_lines: optional list extended with the lines printed to stdout/stderr on execution.
|
|
241
|
-
:param exit_on_err: pass False to not exit the app on error.
|
|
242
|
-
:param exit_msg: additional text to print on stdout/console if the app debug level is greater or equal
|
|
243
|
-
to 1 (:data:`~ae.core.DEBUG_LEVEL_ENABLED`) or if an error occurred.
|
|
244
|
-
:param app_obj: :class:`~ae.console.ConsoleApp` instance, used for logging/force-ignorable error.
|
|
245
|
-
:param shell: pass True to execute command in the default OS shell (see :meth:`subprocess.run`).
|
|
246
|
-
:param env_vars: OS shell environment variables to be used instead of the console/bash defaults.
|
|
247
|
-
:param err_redirect: control how the output on stderr gets captured, redirected and returned. see also
|
|
248
|
-
:paramref:`sh_exec.err_redirect` for more details to the supported argument values.
|
|
249
|
-
if this argument is not specified or has the value :data:`subprocess.DEVNULL` then the
|
|
250
|
-
stderr output will get suppressed on the console and will also not get added to the
|
|
251
|
-
list argument in :paramref:`.output_lines`.
|
|
252
|
-
:return: 0 on success, or if an error occurred the error number set by the executed command.
|
|
253
|
-
"""
|
|
254
|
-
if output_lines is None:
|
|
255
|
-
output_lines = []
|
|
256
|
-
output_len = 0
|
|
257
|
-
else:
|
|
258
|
-
output_len = len(output_lines)
|
|
259
|
-
app_obj = app_obj or cast(ConsoleApp, main_app_instance()) # calls app_obj./ConsoleApp.chk() method
|
|
260
|
-
|
|
261
|
-
sh_err = sh_exec(command_line, extra_args=extra_args, output_lines=output_lines, app_obj=app_obj, shell=shell,
|
|
262
|
-
env_vars=env_vars, err_redirect=err_redirect)
|
|
263
|
-
|
|
264
|
-
if app_obj and (app_obj.debug or sh_err and exit_on_err):
|
|
265
|
-
for line in output_lines[output_len:]:
|
|
266
|
-
if app_obj.verbose or not line.startswith("LOG: "): # if verbose show mypy's endless (stderr) log entries
|
|
267
|
-
app_obj.po(" " * 6 + line)
|
|
268
|
-
command = mask_token(f"{command_line} " + " ".join('"' + _a + '"' if " " in _a else _a for _a in extra_args))
|
|
269
|
-
if sh_err == 0:
|
|
270
|
-
app_obj.dpo(f" = successfully executed {command=}")
|
|
271
|
-
else:
|
|
272
|
-
if exit_msg:
|
|
273
|
-
app_obj.po(f" {exit_msg}")
|
|
274
|
-
app_obj.chk(err_code, not exit_on_err, f"sh_exit_if_exec_err({err_code}, {command!r}) error {sh_err}")
|
|
275
|
-
|
|
276
|
-
return sh_err
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|