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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ae_shell
3
- Version: 0.3.16
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.16
67
+ # shell 0.3.18
68
68
 
69
69
  [![GitLab develop](https://img.shields.io/gitlab/pipeline/ae-group/ae_shell/develop?logo=python)](
70
70
  https://gitlab.com/ae-group/ae_shell)
71
71
  [![LatestPyPIrelease](
72
- https://img.shields.io/gitlab/pipeline/ae-group/ae_shell/release0.3.16?logo=python)](
73
- https://gitlab.com/ae-group/ae_shell/-/tree/release0.3.16)
72
+ https://img.shields.io/gitlab/pipeline/ae-group/ae_shell/release0.3.18?logo=python)](
73
+ https://gitlab.com/ae-group/ae_shell/-/tree/release0.3.18)
74
74
  [![PyPIVersions](https://img.shields.io/pypi/v/ae_shell)](
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.16
3
+ # shell 0.3.18
4
4
 
5
5
  [![GitLab develop](https://img.shields.io/gitlab/pipeline/ae-group/ae_shell/develop?logo=python)](
6
6
  https://gitlab.com/ae-group/ae_shell)
7
7
  [![LatestPyPIrelease](
8
- https://img.shields.io/gitlab/pipeline/ae-group/ae_shell/release0.3.16?logo=python)](
9
- https://gitlab.com/ae-group/ae_shell/-/tree/release0.3.16)
8
+ https://img.shields.io/gitlab/pipeline/ae-group/ae_shell/release0.3.18?logo=python)](
9
+ https://gitlab.com/ae-group/ae_shell/-/tree/release0.3.18)
10
10
  [![PyPIVersions](https://img.shields.io/pypi/v/ae_shell)](
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.16
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.16
67
+ # shell 0.3.18
68
68
 
69
69
  [![GitLab develop](https://img.shields.io/gitlab/pipeline/ae-group/ae_shell/develop?logo=python)](
70
70
  https://gitlab.com/ae-group/ae_shell)
71
71
  [![LatestPyPIrelease](
72
- https://img.shields.io/gitlab/pipeline/ae-group/ae_shell/release0.3.16?logo=python)](
73
- https://gitlab.com/ae-group/ae_shell/-/tree/release0.3.16)
72
+ https://img.shields.io/gitlab/pipeline/ae-group/ae_shell/release0.3.18?logo=python)](
73
+ https://gitlab.com/ae-group/ae_shell/-/tree/release0.3.18)
74
74
  [![PyPIVersions](https://img.shields.io/pypi/v/ae_shell)](
75
75
  https://pypi.org/project/ae-shell/#history)
76
76
 
@@ -77,7 +77,7 @@ setup_kwargs: dict[str, Any] = {
77
77
  },
78
78
  'python_requires': '>=3.12',
79
79
  'url': 'https://gitlab.com/ae-group/ae_shell',
80
- 'version': '0.3.16',
80
+ 'version': '0.3.18',
81
81
  'zip_safe': True,
82
82
  }
83
83
 
@@ -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, active_venv
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, sh_exec, sh_exit_if_exec_err)
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 test_sh_exec_catch_any_exception(self, capsys):
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 sh_exec('any_cmd') == (126, )[0]
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 test_sh_exec_catch_exit_code_exception(self, capsys):
270
- assert sh_exec("exit 69", shell=True) == 69
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 sh_exec(sys.executable, extra_args=["-c", "import sys; sys.exit(96)"]) == 96
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 test_sh_exec_console_output(self, capsys, cons_app):
281
- sh_exec('any command')
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', 'command']" in out
309
+ assert " . executing ['any command']" in out
285
310
  assert os.getcwd() in out
286
- assert active_venv() in out
287
- assert "**** subprocess.run(['any', 'command']) raised exception" in out
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
- sh_exec('any command')
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', 'command']) raised exception" in out
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
- sh_exec('any command', app_obj=cons_app) # strange: patch('ae.core.AppBase.debug_level') does not work
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
- sh_exec('any command', app_obj=UNSET)
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 test_sh_exec_console_output_shell(self, capsys):
313
- ret = sh_exec("echo hello world", shell=True)
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 = sh_exec("echo hello world", app_obj=UNSET, shell=True)
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 test_sh_exec_run_args(self, mock_method):
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
- sh_exec(cmd_line, tuple(extra_args))
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
- sh_exec(cmd_line, shell=True)
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
- sh_exec(cmd_line, {_: "any" for _ in extra_args}, shell=True, err_redirect=subprocess.DEVNULL)
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
- sh_exec(cmd_line, extra_args, console_input='con_inp')
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
- sh_exec(cmd_line, extra_args, output_lines=[])
375
+ run_cmd(cmd_line, *extra_args, input='con_inp', output_lines=[], stderr=subprocess.STDOUT)
356
376
 
357
- mock_method.assert_called_with(
358
- shlex.split(cmd_line) + extra_args, stdout=subprocess.PIPE, stderr=None, input=b'',
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
- sh_exec(cmd_line, extra_args, console_input='con_inp', output_lines=[], err_redirect=subprocess.STDOUT)
380
+ env_vars = {'A': "1", 'C': "tst_string value"}
362
381
 
363
- mock_method.assert_called_with(
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
- env_vars = {'A': "1", 'C': "tst_string value"}
384
+ mock_method.assert_called_with((cmd_line, ) + tuple(extra_args), env=env_vars, check=True)
368
385
 
369
- sh_exec(cmd_line, extra_args, env_vars=env_vars)
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 test_sh_exec_run_returned_values(self):
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 sh_exec("cmd_line", output_lines=output_lines, err_redirect=subprocess.PIPE) == RETURN_CODE
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 sh_exec("cmd_line", output_lines=output_lines) == RETURN_CODE
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 sh_exec("cmd_line", output_lines=output_lines, err_redirect=subprocess.STDOUT) == RETURN_CODE
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 test_sh_exec_stderr_redirection(self, capfd, cons_app): # pytest/capsys replaces sys.stdout/.stderr
417
- args = {'command_line': sys.executable,
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 sh_exec(**args) == 0
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 sh_exec(**args, output_lines=redirected) == 0
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 sh_exec(**args, output_lines=redirected, err_redirect=subprocess.DEVNULL) == 0
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 sh_exec(**args, output_lines=redirected, err_redirect=subprocess.PIPE) == 0
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 sh_exec(**args, output_lines=redirected, err_redirect=subprocess.STDOUT) == 0
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 test_sh_exit_if_exec_err_any_command(self, capsys, cons_app):
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 = sh_exit_if_exec_err(0, 'git --version', output_lines=output,
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 test_sh_exit_if_exec_err_any_invalid_command(self, capsys, cons_app, patched_shutdown_wrapper):
479
- ret = sh_exit_if_exec_err(693, 'tst_command_line', exit_on_err=False, exit_msg='tst exit message')
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 test_sh_exit_if_exec_err_caught_shutdown_exception(self, capsys, cons_app, patched_shutdown_wrapper):
488
- ret = patched_shutdown_wrapper(sh_exit_if_exec_err, 693, 'tst_command_line', exit_msg='tst exit message')
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 'sh_exit_if_exec_err(' in ret[0]['error_message']
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 test_sh_exit_if_exec_err_exception(self, capsys, cons_app):
511
+ def test_run_logged_cmd_exception(self, capsys, cons_app):
501
512
  output = ['old output']
502
513
 
503
- ret = sh_exit_if_exec_err(693, "", output_lines=output, exit_on_err=False, exit_msg='tst exit message')
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 active_venv()='{active_venv()}'" in out
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 test_sh_exit_if_exec_err_with_app(self, capsys, cons_app):
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 = sh_exit_if_exec_err(0, "_err", output_lines=output, exit_on_err=False, err_redirect=subprocess.STDOUT)
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 test_sh_exit_if_exec_err_with_app_kwarg_and_exit_msg(self, capsys, cons_app):
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 = sh_exit_if_exec_err(0, 'error-command', output_lines=output,
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 test_sh_exit_if_exec_err_with_app_extending_output(self, capsys, cons_app):
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.sh_exec', new=lambda *_, **kwargs: kwargs['output_lines'].append('new out line') or 0),
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 = sh_exit_if_exec_err(369, "any_cmd", output_lines=output) # extended output_lines and ret==0
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 test_sh_exit_if_exec_err_without_app(self, capsys):
562
+ def test_run_logged_cmd_without_app(self, capsys):
555
563
  output = []
556
564
 
557
- ret = sh_exit_if_exec_err(0, "_err_cmd", output_lines=output, exit_on_err=False, exit_msg='tst exit message')
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 == ""
@@ -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