ae-shell 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ae/shell.py +1433 -0
- ae_shell-0.3.2.dist-info/METADATA +140 -0
- ae_shell-0.3.2.dist-info/RECORD +7 -0
- ae_shell-0.3.2.dist-info/WHEEL +5 -0
- ae_shell-0.3.2.dist-info/licenses/LICENSE.md +676 -0
- ae_shell-0.3.2.dist-info/top_level.txt +1 -0
- ae_shell-0.3.2.dist-info/zip-safe +1 -0
ae/shell.py
ADDED
|
@@ -0,0 +1,1433 @@
|
|
|
1
|
+
"""
|
|
2
|
+
shell execution and environment helpers
|
|
3
|
+
=======================================
|
|
4
|
+
|
|
5
|
+
.. hint::
|
|
6
|
+
this module is designed to provide a comprehensive set of constants, types, and helper functions
|
|
7
|
+
for executing and managing external commands, particularly those related to the Git and Pip
|
|
8
|
+
command-line interfaces and Python virtual environments.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
fundamental shell execution helpers
|
|
12
|
+
-----------------------------------
|
|
13
|
+
|
|
14
|
+
provided helper functions that simplify the execution of shell commands:
|
|
15
|
+
|
|
16
|
+
- :func:`sh_exec`: generic/fundamental function for all other shell execution helpers.
|
|
17
|
+
- :func:`sh_exit_if_exec_err`: extended version of :func:`sh_exec` with automatically checks for errors
|
|
18
|
+
after a command is executed and handles application termination gracefully.
|
|
19
|
+
- :func:`sh_exit_if_git_err`: enables Git command tracing for in-depth debugging, a feature inherited
|
|
20
|
+
by the Git helper functions within this portion.
|
|
21
|
+
- :func:`sh_which`: determines the absolute path of a command executable (e.g., `git`, `pip`).
|
|
22
|
+
- :func:`is_executable`: checks if a given file path corresponds to a valid executable file.
|
|
23
|
+
|
|
24
|
+
shell command logging
|
|
25
|
+
^^^^^^^^^^^^^^^^^^^^^
|
|
26
|
+
|
|
27
|
+
the logging of executed command lines and their console output is highly useful for debugging and protocolling
|
|
28
|
+
purposes. this portion provides the following helper functions to implement logging for external commands.
|
|
29
|
+
logging will be automatically enabled, if the corresponding log file exists.
|
|
30
|
+
|
|
31
|
+
- :func:`sh_log`: writes a single command execution log entry.
|
|
32
|
+
- :func:`sh_logs`: determines the file paths of the currently existing/enabled log files.
|
|
33
|
+
- :data:`SHELL_LOG_FILE_NAME_SUFFIX`: the default filename suffix for shell command log files.
|
|
34
|
+
|
|
35
|
+
.. hint::
|
|
36
|
+
this feature is implemented in :func:`sh_exit_if_git_err` for all the git command execution helpers (``git_*()``)
|
|
37
|
+
of this portion. to enable logging of all executed git commands simply create a log file with the name
|
|
38
|
+
``git_sh.log``, situated in the current working directory and/or in the users home directory (~).
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
temporary directories
|
|
42
|
+
---------------------
|
|
43
|
+
|
|
44
|
+
multiple temporary directories are easily managed with three helper functions provided by this portion. each of them is
|
|
45
|
+
identified by a context id. the first call of :func:`temp_context_get_or_create` does create a new temporary directory
|
|
46
|
+
with an optional subfolder. further calls to this function will either create new contexts or subfolders to an existing
|
|
47
|
+
context. the already created folders of each context can be determined via the function :func:`temp_context_folders`.
|
|
48
|
+
if the context is no longer needed it can be released/cleaned-up by calling the function
|
|
49
|
+
:func:`temp_context_cleanup`.
|
|
50
|
+
|
|
51
|
+
- :func:`temp_context_get_or_create`: creates a new temporary directory for a specific context or
|
|
52
|
+
retrieves the path of an existing one.
|
|
53
|
+
- :func:`temp_context_folders`: retrieves a list of folders within a temporary directory context.
|
|
54
|
+
- :func:`temp_context_cleanup`: cleans up and removes a temporary directory for a specific context.
|
|
55
|
+
|
|
56
|
+
- :data:`GIT_CLONE_CACHE_CONTEXT`: the context identifier used for Git clone downloads.
|
|
57
|
+
- :data:`TempContextType`: type hint for the temporary directory context key.
|
|
58
|
+
- :data:`_temp_folders`: internal variable that stores the temporary folder contexts.
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
git command helpers
|
|
62
|
+
-------------------
|
|
63
|
+
|
|
64
|
+
this portion is providing helper functions for lots of git commands, most of them allow to activate git trace
|
|
65
|
+
for debugging or intensive testing.
|
|
66
|
+
|
|
67
|
+
git commands will be executed in repo root folder of a project. the current working directory will be changed
|
|
68
|
+
accordingly before a git command get executed (and restored to the old working directory after the execution).
|
|
69
|
+
|
|
70
|
+
to support `GIT HOOKS <https://git-scm.com/book/ms/v2/Customizing-Git-Git-Hooks>`__ that execute Python code,
|
|
71
|
+
the Python virtual environment of a project will be activated
|
|
72
|
+
before the git command get executed, and restored to the old value after the git command has finished.
|
|
73
|
+
|
|
74
|
+
extensive debugging using the `GIT TRACE <https://git-scm.com/docs/api-trace>`__ feature of the git commands
|
|
75
|
+
will be activating if your app based on :mod:`~ae.console.ConsoleApp` got executed with
|
|
76
|
+
the --debug_level/-D option specified.
|
|
77
|
+
|
|
78
|
+
- :func:`git_add`: executes the `git add` command to stage changes.
|
|
79
|
+
- :func:`git_any`: executes any generic Git command.
|
|
80
|
+
- :func:`git_branches`: determines branch names in a Git repository.
|
|
81
|
+
- :func:`git_branch_files`: finds added, changed, or deleted files on a specified branch.
|
|
82
|
+
- :func:`git_branch_is_dirty`: checks if a Git branch has uncommitted or unstaged changes.
|
|
83
|
+
- :func:`git_checkout`: executes the `git checkout` command to switch branches.
|
|
84
|
+
- :func:`git_clean`: executes the `git clean` command to remove untracked files.
|
|
85
|
+
- :func:`git_commit`: executes the `git commit` command.
|
|
86
|
+
- :func:`git_commit_files_count`: determines the number of changed files in the last commit.
|
|
87
|
+
- :func:`git_config`: executes the `git config` command.
|
|
88
|
+
- :func:`git_conflicts`: lists any merge conflicts in the repository.
|
|
89
|
+
- :func:`git_describe`: executes the `git describe` command.
|
|
90
|
+
- :func:`git_fetch`: executes the `git fetch` command.
|
|
91
|
+
- :func:`git_init_branch`: initializes a new Git branch.
|
|
92
|
+
- :func:`git_is_clean`: checks if the repository has a clean working directory (no untracked or
|
|
93
|
+
uncommitted files).
|
|
94
|
+
- :func:`git_log_last_commit_date`: determines the date of the last commit.
|
|
95
|
+
- :func:`git_pull`: executes the `git pull` command.
|
|
96
|
+
- :func:`git_push`: executes the `git push` command.
|
|
97
|
+
- :func:`git_remotes`: retrieves the remote URLs of the repository.
|
|
98
|
+
- :func:`git_repo_is_init`: checks if a project directory contains a Git repository.
|
|
99
|
+
- :func:`git_tags`: lists the Git tags.
|
|
100
|
+
- :func:`git_uncommitted`: lists all uncommitted files.
|
|
101
|
+
- :func:`git_user_email`: retrieves the Git user's email.
|
|
102
|
+
- :func:`git_user_name`: retrieves the Git user's name.
|
|
103
|
+
- :func:`git_version_tag`: determines the current version tag of the repository.
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
git command constants and types
|
|
107
|
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
108
|
+
|
|
109
|
+
- :data:`COMMIT_MSG_FILE_NAME`: the default filename for commit messages.
|
|
110
|
+
- :data:`DEF_MAIN_BRANCH`: the name of the default/main branch.
|
|
111
|
+
- :data:`EXEC_GIT_ERR_PREFIX`: the prefix used to mark Git execution errors.
|
|
112
|
+
- :data:`GIT_FOLDER_NAME`: the default name of the Git-internal subfolder.
|
|
113
|
+
- :data:`GIT_REMOTE_ORIGIN`: the default name for the origin remote.
|
|
114
|
+
- :data:`GIT_REMOTE_UPSTREAM`: the default name for the upstream remote.
|
|
115
|
+
- :data:`GIT_RELEASE_REF_PREFIX`: the default prefix, used for release branches.
|
|
116
|
+
- :data:`GIT_VERSION_TAG_PREFIX`: the default prefix, used for version tags.
|
|
117
|
+
- :data:`GitRemotesType`: the type hint for a dictionary of Git remotes.
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
pip command helpers
|
|
121
|
+
-------------------
|
|
122
|
+
|
|
123
|
+
this section groups the helpers for executing Pip commands within a project's virtual environment.
|
|
124
|
+
|
|
125
|
+
- :func:`pip_freeze`: executes `pip freeze` to list all installed packages.
|
|
126
|
+
- :func:`pip_install`: executes `pip install` to install packages.
|
|
127
|
+
- :func:`pip_show`: executes `pip show` to get detailed information about a package.
|
|
128
|
+
- :func:`pip_versions`: determines the available versions of a package from PyPI.
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
pip command constants
|
|
132
|
+
^^^^^^^^^^^^^^^^^^^^^
|
|
133
|
+
|
|
134
|
+
- :data:`PIP_CMD`: the pip command.
|
|
135
|
+
- :data:`PIP_INSTALL_CMD`: the `pip install` command.
|
|
136
|
+
- :data:`PYPI_ROOT_URL`: the production PyPI URL.
|
|
137
|
+
- :data:`PYPI_ROOT_URL_TEST`: the test PyPI URL.
|
|
138
|
+
- :data:`PROJECT_VERSION_SEP`: the separator used in Pip requirements files to ty/fix a project to a version.
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
virtual environment helpers
|
|
142
|
+
---------------------------
|
|
143
|
+
|
|
144
|
+
these helper functions are provided to assist with the management of Python virtual environments.
|
|
145
|
+
|
|
146
|
+
- :func:`activate_venv`: ensures that a virtual environment is activated if it's different from the
|
|
147
|
+
current one.
|
|
148
|
+
- :func:`active_venv`: determines the name of the currently active virtual environment.
|
|
149
|
+
- :func:`in_prj_dir_venv`: a context manager that temporarily changes the working directory and activates
|
|
150
|
+
the project's virtual environment.
|
|
151
|
+
- :func:`venv_bin_path`: determines the bin/scripts path of a virtual environment.
|
|
152
|
+
- :func:`venv_project_path`: finds the project root path associated with a virtual environment.
|
|
153
|
+
|
|
154
|
+
the following example installs the required packages of a project into its local virtual environment by
|
|
155
|
+
using the :func:`in_prj_dir_venv` context manager together with the shell execution function :func:`sh_exec`
|
|
156
|
+
and the constant :data:`PIP_INSTALL_CMD`::
|
|
157
|
+
|
|
158
|
+
with in_prj_dir_venv(project_root_path):
|
|
159
|
+
sh_err = sh_exec(PIP_INSTALL_CMD + "-r requirements.txt")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
miscellaneous helpers
|
|
163
|
+
---------------------
|
|
164
|
+
|
|
165
|
+
this section includes various other utility functions and classes.
|
|
166
|
+
|
|
167
|
+
- :func:`bytes_file_diff`: returns the differences between a byte buffer and a file, using the `git diff`
|
|
168
|
+
command.
|
|
169
|
+
- :func:`check_commit_msg_file`: checks for the existence of a commit message file.
|
|
170
|
+
- :func:`check_if`: terminates the application with an error message if a specified condition is not met.
|
|
171
|
+
- :func:`debug_or_verbose`: checks if the application is running in debug or verbose mode.
|
|
172
|
+
- :func:`exit_error`: terminates the application with a specific exit code and a custom message.
|
|
173
|
+
- :func:`get_domain_user_variable`: retrieves a configuration variable value for a specific domain and/or user.
|
|
174
|
+
- :func:`get_main_app`: retrieves the main application instance, or a mock instance if one does not exist.
|
|
175
|
+
- :func:`get_pypi_versions`: determines all available release versions of a package on PyPI.
|
|
176
|
+
- :func:`hint`: provides a hint message based on the provided arguments.
|
|
177
|
+
- :func:`prg_git_project_path`: determines the project root path from the current working directory.
|
|
178
|
+
|
|
179
|
+
- :class:`MockedMainApp`: a mock class for a main application instance.
|
|
180
|
+
|
|
181
|
+
- :data:`PPF`: a pre-configured :mod:`pprint` formatter for pretty indented console output.
|
|
182
|
+
- :data:`STDERR_BEG_MARKER`: marker used in the console output for the beginning of stderr output.
|
|
183
|
+
- :data:`STDERR_END_MARKER`: marker used in the console output for the end of stderr output.
|
|
184
|
+
"""
|
|
185
|
+
import os
|
|
186
|
+
import pprint
|
|
187
|
+
import shlex
|
|
188
|
+
import subprocess
|
|
189
|
+
import sys
|
|
190
|
+
import tempfile
|
|
191
|
+
|
|
192
|
+
from contextlib import contextmanager
|
|
193
|
+
from urllib.parse import urlparse
|
|
194
|
+
from typing import Optional, Iterator, Iterable, cast, Callable, Any, Union, MutableMapping
|
|
195
|
+
|
|
196
|
+
import requests
|
|
197
|
+
from packaging.version import Version
|
|
198
|
+
|
|
199
|
+
from ae.base import ( # type: ignore
|
|
200
|
+
DEF_PROJECT_PARENT_FOLDER, dummy_function, env_str, in_wd, load_env_var_defaults, norm_name, norm_path, now_str,
|
|
201
|
+
os_path_isdir, os_path_isfile, os_path_join, os_path_sep, read_file, write_file)
|
|
202
|
+
from ae.core import main_app_instance # type: ignore
|
|
203
|
+
from ae.console import MAIN_SECTION_NAME, ConsoleApp # type: ignore
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
__version__ = '0.3.2'
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
COMMIT_MSG_FILE_NAME = '.commit_msg.txt' #: name of the file containing the commit message
|
|
210
|
+
DEF_MAIN_BRANCH = 'develop' #: main/develop/default branch name
|
|
211
|
+
EXEC_GIT_ERR_PREFIX = "sh_exec() returned error " #: used by sh_exit_if_exec_err to mark error in 1st output line
|
|
212
|
+
GIT_CLONE_CACHE_CONTEXT = 'shell.git_clone' #: temp directory context for git clone downloads
|
|
213
|
+
GIT_FOLDER_NAME = '.git' #: git subfolder in project path root of local repository
|
|
214
|
+
GIT_REMOTE_ORIGIN = 'origin' #: git origin remote name of (fork) repository in user account
|
|
215
|
+
GIT_REMOTE_UPSTREAM = 'upstream' #: git upstream remote name of original/forked repository
|
|
216
|
+
GIT_RELEASE_REF_PREFIX = 'release' #: git repository release branch name prefix
|
|
217
|
+
GIT_VERSION_TAG_PREFIX = 'v' #: git repository version tag prefix
|
|
218
|
+
PIP_CMD = "python -m pip" #: pip command using python venvs, especially on Windows
|
|
219
|
+
PIP_INSTALL_CMD = f"{PIP_CMD} install" #: pip install command
|
|
220
|
+
PPF = pprint.PrettyPrinter(indent=6, width=189, depth=12).pformat #: formatter for console printouts
|
|
221
|
+
PROJECT_VERSION_SEP = '==' #: separates package name and version in pip req files
|
|
222
|
+
PYPI_ROOT_URL = "https://pypi.org" #: PyPI cheeseshop production domain with service
|
|
223
|
+
PYPI_ROOT_URL_TEST = "https://test.pypi.org" #: PyPI cheeseshop test domain with service
|
|
224
|
+
SHELL_LOG_FILE_NAME_SUFFIX = "_sh.log" #: default file name (suffix) of the shell log file
|
|
225
|
+
STDERR_BEG_MARKER = "vvv STDERR vvv" #: :paramref:`ae.shell.sh_exec.lines_output` begin stderr lines
|
|
226
|
+
STDERR_END_MARKER = "^^^ STDERR ^^^" #: end stderr lines in :paramref:`ae.shell.sh_exec.lines_output`
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# types ---------------------------------------------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
GitRemotesType = dict[str, str] #: git remote urls dict with keys like 'origin'/'upstream'
|
|
232
|
+
TempContextType = str #: id/key of a temporary directory context
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# global variables ----------------------------------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
_temp_folders: dict[TempContextType, tuple[tempfile.TemporaryDirectory, list[str]]] = {} #: temporary folders
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# helper functions ----------------------------------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def activate_venv(name: str = "") -> str:
|
|
243
|
+
""" ensure to activate a virtual environment if it is different to the current one (the one on Python/app start).
|
|
244
|
+
|
|
245
|
+
:param name: the name of the venv to activate. if this arg is empty or not specified, then the venv
|
|
246
|
+
of the project in the current working directory tree will be activated.
|
|
247
|
+
:return: the name of the previously active venv
|
|
248
|
+
or an empty string if the requested or no venv was active, or if venv is not supported.
|
|
249
|
+
"""
|
|
250
|
+
main_app = get_main_app() # only for console outputs
|
|
251
|
+
old_name = active_venv()
|
|
252
|
+
bin_path = venv_bin_path(name)
|
|
253
|
+
if not bin_path:
|
|
254
|
+
if name and old_name:
|
|
255
|
+
main_app.dpo(f" * the venv '{name}' does not exists - skipping switch from current venv '{old_name}'")
|
|
256
|
+
else:
|
|
257
|
+
main_app.vpo(f" # venv {name=} activation skipped {os.getcwd()=} {old_name=} {bin_path=}")
|
|
258
|
+
return ""
|
|
259
|
+
|
|
260
|
+
activate_script_path = os_path_join(bin_path, 'activate')
|
|
261
|
+
if not os_path_isfile(activate_script_path):
|
|
262
|
+
main_app.po(f" * skipping venv activation, because activate script '{activate_script_path}' not found")
|
|
263
|
+
return ""
|
|
264
|
+
|
|
265
|
+
new_name = bin_path.split(os_path_sep)[-2]
|
|
266
|
+
if old_name == new_name:
|
|
267
|
+
main_app.vpo(f" _ skipped activation of venv '{new_name}' because it is already activated")
|
|
268
|
+
return ""
|
|
269
|
+
|
|
270
|
+
main_app.dpo(f" - activating venv: switching from current venv '{old_name}' to '{new_name}'")
|
|
271
|
+
output: list[str] = [] # venv activation command line inspired by https://stackoverflow.com/questions/7040592
|
|
272
|
+
sh_exit_if_exec_err(323, f"env -i bash -c 'set -a && source {activate_script_path} && env -0'",
|
|
273
|
+
lines_output=output, shell=True)
|
|
274
|
+
if output and "\0" in output[0]: # fix error for APP_PRJ (e.g. kivy_lisz)
|
|
275
|
+
os.environ.update(line.split("=", maxsplit=1) for line in output[0].split("\0")) # type: ignore
|
|
276
|
+
|
|
277
|
+
return old_name
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def active_venv() -> str:
|
|
281
|
+
""" determine the virtual environment that is currently active.
|
|
282
|
+
|
|
283
|
+
.. note:: the current venv gets set via `data:`os.environ` on start of this Python app or by :func:`activate_venv`.
|
|
284
|
+
|
|
285
|
+
:return: the name of the currently active venv.
|
|
286
|
+
"""
|
|
287
|
+
return os.getenv('VIRTUAL_ENV', "").split(os_path_sep)[-1]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def bytes_file_diff(file_content: bytes, file_path: str, line_sep: str = os.linesep) -> str:
|
|
291
|
+
""" return the differences between the content of a file against the specified file content buffer.
|
|
292
|
+
|
|
293
|
+
:param file_content: older file bytes to be compared against the file content of the file specified by the
|
|
294
|
+
:paramref:`~bytes_file_diff.file_path` argument.
|
|
295
|
+
:param file_path: path to the file of which newer content gets compared against the file bytes specified
|
|
296
|
+
by the :paramref:`~bytes_file_diff.file_content` argument.
|
|
297
|
+
:param line_sep: string used to prefix, separate and indent the lines in the returned output string.
|
|
298
|
+
:return: differences between the two file contents, compiled with the `git diff` command.
|
|
299
|
+
"""
|
|
300
|
+
with tempfile.NamedTemporaryFile('w+b', delete=False) as tfp: # delete_on_close kwarg available in Python 3.12+
|
|
301
|
+
tfp.write(file_content)
|
|
302
|
+
tfp.close()
|
|
303
|
+
output = sh_exit_if_git_err(72, "git diff", extra_args=("--no-index", tfp.name, file_path), exit_on_err=False)
|
|
304
|
+
os.remove(tfp.name)
|
|
305
|
+
|
|
306
|
+
if output and not output[0].startswith(line_sep):
|
|
307
|
+
output[0] = line_sep + output[0]
|
|
308
|
+
|
|
309
|
+
return line_sep.join(output)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def check_commit_msg_file(project_path: str, *hint_args, commit_msg_file: str = COMMIT_MSG_FILE_NAME) -> str:
|
|
313
|
+
""" check if the commit message file exists and if yes return the path of it.
|
|
314
|
+
|
|
315
|
+
:param project_path: project root path.
|
|
316
|
+
:param hint_args: hint arguments.
|
|
317
|
+
:param commit_msg_file: name of the git commit message file (def=COMMIT_MSG_FILE_NAME).
|
|
318
|
+
:return: the path of the git commit message file of this project.
|
|
319
|
+
:raises: exit_error(381) if the commit message file does not exist.
|
|
320
|
+
"""
|
|
321
|
+
commit_msg_file = os_path_join(project_path, commit_msg_file)
|
|
322
|
+
if not os_path_isfile(commit_msg_file) or not read_file(commit_msg_file):
|
|
323
|
+
hint_msg = hint(*hint_args) if hint_args else ""
|
|
324
|
+
exit_error(381, f"missing or unreadable commit message/file {commit_msg_file}{hint_msg}")
|
|
325
|
+
return commit_msg_file
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def check_if(error_code: int, check_result: bool, error_message: str):
|
|
329
|
+
""" exit/quit this console app if the `check_result` argument is False and the `force` app option is False. """
|
|
330
|
+
if not check_result:
|
|
331
|
+
main_app = get_main_app()
|
|
332
|
+
if left_forces := main_app.get_option('force'):
|
|
333
|
+
main_app.set_option('force', left_forces - 1, save_to_config=False)
|
|
334
|
+
main_app.po(f" ### forced to ignore/skip error {error_code}: {error_message}")
|
|
335
|
+
else:
|
|
336
|
+
exit_error(error_code, error_message + os.linesep + " add (another) --force to ignore&skip this error")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def debug_or_verbose() -> bool:
|
|
340
|
+
""" determine if the current app runs in debug|verbose mode, while preventing early .get_option() call an app init.
|
|
341
|
+
|
|
342
|
+
:return: a boolean False when the main app debug level is :data:`~ae.core.DEBUG_LEVEL_DISABLED`
|
|
343
|
+
and the app option 'more_verbose' is not specified (in cfg-file or at the command line),
|
|
344
|
+
else True.
|
|
345
|
+
|
|
346
|
+
.. note:: the return value on app startup/initialization, before the command line parsing, is always True.
|
|
347
|
+
|
|
348
|
+
.. hint::
|
|
349
|
+
the debug mode can be activated via the :class:`~ae.console.ConsoleApp` option `debug_level`, specified either
|
|
350
|
+
in a config file or via the command line options. the verbose mode get activated via the `more_verbose` option.
|
|
351
|
+
"""
|
|
352
|
+
main_app = get_main_app()
|
|
353
|
+
# noinspection PyProtectedMember
|
|
354
|
+
return bool(
|
|
355
|
+
main_app.debug # main_app.debug_level > DEBUG_LEVEL_DISABLED
|
|
356
|
+
or not main_app._parsed_arguments # pylint: disable=protected-access
|
|
357
|
+
or main_app.get_option('more_verbose')) # optional app option
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def exit_error(error_code: int, error_message: str):
|
|
361
|
+
""" quit this shell script, optionally displaying an error message. """
|
|
362
|
+
main_app = get_main_app()
|
|
363
|
+
if error_code <= 9:
|
|
364
|
+
main_app.show_help()
|
|
365
|
+
if error_message:
|
|
366
|
+
main_app.po("***** " + error_message)
|
|
367
|
+
|
|
368
|
+
if not main_app.verbose: # if not in verbose debug mode then
|
|
369
|
+
temp_context_cleanup() # cleanup default context and git clone context
|
|
370
|
+
temp_context_cleanup(GIT_CLONE_CACHE_CONTEXT)
|
|
371
|
+
|
|
372
|
+
main_app.shutdown(error_code)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def get_domain_user_variable(main_app: ConsoleApp, variable_name: str, domain: str = "", user: str = "") -> Any:
|
|
376
|
+
""" determine the value of a config variable for a specific domain and/or username.
|
|
377
|
+
|
|
378
|
+
:param main_app: main app instance.
|
|
379
|
+
:param variable_name: name of the config variable.
|
|
380
|
+
:param domain: name of the domain.
|
|
381
|
+
:param user: name/id of the user to get a user-specific value of.
|
|
382
|
+
:return: domain/user-specific value of the specified config variable.
|
|
383
|
+
"""
|
|
384
|
+
value = None
|
|
385
|
+
if domain:
|
|
386
|
+
if user:
|
|
387
|
+
value = main_app.get_variable(f'{variable_name}_AT_{norm_name(domain)}_{norm_name(user)}'.lower())
|
|
388
|
+
if value is None:
|
|
389
|
+
value = main_app.get_variable(f'{variable_name}_AT_{norm_name(domain)}'.lower())
|
|
390
|
+
elif user:
|
|
391
|
+
value = main_app.get_variable(f'{variable_name}_{norm_name(user)}'.lower())
|
|
392
|
+
|
|
393
|
+
if value is None:
|
|
394
|
+
value = main_app.get_variable(variable_name.lower())
|
|
395
|
+
|
|
396
|
+
return value
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def get_main_app() -> ConsoleApp:
|
|
400
|
+
""" determine the main ConsoleApp instance..
|
|
401
|
+
|
|
402
|
+
:return: ConsoleApp instance of the main app (after their instantiation).
|
|
403
|
+
"""
|
|
404
|
+
main_app = main_app_instance()
|
|
405
|
+
if not main_app:
|
|
406
|
+
main_app = MockedMainApp()
|
|
407
|
+
|
|
408
|
+
return cast(ConsoleApp, main_app)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def get_pypi_versions(pip_name: str, pypi_test: Optional[bool] = None) -> list[str]:
|
|
412
|
+
""" determine all the available release versions of a package hosted at the PyPI 'Cheese Shop'.
|
|
413
|
+
|
|
414
|
+
:param pip_name: pip|package|project name to get release versions from.
|
|
415
|
+
:param pypi_test: pass True to use the test version of PyPI (at test.pypi.org). if not specified or None
|
|
416
|
+
then the test version of PyPI will be used if :paramref:`~get_pypi_versions.pip_name`
|
|
417
|
+
starts with 'aetst' (the projects namespace used for the pjm integration tests).
|
|
418
|
+
:return: list of released versions (the latest last) or
|
|
419
|
+
on error a list with a single empty string item.
|
|
420
|
+
"""
|
|
421
|
+
if pypi_test is None:
|
|
422
|
+
pypi_test = pip_name.startswith('aetst') # no project path available to check for 'TsT' parent folder
|
|
423
|
+
pypi_root_url = PYPI_ROOT_URL_TEST if pypi_test else PYPI_ROOT_URL
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
response = requests.get(f"{pypi_root_url}/pypi/{pip_name}/json") # pylint: disable=missing-timeout
|
|
427
|
+
response.raise_for_status() # raise HTTPError
|
|
428
|
+
data = response.json()
|
|
429
|
+
versions = list(data['releases'].keys())
|
|
430
|
+
versions.sort(key=Version)
|
|
431
|
+
return versions
|
|
432
|
+
|
|
433
|
+
except (KeyError, ValueError, Exception): # pylint: disable=broad-exception-caught
|
|
434
|
+
# catching too: requests.exceptions.HTTPError/.JSONDecodeError
|
|
435
|
+
return [""] # ignore error on invalid pip_name/page-not-found/never released to PyPi
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def git_add(project_path: str, *extra_args: str):
|
|
439
|
+
""" execute the git add command.
|
|
440
|
+
|
|
441
|
+
:param project_path: project path.
|
|
442
|
+
:param extra_args: additional arguments passed onto git add command. default=["-A"].
|
|
443
|
+
"""
|
|
444
|
+
with in_prj_dir_venv(project_path):
|
|
445
|
+
sh_exit_if_git_err(331, "git add", extra_args=extra_args or ["-A"])
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def git_any(project_path: str, *args: str) -> list[str]:
|
|
449
|
+
""" execute any git command.
|
|
450
|
+
|
|
451
|
+
:param project_path: path to project root folder.
|
|
452
|
+
:param args: arguments passed onto the git executable. first arg is the git command.
|
|
453
|
+
:return: list of console output lines of the git command optionally including the exit error code
|
|
454
|
+
(marked with :data:`EXEC_GIT_ERR_PREFIX` in the first returned list item),
|
|
455
|
+
like returned by :func:`sh_exit_if_git_err`.
|
|
456
|
+
"""
|
|
457
|
+
with in_prj_dir_venv(project_path):
|
|
458
|
+
output = sh_exit_if_git_err(329, "git", extra_args=args)
|
|
459
|
+
return output
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def git_branches(project_path: str, *extra_args: str) -> list[str]:
|
|
463
|
+
""" determine all branch names with the git branch command.
|
|
464
|
+
|
|
465
|
+
:param project_path: path to project root folder.
|
|
466
|
+
:param extra_args: additional arguments passed onto git branch command. default=("-a", "--no-color").
|
|
467
|
+
:return: list of branch names of the project repo.
|
|
468
|
+
"""
|
|
469
|
+
with in_prj_dir_venv(project_path):
|
|
470
|
+
all_branches = sh_exit_if_git_err(327, "git branch", extra_args=extra_args or ("-a", "--no-color"))
|
|
471
|
+
return [branch_name[2:] for branch_name in all_branches]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def git_branch_files(project_path: str, branch_or_tag: str = DEF_MAIN_BRANCH, untracked: bool = False,
|
|
475
|
+
skip_file_path: Callable[[str], bool] = lambda _: False) -> set[str]:
|
|
476
|
+
""" find all added/changed/deleted/renamed/unstaged worktree files that are not merged into the main branch.
|
|
477
|
+
|
|
478
|
+
:param project_path: path of the project root folder. pass empty string to use the current working directory.
|
|
479
|
+
:param branch_or_tag: branch(es)/tag(s)/commit(s) passed to `git diff <https://git-scm.com/docs/git-diff>`__
|
|
480
|
+
to specify the changed files between version(s).
|
|
481
|
+
:param skip_file_path: called for each found file passing the file path relative to the project root folder
|
|
482
|
+
(specified by the :paramref:`~find_git_branch_files.project_path` argument), returning
|
|
483
|
+
True to exclude/skip the file with passed file path.
|
|
484
|
+
:param untracked: pass True to include untracked files from the returned result set.
|
|
485
|
+
:return: set of file paths relative to worktree root specified by the project root path
|
|
486
|
+
specified by the :paramref:`~find_git_branch_files.project_path` argument.
|
|
487
|
+
|
|
488
|
+
.. hint:: see also func:`git_uncommitted` and the unit tests for the differences between them.
|
|
489
|
+
"""
|
|
490
|
+
file_paths = set()
|
|
491
|
+
|
|
492
|
+
def _call(_cmd: str, _args: tuple[str, ...], _dedent: int = 0):
|
|
493
|
+
_output = sh_exit_if_git_err(318, _cmd, extra_args=_args, exit_on_err=False)
|
|
494
|
+
for _fil_path in _output:
|
|
495
|
+
_fil_path = _fil_path[_dedent:]
|
|
496
|
+
if not skip_file_path(_fil_path):
|
|
497
|
+
file_paths.add(_fil_path)
|
|
498
|
+
|
|
499
|
+
with in_prj_dir_venv(project_path):
|
|
500
|
+
if untracked:
|
|
501
|
+
_call("git ls-files", ("--cached", "--others"))
|
|
502
|
+
_call("git status", ("--find-renames", "--porcelain", "--untracked-files", "-v"), _dedent=3)
|
|
503
|
+
# --compact-summary is alternative to --name-only
|
|
504
|
+
_call("git diff", ("--find-renames", "--full-index", "--name-only", "--no-color", branch_or_tag))
|
|
505
|
+
|
|
506
|
+
return file_paths
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def git_branch_remotes(project_path: str, branch_pattern: str, remote_names: Iterable[str] = ()) -> list[str]:
|
|
510
|
+
""" return the remote names where the specified branch name exists.
|
|
511
|
+
|
|
512
|
+
:param project_path: path of the project root folder.
|
|
513
|
+
:param branch_pattern: branch name pattern to search for.
|
|
514
|
+
:param remote_names: iterable with the remote names. determined with :func:`git_remotes` if not specified.
|
|
515
|
+
:return: list of remote names where the branch exists.
|
|
516
|
+
"""
|
|
517
|
+
if not remote_names:
|
|
518
|
+
remote_names = git_remotes(project_path)
|
|
519
|
+
|
|
520
|
+
remotes = []
|
|
521
|
+
for remote_name in remote_names:
|
|
522
|
+
output = git_any(project_path, 'ls-remote', '--heads', remote_name, branch_pattern) # --branches in future
|
|
523
|
+
if output and not output[0].startswith(EXEC_GIT_ERR_PREFIX):
|
|
524
|
+
remotes.append(remote_name)
|
|
525
|
+
return remotes
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def git_checkout(project_path: str, *extra_args: str,
|
|
529
|
+
new_branch: str = "", exit_on_err: bool = True, force: bool = False, remote_names: Iterable[str] = ()
|
|
530
|
+
) -> str:
|
|
531
|
+
""" checkout git branch.
|
|
532
|
+
|
|
533
|
+
:param project_path: path of the project root folder.
|
|
534
|
+
:param extra_args: additional arguments passed onto git checkout command.
|
|
535
|
+
:param new_branch: new branch name to create and check out.
|
|
536
|
+
:param exit_on_err: specify False to not exit the Python app on any git checkout error.
|
|
537
|
+
:param force: pass True to ignore uncommitted files and if undefined branch.
|
|
538
|
+
:param remote_names: iterable with the remote names. determined with :func:`git_remotes` if not specified.
|
|
539
|
+
:return: error message or empty string if no error occurred.
|
|
540
|
+
"""
|
|
541
|
+
if not force and new_branch:
|
|
542
|
+
if (uncommitted_files := git_uncommitted(project_path)) and new_branch in git_branches(project_path):
|
|
543
|
+
current_branch = git_current_branch(project_path)
|
|
544
|
+
return f"{new_branch=} exists already and {current_branch=} has {uncommitted_files=}"
|
|
545
|
+
if branch_remotes := git_branch_remotes(project_path, new_branch, remote_names=remote_names):
|
|
546
|
+
return f"{new_branch} exists already on the remote(s): {', '.join(branch_remotes)}"
|
|
547
|
+
|
|
548
|
+
args = ["--quiet"]
|
|
549
|
+
if new_branch:
|
|
550
|
+
args.extend(["-b", new_branch])
|
|
551
|
+
args.extend(extra_args)
|
|
552
|
+
|
|
553
|
+
with in_prj_dir_venv(project_path):
|
|
554
|
+
output = sh_exit_if_git_err(357, "git checkout", extra_args=args, exit_on_err=exit_on_err)
|
|
555
|
+
|
|
556
|
+
return os.linesep.join(output)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def git_clone(repo_root: str, project_name: str, *extra_args: str,
|
|
560
|
+
branch_or_tag: str = "", parent_path: str = "", enable_log: bool = False) -> str:
|
|
561
|
+
""" clone a git remote repository onto your local machine.
|
|
562
|
+
|
|
563
|
+
:param repo_root: repository root url without the project name to clone.
|
|
564
|
+
:param project_name: project name to clone.
|
|
565
|
+
:param extra_args: extra arguments passed onto the git clone command.
|
|
566
|
+
:param branch_or_tag: repo branch to clone. if not specified then the main branch will be cloned.
|
|
567
|
+
:param parent_path: destination path on the local machine to clone onto. if not specified a temporary folder
|
|
568
|
+
will be used.
|
|
569
|
+
:param enable_log: pass True to enable git shell command logging.
|
|
570
|
+
:return: path to the cloned repository folder or empty string if an error occurred.
|
|
571
|
+
|
|
572
|
+
.. hint:: there could occur a user/password prompt if repo is private/invalid!
|
|
573
|
+
"""
|
|
574
|
+
if not parent_path:
|
|
575
|
+
parent_path = temp_context_get_or_create(context=GIT_CLONE_CACHE_CONTEXT, folder_name=DEF_PROJECT_PARENT_FOLDER)
|
|
576
|
+
project_path = norm_path(os_path_join(parent_path, project_name))
|
|
577
|
+
|
|
578
|
+
args = []
|
|
579
|
+
if branch_or_tag:
|
|
580
|
+
# https://stackoverflow.com/questions/791959/download-a-specific-tag-with-git says:
|
|
581
|
+
# add -b <tag> to specify a release tag/branch to clone, adding --single-branch will speed up the download
|
|
582
|
+
args.append("--branch")
|
|
583
|
+
args.append(branch_or_tag)
|
|
584
|
+
args.append("--single-branch")
|
|
585
|
+
if extra_args:
|
|
586
|
+
args.extend(extra_args)
|
|
587
|
+
args.append(f"{repo_root}/{project_name}.git")
|
|
588
|
+
|
|
589
|
+
with in_prj_dir_venv(parent_path):
|
|
590
|
+
output = sh_exit_if_git_err(315, "git clone", extra_args=args, exit_on_err=False,
|
|
591
|
+
log_enable_dir=project_path if enable_log else "")
|
|
592
|
+
|
|
593
|
+
if output and output[0].startswith(EXEC_GIT_ERR_PREFIX):
|
|
594
|
+
return ""
|
|
595
|
+
return project_path
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def git_commit(project_path: str, project_version: str, *extra_args: str,
|
|
599
|
+
commit_msg_text: str = "", commit_msg_file: str = COMMIT_MSG_FILE_NAME):
|
|
600
|
+
""" execute the command 'git commit' for the specified project.
|
|
601
|
+
|
|
602
|
+
:param project_path: path of the project root folder, in which this git command gets executed.
|
|
603
|
+
:param project_version: project version string.
|
|
604
|
+
:param extra_args: additional options or args passed to the `git commit` command line,
|
|
605
|
+
e.g., ["--patch", "--dry-run"]. except from the --file option, which will be added
|
|
606
|
+
by this function with the name of the git commit message file.
|
|
607
|
+
:param commit_msg_text: used commit message. if specified then the argument
|
|
608
|
+
in :paramref:`~git_commit.commit_msg_file` will be ignored.
|
|
609
|
+
:param commit_msg_file: name of the git commit message file (def=:data:`COMMIT_MSG_FILE_NAME`).
|
|
610
|
+
"""
|
|
611
|
+
file_name = check_commit_msg_file(project_path, commit_msg_file=commit_msg_file)
|
|
612
|
+
if commit_msg_text:
|
|
613
|
+
args = ["-m", commit_msg_text]
|
|
614
|
+
else:
|
|
615
|
+
commit_msg = read_file(file_name)
|
|
616
|
+
commit_msg = commit_msg.replace('{apk_ext}', '{{apk_ext}}').format(project_version=project_version)
|
|
617
|
+
write_file(file_name, commit_msg)
|
|
618
|
+
args = [f"--file={file_name}"] # not valid: "--file <file>" nor "--file='<file>'
|
|
619
|
+
args.extend(extra_args)
|
|
620
|
+
|
|
621
|
+
with in_prj_dir_venv(project_path):
|
|
622
|
+
sh_exit_if_git_err(382, "git commit", extra_args=args)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def git_current_branch(project_path: str) -> str:
|
|
626
|
+
""" determine the currently checked-out branch of the specified git repository.
|
|
627
|
+
|
|
628
|
+
:param project_path: project root folder of the git repo.
|
|
629
|
+
:return: name of the current branch, or an empty string if no branch is checked out.
|
|
630
|
+
"""
|
|
631
|
+
if not os_path_isdir(os_path_join(project_path, GIT_FOLDER_NAME)):
|
|
632
|
+
return ""
|
|
633
|
+
|
|
634
|
+
with in_prj_dir_venv(project_path):
|
|
635
|
+
cur_branch = sh_exit_if_git_err(328, "git branch --show-current")
|
|
636
|
+
return cur_branch[0] if cur_branch else ""
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def git_diff(project_path: str, *extra_args: str) -> list[str]:
|
|
640
|
+
""" determine the uncommited/unstaged changes of a git project-
|
|
641
|
+
|
|
642
|
+
:param project_path: project root folder.
|
|
643
|
+
:param extra_args: additional options and refs passed onto the git diff command, apart from the
|
|
644
|
+
always added options: --no-color, --find-copies-harder, --find-renames and --full-index.
|
|
645
|
+
pass e.g. --compact-summary or --name-only for a more compact output/return.
|
|
646
|
+
:return: list of console output lines of the git diff command, optionally including the exit
|
|
647
|
+
error code (marked with :data:`EXEC_GIT_ERR_PREFIX` in the first returned list item),
|
|
648
|
+
like returned by :func:`sh_exit_if_git_err`.
|
|
649
|
+
"""
|
|
650
|
+
args = ["--no-color", "--find-copies-harder", "--find-renames", "--full-index"]
|
|
651
|
+
args.extend(extra_args)
|
|
652
|
+
|
|
653
|
+
with in_prj_dir_venv(project_path):
|
|
654
|
+
output = sh_exit_if_git_err(370, "git diff", extra_args=args, exit_on_err=False)
|
|
655
|
+
|
|
656
|
+
return output
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def git_fetch(project_path: str, *extra_args: str, exit_on_err: bool = False) -> list[str]:
|
|
660
|
+
""" fetch repository from remotes.
|
|
661
|
+
|
|
662
|
+
:param project_path: project root folder.
|
|
663
|
+
:param extra_args: additional options and arguments (remote name) for the git fetch command.
|
|
664
|
+
:param exit_on_err: specify True to exit the Python app on any git fetch error.
|
|
665
|
+
:return: list of lines from the console output that record an error (e.g. if no .git folder).
|
|
666
|
+
"""
|
|
667
|
+
with in_prj_dir_venv(project_path):
|
|
668
|
+
output = sh_exit_if_git_err(375, "git fetch", extra_args=extra_args, exit_on_err=exit_on_err)
|
|
669
|
+
|
|
670
|
+
return [_ for _ in output if _.lstrip().startswith((EXEC_GIT_ERR_PREFIX, "!", 'fatal:'))]
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def git_init_if_needed(project_path: str,
|
|
674
|
+
author: str = "", email: str = "", main_branch: str = DEF_MAIN_BRANCH) -> bool:
|
|
675
|
+
""" check if a git repository already exists in the specified project root folder, and create it if not.
|
|
676
|
+
|
|
677
|
+
:param project_path: project root path
|
|
678
|
+
:param author: author of the project/repo added to git config (if specified).
|
|
679
|
+
:param email: email address of the author added to git config (if specified).
|
|
680
|
+
:param main_branch: first main branch name to be checked out if repo did not exist / got created.
|
|
681
|
+
if not specified then the module constant :data:`DEF_MAIN_BRANCH` is used.
|
|
682
|
+
pass empty string to not do the initial checkout on a not existing repository.
|
|
683
|
+
:return: boolean True if a new repo got created and initialized, else False.
|
|
684
|
+
"""
|
|
685
|
+
if os_path_isdir(os_path_join(project_path, GIT_FOLDER_NAME)):
|
|
686
|
+
return False
|
|
687
|
+
|
|
688
|
+
with in_prj_dir_venv(project_path):
|
|
689
|
+
# the next two config commands prevent error in test systems/containers
|
|
690
|
+
sh_exit_if_git_err(351, "git init")
|
|
691
|
+
if author:
|
|
692
|
+
sh_exit_if_git_err(352, "git config", extra_args=("user.name", author))
|
|
693
|
+
if email:
|
|
694
|
+
sh_exit_if_git_err(353, "git config", extra_args=("user.email", email))
|
|
695
|
+
if main_branch:
|
|
696
|
+
sh_exit_if_git_err(354, "git checkout", extra_args=("-b", main_branch))
|
|
697
|
+
sh_exit_if_git_err(355, "git commit", extra_args=("--allow-empty", "-m", "git repository initialization"))
|
|
698
|
+
|
|
699
|
+
return True
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def git_merge(project_path: str, from_branch: str, *extra_options: str,
|
|
703
|
+
commit_msg_text: str = "", commit_msg_file: str = COMMIT_MSG_FILE_NAME, exit_on_err: bool = False
|
|
704
|
+
) -> list[str]:
|
|
705
|
+
""" merge current worktree with the specified [remote/]branch (exit app if an error occurred).
|
|
706
|
+
|
|
707
|
+
:param project_path: project root path of worktree to merge.
|
|
708
|
+
:param from_branch: branch (or commit/tag) to merge into current worktree with (optional with a leading
|
|
709
|
+
upstream/origin remote name, e.g. "upstream/main_branch").
|
|
710
|
+
:param extra_options: extra arguments for the git command
|
|
711
|
+
:param commit_msg_text: commit message used for the optional merge commit. if specified then the argument
|
|
712
|
+
in :paramref:`~git_merge.commit_msg_file` will be ignored.
|
|
713
|
+
:param commit_msg_file: name of the git commit message file (def=COMMIT_MSG_FILE_NAME).
|
|
714
|
+
:param exit_on_err: specify True to exit the Python app on any git push error.
|
|
715
|
+
:return: list with output lines of the git merge command (like returned by the function
|
|
716
|
+
:func:`sh_exit_if_git_err`, used to execute this git command).
|
|
717
|
+
if the git command returned with an error code and the argument in
|
|
718
|
+
:paramref:`~git_merge.exit_on_err` got not specified or as a `True` argument, then
|
|
719
|
+
the app will quit. if :paramref:`~git_merge.exit_on_err` got specified as 'False' and
|
|
720
|
+
git push returned an error code, then it will be returned in the first line/list-item
|
|
721
|
+
(prefixed with :data:`EXEC_GIT_ERR_PREFIX`).
|
|
722
|
+
"""
|
|
723
|
+
extra_args: tuple[str, ...]
|
|
724
|
+
if commit_msg_text:
|
|
725
|
+
extra_args = ("-m", commit_msg_text)
|
|
726
|
+
else:
|
|
727
|
+
commit_msg_file = check_commit_msg_file(project_path, commit_msg_file=commit_msg_file)
|
|
728
|
+
extra_args = ("--file", commit_msg_file)
|
|
729
|
+
extra_args += extra_options + ("--log", "--no-stat", from_branch)
|
|
730
|
+
|
|
731
|
+
with in_prj_dir_venv(project_path):
|
|
732
|
+
output = sh_exit_if_git_err(377, "git merge", extra_args=extra_args, exit_on_err=exit_on_err)
|
|
733
|
+
|
|
734
|
+
return output
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def git_push(project_path: str, remote_repo_url: str, *options_and_refs: str, exit_on_err: bool = True) -> list[str]:
|
|
738
|
+
""" push the repo of the specified project with the specified branch/tags to the specified remote.
|
|
739
|
+
|
|
740
|
+
:param project_path: project root folder.
|
|
741
|
+
:param remote_repo_url: remote repo url. if git credentials are not configured on the local system, then
|
|
742
|
+
a user token or password has to be included for the authentication against remote.
|
|
743
|
+
:param options_and_refs: extra arguments for the git command. pass additional options, e.g. "--delete" or
|
|
744
|
+
"--set-upstream" or "-u", and any references like branch/tag names to be pushed.
|
|
745
|
+
:param exit_on_err: specify False to not exit the Python app on any git push error.
|
|
746
|
+
:return: list with output lines of the git push command (like returned by the function
|
|
747
|
+
:func:`sh_exit_if_git_err`, used to execute this git push command).
|
|
748
|
+
if git push returned with an error code and the argument in
|
|
749
|
+
:paramref:`~git_push.exit_on_err` got not specified or as a `True` argument, then
|
|
750
|
+
the app will quit. if :paramref:`~git_push.exit_on_err` got specified as 'False' and
|
|
751
|
+
git push returned an error code, then it will be returned in the first line/list-item
|
|
752
|
+
(prefixed with :data:`EXEC_GIT_ERR_PREFIX`).
|
|
753
|
+
"""
|
|
754
|
+
options = []
|
|
755
|
+
refs = []
|
|
756
|
+
for arg in options_and_refs:
|
|
757
|
+
if arg.startswith("-"):
|
|
758
|
+
options.append(arg)
|
|
759
|
+
else:
|
|
760
|
+
refs.append(arg)
|
|
761
|
+
|
|
762
|
+
with in_prj_dir_venv(project_path):
|
|
763
|
+
output = sh_exit_if_git_err(380, "git push",
|
|
764
|
+
extra_args=options + [remote_repo_url] + refs, exit_on_err=exit_on_err)
|
|
765
|
+
|
|
766
|
+
return output
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def git_ref_in_branch(project_path: str, ref: str, branch: str = f'{GIT_REMOTE_ORIGIN}/{DEF_MAIN_BRANCH}') -> bool:
|
|
770
|
+
""" check if branch/tag/ref is in the specified branch.
|
|
771
|
+
|
|
772
|
+
:param project_path: project worktree root path.
|
|
773
|
+
:param ref: any ref like a tag or another branch, to be searched within
|
|
774
|
+
:paramref:`~_git_ref_in_branch.branch`.
|
|
775
|
+
:param branch: branch to be searched in for :paramref:`~_git_ref_in_branch.tag`. if not specified
|
|
776
|
+
then it defaults to the remote/origin main branch.
|
|
777
|
+
:return: boolean True if the ref got found in the branch, else False.
|
|
778
|
+
"""
|
|
779
|
+
with in_prj_dir_venv(project_path):
|
|
780
|
+
extra_args = ("--all", "--contains", ref, "--format=%(refname:short)")
|
|
781
|
+
output = sh_exit_if_git_err(388, "git branch", extra_args=extra_args, exit_on_err=False)
|
|
782
|
+
return bool(output) and not output[0].startswith(EXEC_GIT_ERR_PREFIX) and branch in output
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def git_remote_domain_group(project_path: str,
|
|
786
|
+
origin_name: str = GIT_REMOTE_ORIGIN, upstream_name: str = GIT_REMOTE_UPSTREAM,
|
|
787
|
+
remote_urls: Optional[GitRemotesType] = None) -> tuple[str, str]:
|
|
788
|
+
""" determine the domain and the repository owner group-/user-name from the git remote configuration (.git/config).
|
|
789
|
+
|
|
790
|
+
:param project_path: path to the project root folder of the repository.
|
|
791
|
+
:param origin_name: name of the origin git remote (to push to).
|
|
792
|
+
:param upstream_name: name of the upstream git remote (to request a merge from).
|
|
793
|
+
:param remote_urls: remote urls. defaults to :func:`git_remotes` if not provided.
|
|
794
|
+
:return: tuple with the domain and the user/group of the upstream repository url. if no upstream
|
|
795
|
+
url is set/configured (not forked) then the origin url is used. if there is no remote
|
|
796
|
+
configured at all, then the returned tuple contains two empty strings.
|
|
797
|
+
"""
|
|
798
|
+
if remote_urls is None:
|
|
799
|
+
remote_urls = git_remotes(project_path)
|
|
800
|
+
|
|
801
|
+
remote_url = remote_urls.get(upstream_name, remote_urls.get(origin_name))
|
|
802
|
+
if not remote_url:
|
|
803
|
+
return "", ""
|
|
804
|
+
|
|
805
|
+
url_parts = urlparse(remote_url)
|
|
806
|
+
return url_parts.hostname or "", url_parts.path[1:].split('/')[0]
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def git_remotes(project_path: str) -> GitRemotesType:
|
|
810
|
+
""" get mapping of hte project repository remotes.
|
|
811
|
+
|
|
812
|
+
:param project_path: path to the project root folder of the repository.
|
|
813
|
+
:return: dict with the remote ids as keys and the url as the values.
|
|
814
|
+
"""
|
|
815
|
+
remotes = {}
|
|
816
|
+
if os_path_isdir(os_path_join(project_path, GIT_FOLDER_NAME)):
|
|
817
|
+
with in_prj_dir_venv(project_path):
|
|
818
|
+
remote_ids = sh_exit_if_git_err(321, "git remote")
|
|
819
|
+
for remote_id in remote_ids:
|
|
820
|
+
remote_url = sh_exit_if_git_err(322, "git remote", extra_args=("get-url", "--push", remote_id))
|
|
821
|
+
remotes[remote_id] = remote_url[0]
|
|
822
|
+
return remotes
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
826
|
+
def git_renew_remotes(project_path: str, origin_url: str, upstream_url: str = "",
|
|
827
|
+
origin_name: str = GIT_REMOTE_ORIGIN, upstream_name: str = GIT_REMOTE_UPSTREAM,
|
|
828
|
+
remotes: Optional[GitRemotesType] = None) -> list[str]:
|
|
829
|
+
""" renew the origin remote and optionally (if repo is forked) also the upstream remote.
|
|
830
|
+
|
|
831
|
+
:param project_path: project root folder.
|
|
832
|
+
:param origin_url: new url of the origin repo. will append a missing .git extension to the specified url.
|
|
833
|
+
:param upstream_url: new url of the upstream repo if forked. if specified and differs to the actual upstream
|
|
834
|
+
url and to the specified origin url, then it will be automatically added.
|
|
835
|
+
appends a missing .git extension to the specified url.
|
|
836
|
+
:param origin_name: name of the origin git remote (to push to). if not specified defaults
|
|
837
|
+
to :data:`GIT_REMOTE_ORIGIN`.
|
|
838
|
+
:param upstream_name: name of the upstream git remote (to request a merge from). if not specified defaults
|
|
839
|
+
to :data:`GIT_REMOTE_UPSTREAM`.
|
|
840
|
+
:param remotes: pass the actual git remotes to prevent multiple execution of the git remote command.
|
|
841
|
+
determined via :func:`git_remotes` if not specified.
|
|
842
|
+
:return: list of console output lines of the executed git remote commands.
|
|
843
|
+
an empty list no errors/warnings got outputted by the git remote command.
|
|
844
|
+
"""
|
|
845
|
+
if not origin_url.endswith(".git"):
|
|
846
|
+
origin_url += ".git"
|
|
847
|
+
if upstream_url and not upstream_url.endswith(".git"):
|
|
848
|
+
upstream_url += ".git"
|
|
849
|
+
if not remotes:
|
|
850
|
+
remotes = git_remotes(project_path)
|
|
851
|
+
|
|
852
|
+
err = []
|
|
853
|
+
with in_prj_dir_venv(project_path):
|
|
854
|
+
if upstream_url:
|
|
855
|
+
if upstream_name not in remotes:
|
|
856
|
+
if upstream_url != origin_url:
|
|
857
|
+
err.extend(sh_exit_if_git_err(340, "git remote", extra_args=("add", upstream_name, upstream_url)))
|
|
858
|
+
elif remotes[upstream_name] != upstream_url:
|
|
859
|
+
err.extend(sh_exit_if_git_err(341, "git remote", extra_args=("set-url", upstream_name, upstream_url)))
|
|
860
|
+
|
|
861
|
+
if origin_name not in remotes:
|
|
862
|
+
err.extend(sh_exit_if_git_err(342, "git remote", extra_args=("add", origin_name, origin_url)))
|
|
863
|
+
elif remotes[origin_name] != origin_url:
|
|
864
|
+
err.extend(sh_exit_if_git_err(343, "git remote", extra_args=("set-url", origin_name, origin_url)))
|
|
865
|
+
|
|
866
|
+
return err
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def git_status(project_path: str, verbose: bool = False) -> list[str]:
|
|
870
|
+
""" get the status of the project repository.
|
|
871
|
+
|
|
872
|
+
:param project_path: project root path.
|
|
873
|
+
:param verbose: pass True to return a more verbose console output (using --branch -vv --porcelain=2).
|
|
874
|
+
:return: console output of the git status command.
|
|
875
|
+
"""
|
|
876
|
+
args = ["--find-renames", "--untracked-files"] # --untracked-files=normal is missing a full subdir-rel-file-path
|
|
877
|
+
if verbose:
|
|
878
|
+
args.append("--branch")
|
|
879
|
+
args.append("-vv")
|
|
880
|
+
args.append("--porcelain=2")
|
|
881
|
+
else:
|
|
882
|
+
args.append("-v")
|
|
883
|
+
args.append("--porcelain")
|
|
884
|
+
|
|
885
|
+
with in_prj_dir_venv(project_path):
|
|
886
|
+
output = sh_exit_if_git_err(376, "git status", extra_args=args)
|
|
887
|
+
|
|
888
|
+
return output
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def git_tag_add(project_path: str, tag: str, commit_msg_file: str = COMMIT_MSG_FILE_NAME) -> list[str]:
|
|
892
|
+
""" add a new tag onto the project in the specified project root path
|
|
893
|
+
|
|
894
|
+
:param project_path: project root path.
|
|
895
|
+
:param tag: tag to add.
|
|
896
|
+
:param commit_msg_file: name of the git commit message file (def=COMMIT_MSG_FILE_NAME).
|
|
897
|
+
:return: console output of the git tag --annotate command.
|
|
898
|
+
"""
|
|
899
|
+
with in_prj_dir_venv(project_path):
|
|
900
|
+
output = sh_exit_if_git_err(387, "git tag --annotate",
|
|
901
|
+
extra_args=("--file",
|
|
902
|
+
check_commit_msg_file(project_path, commit_msg_file=commit_msg_file),
|
|
903
|
+
tag))
|
|
904
|
+
return output
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def git_tag_list(project_path: str, remote="", tag_pattern: str = "*") -> list[str]:
|
|
908
|
+
""" determine a list of matching tags of a local or remote git repository.
|
|
909
|
+
|
|
910
|
+
:param project_path: project root folder.
|
|
911
|
+
:param remote: name of a remote.
|
|
912
|
+
if not specified then only local tags will be determined.
|
|
913
|
+
:param tag_pattern: matching pattern. if not specified then all tags will be returned.
|
|
914
|
+
:return: list of matching repo tags, ordered via the git ls-remote option --sort=version:refname.
|
|
915
|
+
an empty list will be returned if no tag is matching the specified tag pattern
|
|
916
|
+
or an error occurred or if the project has no .git folder.
|
|
917
|
+
"""
|
|
918
|
+
output: list[str] = []
|
|
919
|
+
if not os_path_isdir(os_path_join(project_path, GIT_FOLDER_NAME)):
|
|
920
|
+
return output
|
|
921
|
+
|
|
922
|
+
with in_prj_dir_venv(project_path):
|
|
923
|
+
if remote:
|
|
924
|
+
output = sh_exit_if_git_err(389, "git ls-remote",
|
|
925
|
+
extra_args=("--tags", "--refs", "--sort=version:refname", remote, tag_pattern),
|
|
926
|
+
exit_on_err=False)
|
|
927
|
+
if output and output[0].startswith(EXEC_GIT_ERR_PREFIX):
|
|
928
|
+
output = []
|
|
929
|
+
else:
|
|
930
|
+
output = [line.split("\t")[-1].split("/")[-1] for line in output]
|
|
931
|
+
else:
|
|
932
|
+
output = sh_exit_if_git_err(389, "git tag",
|
|
933
|
+
extra_args=("--list", "--sort=version:refname", tag_pattern),
|
|
934
|
+
exit_on_err=False)
|
|
935
|
+
if output and output[0].startswith(EXEC_GIT_ERR_PREFIX):
|
|
936
|
+
output = []
|
|
937
|
+
|
|
938
|
+
return output
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def git_tag_remotes(project_path: str, tag_pattern: str, remote_names: Iterable[str] = ()) -> list[str]:
|
|
942
|
+
""" return the remote names where the specified tag name exists.
|
|
943
|
+
|
|
944
|
+
:param project_path: path of the project root folder.
|
|
945
|
+
:param tag_pattern: tag pattern to search for.
|
|
946
|
+
:param remote_names: iterable with the remote names. determined with :func:`git_remotes` if not specified.
|
|
947
|
+
:return: list of remote names where the tag exists.
|
|
948
|
+
"""
|
|
949
|
+
if not remote_names:
|
|
950
|
+
remote_names = git_remotes(project_path)
|
|
951
|
+
|
|
952
|
+
remotes = []
|
|
953
|
+
for remote_name in remote_names:
|
|
954
|
+
output = git_any(project_path, 'ls-remote', '--tags', remote_name, tag_pattern)
|
|
955
|
+
if output and not output[0].startswith(EXEC_GIT_ERR_PREFIX):
|
|
956
|
+
remotes.append(remote_name)
|
|
957
|
+
return remotes
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def git_uncommitted(project_path: str) -> set[str]:
|
|
961
|
+
""" determine the changed/untracked/uncommitted files of a git repository/project.
|
|
962
|
+
|
|
963
|
+
:param project_path: project root folder.
|
|
964
|
+
:return: set of changed/untracked/uncommitted file names.
|
|
965
|
+
an empty set will be returned if the git repo is not initialized or
|
|
966
|
+
if there are no uncommitted files in the git repo specified by project_path.
|
|
967
|
+
|
|
968
|
+
.. hint:: see also func:`git_branch_files` and the unit tests for the differences between them.
|
|
969
|
+
"""
|
|
970
|
+
if not os_path_isdir(os_path_join(project_path, GIT_FOLDER_NAME)):
|
|
971
|
+
return set()
|
|
972
|
+
|
|
973
|
+
with in_prj_dir_venv(project_path):
|
|
974
|
+
output = sh_exit_if_git_err(379, "git status",
|
|
975
|
+
extra_args=("--find-renames", "--untracked-files=all", "--porcelain"))
|
|
976
|
+
return {_[3:] for _ in output}
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def hint(command: str, action: Union[Callable, str], message_suffix: str = "") -> str:
|
|
980
|
+
""" return hint string in debug/verbose mode, to be appended onto a shell/console output.
|
|
981
|
+
|
|
982
|
+
:param command: shell command.
|
|
983
|
+
:param action: shell command action function/method.
|
|
984
|
+
:param message_suffix: extra message text, added to the end of the returned console output string.
|
|
985
|
+
:return: in debug/verbose mode return a string with leading line feed to be sent
|
|
986
|
+
to console output, else return an empty string.
|
|
987
|
+
"""
|
|
988
|
+
if not isinstance(action, str):
|
|
989
|
+
action = action.__name__
|
|
990
|
+
return f"{os.linesep} (run: {command} {action}{message_suffix})" if debug_or_verbose() else ""
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
@contextmanager
|
|
994
|
+
def in_os_env(start_dir: str = "") -> Iterator[MutableMapping[str, str]]:
|
|
995
|
+
""" temporarily add environment variables from the dotenv files that not exist in os.environ to it.
|
|
996
|
+
|
|
997
|
+
:param start_dir: path to the folder where the first dotenv file (with the highest priority) is stored.
|
|
998
|
+
:return: yielding the os env variables that got added to os.environ in this temporary context.
|
|
999
|
+
"""
|
|
1000
|
+
loaded_env_vars = load_env_var_defaults(start_dir, os.environ)
|
|
1001
|
+
try:
|
|
1002
|
+
yield loaded_env_vars
|
|
1003
|
+
finally:
|
|
1004
|
+
for var_name in loaded_env_vars:
|
|
1005
|
+
os.environ.pop(var_name)
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
@contextmanager
|
|
1009
|
+
def in_prj_dir_venv(project_path: str, venv_name: str = "") -> Iterator[None]:
|
|
1010
|
+
""" ensure the current working directory and the specified or .python-version-configured Python Virtual Environment.
|
|
1011
|
+
|
|
1012
|
+
:param project_path: path to the project root folder to switch the current working directory in this context.
|
|
1013
|
+
:param venv_name: name of the Python Virtual Environment to activate in this context. if not specified
|
|
1014
|
+
(or as empty string), then the venv configured via the file .python-version will be
|
|
1015
|
+
activated.
|
|
1016
|
+
:return:
|
|
1017
|
+
"""
|
|
1018
|
+
with in_wd(project_path), in_os_env(project_path), in_venv(name=venv_name):
|
|
1019
|
+
yield
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
@contextmanager
|
|
1023
|
+
def in_venv(name: str = "") -> Iterator[None]:
|
|
1024
|
+
""" ensure the virtual environment gets activated within the context.
|
|
1025
|
+
|
|
1026
|
+
:param name: the name of the venv to activate. if not specified, then the venv of the project in the
|
|
1027
|
+
current working directory tree will be activated.
|
|
1028
|
+
"""
|
|
1029
|
+
old_venv = activate_venv(name)
|
|
1030
|
+
yield
|
|
1031
|
+
if old_venv:
|
|
1032
|
+
activate_venv(old_venv)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def on_ci_host() -> bool:
|
|
1036
|
+
""" check and return True if this tool is running on the GitLab/GitHub CI host/server.
|
|
1037
|
+
|
|
1038
|
+
:return: True if running on CI host, else False
|
|
1039
|
+
"""
|
|
1040
|
+
return 'CI' in os.environ or 'CI_PROJECT_ID' in os.environ
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def owner_project_from_url(remote_url: str) -> str:
|
|
1044
|
+
""" determine the owner and project name path from the specified git remote repository url.
|
|
1045
|
+
|
|
1046
|
+
:param remote_url: remote repository url.
|
|
1047
|
+
:return: the owner name and the project name, separated with a slash character.
|
|
1048
|
+
"""
|
|
1049
|
+
url_parts = urlparse(remote_url)
|
|
1050
|
+
url_path = url_parts.path
|
|
1051
|
+
if url_path.startswith("/"):
|
|
1052
|
+
url_path = url_path[1:]
|
|
1053
|
+
if url_path.endswith(".git"):
|
|
1054
|
+
url_path = url_path[:-4]
|
|
1055
|
+
return url_path
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def project_name_version(imp_or_pkg_name: str, packages_versions: Iterable[str]) -> tuple[str, str]:
|
|
1059
|
+
""" determine package name and version in the specified list of package/version strings.
|
|
1060
|
+
|
|
1061
|
+
:param imp_or_pkg_name: import or package name to search.
|
|
1062
|
+
:param packages_versions: an Iterable with project package name and optional version strings, like specified in
|
|
1063
|
+
requirements.txt files (format: <project_name>[<PROJECT_VERSION_SEP><project_version>]).
|
|
1064
|
+
:return: tuple of package name and version number. the package name is an empty string if it
|
|
1065
|
+
is not in :paramref:`~project_version.packages_versions`. the version number is an
|
|
1066
|
+
empty string if no package version is specified in
|
|
1067
|
+
:paramref:`~project_version.packages_versions`.
|
|
1068
|
+
"""
|
|
1069
|
+
project_name = norm_name(imp_or_pkg_name)
|
|
1070
|
+
for imp_or_pkg_name_and_ver in packages_versions:
|
|
1071
|
+
imp_or_pkg_name, *ver = imp_or_pkg_name_and_ver.split(PROJECT_VERSION_SEP)
|
|
1072
|
+
prj_name = norm_name(imp_or_pkg_name)
|
|
1073
|
+
if prj_name == project_name:
|
|
1074
|
+
return project_name, ver[0] if ver else ""
|
|
1075
|
+
return "", ""
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
1079
|
+
def sh_exec(command_line: str, extra_args: Iterable = (), console_input: str = "",
|
|
1080
|
+
lines_output: Optional[list[str]] = None, main_app: Optional[Any] = None, shell: bool = False,
|
|
1081
|
+
env_vars: Optional[dict[str, str]] = None) -> int:
|
|
1082
|
+
""" execute command in the current working directory of the OS console/shell.
|
|
1083
|
+
|
|
1084
|
+
:param command_line: command line string to execute on the console/shell. could contain command line args
|
|
1085
|
+
separated by whitespace characters (alternatively use :paramref:`~sh_exec.extra_args`).
|
|
1086
|
+
:param extra_args: optional sequence of extra command line arguments.
|
|
1087
|
+
:param console_input: optional string to be sent to the stdin stream of the console/shell.
|
|
1088
|
+
:param lines_output: optional list to be extended with the lines printed to stdout/stderr on execution.
|
|
1089
|
+
by passing an empty list, the stdout and stderr streams/pipes will be separated,
|
|
1090
|
+
resulting in having the stderr output lines at the end of the list, enclosed by
|
|
1091
|
+
the list items :data:`STDERR_BEG_MARKER` and :data:`STDERR_END_MARKER`.
|
|
1092
|
+
:param main_app: optional :class:`~ae.console.ConsoleApp` instance, only used for logging. to suppress
|
|
1093
|
+
any logging output, pass :data:`~ae.base.UNSET`.
|
|
1094
|
+
:param shell: pass True to execute command in the default OS shell (see :meth:`subprocess.run`).
|
|
1095
|
+
:param env_vars: OS shell environment variables to be used instead of the console/bash defaults.
|
|
1096
|
+
:return: return code of the executed command or 126 if execution raised any other exception.
|
|
1097
|
+
"""
|
|
1098
|
+
args = command_line + " " + " ".join(extra_args) if shell else shlex.split(command_line) + list(extra_args)
|
|
1099
|
+
ret_out = lines_output is not None # == isinstance(lines_output, list)
|
|
1100
|
+
merge_err = bool(lines_output) # == -''- and len(lines_output) > 0
|
|
1101
|
+
print_out = main_app.po if main_app else print if main_app is None else dummy_function
|
|
1102
|
+
debug_out = main_app.dpo if main_app else dummy_function
|
|
1103
|
+
debug_out(f" . executing at {os.getcwd()}: {args}")
|
|
1104
|
+
|
|
1105
|
+
result: Union[subprocess.CompletedProcess, subprocess.CalledProcessError] # having: stdout/stderr/returncode
|
|
1106
|
+
try:
|
|
1107
|
+
result = subprocess.run(args,
|
|
1108
|
+
stdout=subprocess.PIPE if ret_out else None,
|
|
1109
|
+
stderr=subprocess.STDOUT if merge_err else subprocess.PIPE if ret_out else None,
|
|
1110
|
+
input=console_input.encode(),
|
|
1111
|
+
check=True,
|
|
1112
|
+
shell=shell,
|
|
1113
|
+
env=env_vars)
|
|
1114
|
+
except subprocess.CalledProcessError as ex: # pragma: no cover
|
|
1115
|
+
debug_out(f"**** subprocess.run({args=}) returned non-zero exit code {ex.returncode}; {ex=}")
|
|
1116
|
+
result = ex
|
|
1117
|
+
except Exception as ex: # pylint: disable=broad-except # pragma: no cover
|
|
1118
|
+
print_out(f"**** subprocess.run({args}) raised exception {ex}")
|
|
1119
|
+
return 126
|
|
1120
|
+
|
|
1121
|
+
if ret_out:
|
|
1122
|
+
assert isinstance(lines_output, list), "silly mypy doesn't recognize ret_out"
|
|
1123
|
+
if result.stdout:
|
|
1124
|
+
lines_output.extend([line for line in result.stdout.decode().split(os.linesep) if line])
|
|
1125
|
+
if not merge_err and result.stderr:
|
|
1126
|
+
lines_output.append(STDERR_BEG_MARKER)
|
|
1127
|
+
lines_output.extend([line for line in result.stderr.decode().split(os.linesep) if line])
|
|
1128
|
+
lines_output.append(STDERR_END_MARKER)
|
|
1129
|
+
|
|
1130
|
+
return result.returncode
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
1134
|
+
def sh_exit_if_exec_err(err_code: int, command_line: str,
|
|
1135
|
+
extra_args: Iterable = (), lines_output: Optional[list[str]] = None,
|
|
1136
|
+
exit_on_err: bool = True, exit_msg: str = "", shell: bool = False,
|
|
1137
|
+
env_vars: Optional[dict[str, str]] = None) -> int:
|
|
1138
|
+
""" execute command in the current working directory of the OS console/shell, dump error, and exit app if needed.
|
|
1139
|
+
|
|
1140
|
+
:param err_code: error code to pass to the console as exit code if :paramref:`.exit_on_err` is True.
|
|
1141
|
+
:param command_line: command line string to execute on the console/shell. could contain command line args
|
|
1142
|
+
separated by whitespace characters (alternatively use :paramref:`~sh_exec.extra_args`).
|
|
1143
|
+
:param extra_args: optional iterable of extra command line arguments.
|
|
1144
|
+
:param lines_output: optional list to return the lines printed to stdout/stderr on execution.
|
|
1145
|
+
by passing an empty list, the stdout and stderr streams/pipes will be separated,
|
|
1146
|
+
resulting in having the stderr output lines at the end of the list. specify at
|
|
1147
|
+
least on list item to merge-in the stderr output (into the stdout output and return).
|
|
1148
|
+
:param exit_on_err: pass False to **not** exit the app on error (:paramref:`.exit_msg` has then to be
|
|
1149
|
+
empty).
|
|
1150
|
+
:param exit_msg: additional text to print on stdout/console if the app debug level is greater or equal
|
|
1151
|
+
to 1 or if an error occurred and :paramref:`~sh_exit_if_exec_err.exit_on_err` is True.
|
|
1152
|
+
:param shell: pass True to execute command in the default OS shell (see :meth:`subprocess.run`).
|
|
1153
|
+
:param env_vars: OS shell environment variables to be used instead of the console/bash defaults.
|
|
1154
|
+
:return: 0 on success or the error number if an error occurred.
|
|
1155
|
+
"""
|
|
1156
|
+
assert exit_on_err or not exit_msg, "specified exit message will never be shown because exit_on_err is False"
|
|
1157
|
+
if lines_output is None:
|
|
1158
|
+
lines_output = []
|
|
1159
|
+
main_app = get_main_app()
|
|
1160
|
+
|
|
1161
|
+
sh_err = sh_exec(command_line, extra_args=extra_args,
|
|
1162
|
+
lines_output=lines_output, main_app=main_app, shell=shell, env_vars=env_vars)
|
|
1163
|
+
|
|
1164
|
+
if (sh_err and exit_on_err) or main_app.debug:
|
|
1165
|
+
for line in lines_output:
|
|
1166
|
+
if main_app.verbose or not line.startswith("LOG: "): # if verbose show mypy's endless (stderr) log entries
|
|
1167
|
+
main_app.po(" " * 6 + line)
|
|
1168
|
+
msg = f"command: {command_line} " + " ".join('"' + arg + '"' if " " in arg else arg for arg in extra_args)
|
|
1169
|
+
if not sh_err:
|
|
1170
|
+
main_app.dpo(f" = successfully executed {msg}")
|
|
1171
|
+
else:
|
|
1172
|
+
if exit_msg:
|
|
1173
|
+
main_app.po(f" {exit_msg}")
|
|
1174
|
+
check_if(err_code, not exit_on_err, f"sh_exit_if_exec_err error {sh_err} in {msg}") # app exit
|
|
1175
|
+
|
|
1176
|
+
return sh_err
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments,too-many-locals
|
|
1180
|
+
def sh_exit_if_git_err(err_code: int, command_line: str,
|
|
1181
|
+
extra_args: Iterable = (), lines_output: Optional[list[str]] = None, exit_on_err: bool = False,
|
|
1182
|
+
log_enable_dir: str = "") -> list[str]:
|
|
1183
|
+
""" execute git command with optional git trace output, returning the stdout lines cleaned from any trace messages.
|
|
1184
|
+
|
|
1185
|
+
:param err_code: error code to pass to the console as exit code if :paramref:`.exit_on_err` is True.
|
|
1186
|
+
:param command_line: command line string to execute on the console/shell. could contain command line args
|
|
1187
|
+
separated by whitespace characters (alternatively use :paramref:`~sh_exec.extra_args`).
|
|
1188
|
+
:param extra_args: optional iterable of extra command line arguments.
|
|
1189
|
+
:param lines_output: optional list to return the lines printed to stdout/stderr on execution.
|
|
1190
|
+
by passing an empty list, the stdout and stderr streams/pipes will be separated,
|
|
1191
|
+
resulting in having the stderr output lines at the end of the list. specify at
|
|
1192
|
+
least on list item to merge-in the stderr output (into the stdout output and return).
|
|
1193
|
+
:param exit_on_err: pass True to exit the app on error.
|
|
1194
|
+
:param log_enable_dir: pass the path of the directory in which git shell command logging have to get enabled.
|
|
1195
|
+
:return: output lines of git command - cleaned from GIT_TRACE messages,
|
|
1196
|
+
if :paramref:`~sh_exit_if_git_err.exit_on_err` got specified as 'False' and the executed
|
|
1197
|
+
git command returned an error code, then the error code will be returned in the first
|
|
1198
|
+
line/list-item (prefixed with :data:`EXEC_GIT_ERR_PREFIX`).
|
|
1199
|
+
"""
|
|
1200
|
+
if lines_output is None:
|
|
1201
|
+
lines_output = []
|
|
1202
|
+
|
|
1203
|
+
main_app = get_main_app()
|
|
1204
|
+
git_debug = main_app.verbose
|
|
1205
|
+
git_trace_vars = ('GIT_TRACE', 'GIT_TRACE_PACK_ACCESS', 'GIT_TRACE_PACKET', 'GIT_TRACE_SETUP')
|
|
1206
|
+
env_vars = {}
|
|
1207
|
+
if git_debug:
|
|
1208
|
+
env_vars['GIT_CURL_VERBOSE'] = "1"
|
|
1209
|
+
env_vars['GIT_MERGE_VERBOSITY'] = "5"
|
|
1210
|
+
for var in git_trace_vars:
|
|
1211
|
+
env_vars[var] = "1"
|
|
1212
|
+
|
|
1213
|
+
cl_err = sh_exit_if_exec_err(err_code, command_line,
|
|
1214
|
+
extra_args=extra_args, lines_output=lines_output, exit_on_err=exit_on_err,
|
|
1215
|
+
env_vars={**os.environ.copy(), **env_vars} if env_vars else None)
|
|
1216
|
+
|
|
1217
|
+
if log_files := sh_logs(log_enable_dir=log_enable_dir, log_name_prefix='git'):
|
|
1218
|
+
sh_log(command_line, extra_args=extra_args, cl_err=cl_err, lines_output=lines_output, log_file_paths=log_files)
|
|
1219
|
+
|
|
1220
|
+
if cl_err: # if cl_err and exit_on_err then it would have exit the Python interpreter (so never would run to here)
|
|
1221
|
+
main_app.vpo(f" # ignored error {cl_err} of {command_line=} with {extra_args=} and git trace {env_vars=}")
|
|
1222
|
+
lines_output.insert(0, EXEC_GIT_ERR_PREFIX + str(cl_err) + f" in {command_line=} with {extra_args=}")
|
|
1223
|
+
|
|
1224
|
+
if STDERR_BEG_MARKER in lines_output and ( # output marker only if stderr not got merged/called w/ lines_output==[]
|
|
1225
|
+
git_debug or any(os.environ.get(_, "0") in ("true", "1", "2") for _ in git_trace_vars)):
|
|
1226
|
+
start = lines_output.index(STDERR_BEG_MARKER)
|
|
1227
|
+
if git_debug: # if not already printed by sh_exit_if_exec_err()
|
|
1228
|
+
sep = " " * 6
|
|
1229
|
+
main_app.po(sep + "git trace output:")
|
|
1230
|
+
for line_no in range(start + 1, len(lines_output) - 1):
|
|
1231
|
+
main_app.po(sep + lines_output[line_no])
|
|
1232
|
+
lines_output[:] = lines_output[:start] # del output[start:]
|
|
1233
|
+
|
|
1234
|
+
return lines_output
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
1238
|
+
def sh_log(comment_or_command: str, extra_args: Iterable[str] = (), cl_err: int = 0, lines_output: Iterable[str] = (),
|
|
1239
|
+
log_file_paths: Iterable[str] = (), log_name_prefix: str = ""):
|
|
1240
|
+
""" append a log entry to each existing/enabled shell command log file.
|
|
1241
|
+
|
|
1242
|
+
:param comment_or_command: command line or comment line (if starts with the # character).
|
|
1243
|
+
:param extra_args: extra arguments (added to the command line).
|
|
1244
|
+
:param cl_err: command exit code.
|
|
1245
|
+
:param lines_output: console output lines.
|
|
1246
|
+
:param log_file_paths: log file paths - if specified then the search of the default locations for log files
|
|
1247
|
+
will be skipped and therefore :paramref:`~sh_log.log_name_prefix` will be ignored.
|
|
1248
|
+
:param log_name_prefix: log file name prefix. extended with the :data:`SHELL_LOG_FILE_NAME_SUFFIX` results in
|
|
1249
|
+
the file name to search for (and to log into if exists).
|
|
1250
|
+
"""
|
|
1251
|
+
if not comment_or_command.startswith("#"):
|
|
1252
|
+
comment_or_command = " > " + comment_or_command
|
|
1253
|
+
|
|
1254
|
+
sep = os.linesep
|
|
1255
|
+
log_lines = (now_str(sep='-') + sep +
|
|
1256
|
+
comment_or_command + " " + " ".join('"' + _ + '"' if " " in _ else _ for _ in extra_args) + sep +
|
|
1257
|
+
(f" * {cl_err=}" + sep if cl_err else "") +
|
|
1258
|
+
(" " + (sep + " ").join(lines_output) + sep if lines_output else ""))
|
|
1259
|
+
|
|
1260
|
+
while "glpat-" in log_lines: # hide the gitlab private token, e.g. from git-push-urls with authentication
|
|
1261
|
+
start = log_lines.index("glpat-")
|
|
1262
|
+
end = log_lines.index("@gitlab.com", start)
|
|
1263
|
+
log_lines = log_lines[:start] + "private-token-" + log_lines[end - 3:]
|
|
1264
|
+
|
|
1265
|
+
for log_path in log_file_paths or sh_logs(log_name_prefix=log_name_prefix):
|
|
1266
|
+
write_file(log_path, log_lines, extra_mode='a')
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def sh_logs(log_enable_dir: str = "", log_name_prefix: str = "") -> list[str]:
|
|
1270
|
+
""" determine paths of the existing/enabled shell command log files, optionally enabling/creating a new log file.
|
|
1271
|
+
|
|
1272
|
+
:param log_enable_dir: specify the directory/folder path in which to enable shell command logging, by creating
|
|
1273
|
+
a new log file (and the folder) if they not exist. valid folder paths are
|
|
1274
|
+
either the CWD where the shell command get executed (e.g. the project root folder)
|
|
1275
|
+
or the users home directory (~).
|
|
1276
|
+
:param log_name_prefix: log file name prefix. extended with the :data:`SHELL_LOG_FILE_NAME_SUFFIX` results in
|
|
1277
|
+
the file name to search for (and to log into if exists).
|
|
1278
|
+
:return: list of existing/enabled shell log file name paths.
|
|
1279
|
+
"""
|
|
1280
|
+
file_name = log_name_prefix + SHELL_LOG_FILE_NAME_SUFFIX
|
|
1281
|
+
log_files = []
|
|
1282
|
+
|
|
1283
|
+
if os_path_isfile(file_name):
|
|
1284
|
+
log_files.append(norm_path(file_name))
|
|
1285
|
+
if os_path_isfile(file_path := norm_path(os_path_join("~", file_name))):
|
|
1286
|
+
log_files.append(file_path)
|
|
1287
|
+
|
|
1288
|
+
if log_enable_dir:
|
|
1289
|
+
file_path = norm_path(os_path_join(log_enable_dir, file_name))
|
|
1290
|
+
if file_path not in log_files:
|
|
1291
|
+
log_files.append(file_path)
|
|
1292
|
+
if not os_path_isfile(file_path):
|
|
1293
|
+
write_file(file_path, f"# enabled shell command logging into {file_path}{os.linesep}", make_dirs=True)
|
|
1294
|
+
|
|
1295
|
+
return log_files
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def temp_context_cleanup(context: TempContextType = ""):
|
|
1299
|
+
""" clean up temporary folders and files.
|
|
1300
|
+
|
|
1301
|
+
:param context: temporary directory context name. if not specified or passed as an empty string then
|
|
1302
|
+
the default context will be cleaned up.
|
|
1303
|
+
"""
|
|
1304
|
+
if ctx := _temp_folders.pop(context, None):
|
|
1305
|
+
ctx[0].cleanup()
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def temp_context_folders(context: TempContextType = "") -> list[str]:
|
|
1309
|
+
""" determine the folders created under the specified temporary directory context.
|
|
1310
|
+
|
|
1311
|
+
:param context: temporary directory context name. if not specified or passed as an empty string then
|
|
1312
|
+
the default context will be cleaned up.
|
|
1313
|
+
:return: list of folders created underneath the temporary directory of the specified context.
|
|
1314
|
+
or an empty list if the context does not exist (or got cleaned up).
|
|
1315
|
+
"""
|
|
1316
|
+
if context in _temp_folders:
|
|
1317
|
+
return _temp_folders[context][1]
|
|
1318
|
+
return []
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def temp_context_get_or_create(context: TempContextType = "", folder_name: str = "") -> str:
|
|
1322
|
+
""" get or create (if not exists) a temporary directory context with optional sub-folder.
|
|
1323
|
+
|
|
1324
|
+
:param context: temporary folder context name. if not specified or passed as an empty string then
|
|
1325
|
+
the default context will be used/created
|
|
1326
|
+
:param folder_name: optional name of a sub-folder.
|
|
1327
|
+
:return: absolute path of the temporary directory (including the optional sub-folder).
|
|
1328
|
+
"""
|
|
1329
|
+
if context in _temp_folders:
|
|
1330
|
+
temp_obj, folders = _temp_folders[context]
|
|
1331
|
+
else:
|
|
1332
|
+
temp_obj = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with
|
|
1333
|
+
folders = []
|
|
1334
|
+
_temp_folders[context] = (temp_obj, folders)
|
|
1335
|
+
|
|
1336
|
+
folder_path = norm_path(os_path_join(temp_obj.name, folder_name))
|
|
1337
|
+
|
|
1338
|
+
if folder_name not in folders:
|
|
1339
|
+
if folder_name:
|
|
1340
|
+
os.makedirs(folder_path, exist_ok=True)
|
|
1341
|
+
folders.append(folder_name)
|
|
1342
|
+
|
|
1343
|
+
return folder_path
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def venv_bin_path(name: str = "") -> str:
|
|
1347
|
+
""" determine the absolute bin/executables folder path of a virtual pyenv environment.
|
|
1348
|
+
|
|
1349
|
+
:param name: the name of the venv. if not specified, then the venv name will be determined from the
|
|
1350
|
+
first found ``.python-version`` file, starting in the current working directory (cwd)
|
|
1351
|
+
and up to 5 parent directories above. if no ``.python-version`` file could be found
|
|
1352
|
+
then the name of the currently active venv will be used (via the function
|
|
1353
|
+
:func:`active_venv` respectively the ``VIRTUAL_ENV`` shell environment variable).
|
|
1354
|
+
:return: absolute path of the "bin" folder in the specified/determined virtual environment or
|
|
1355
|
+
an empty string if pyenv is not installed or no venv name or bin folder could be found.
|
|
1356
|
+
|
|
1357
|
+
.. note::
|
|
1358
|
+
under Windows/win32 the base name of the returned path is 'Scripts' (not 'bin'), and
|
|
1359
|
+
the executables have a file extension (e.g., pip.exe, activate.bat, python.exe).
|
|
1360
|
+
"""
|
|
1361
|
+
venv_root = os.getenv('PYENV_ROOT')
|
|
1362
|
+
if not venv_root: # pyenv is not installed
|
|
1363
|
+
return ""
|
|
1364
|
+
|
|
1365
|
+
if not name:
|
|
1366
|
+
loc_env_file = '.python-version'
|
|
1367
|
+
for _ in range(6):
|
|
1368
|
+
if os_path_isfile(loc_env_file):
|
|
1369
|
+
name = read_file(loc_env_file).split(os.linesep)[0]
|
|
1370
|
+
break
|
|
1371
|
+
loc_env_file = ".." + os_path_sep + loc_env_file
|
|
1372
|
+
else:
|
|
1373
|
+
name = active_venv()
|
|
1374
|
+
if not name:
|
|
1375
|
+
return ""
|
|
1376
|
+
|
|
1377
|
+
bin_path = os_path_join(venv_root, 'versions', name, 'Scripts' if sys.platform == "win32" else 'bin')
|
|
1378
|
+
return bin_path if os_path_isdir(bin_path) else ""
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
class MockedMainApp:
|
|
1382
|
+
""" minimal main app class supporting essential printouts for debugging and testing. """
|
|
1383
|
+
debug = True
|
|
1384
|
+
verbose = False
|
|
1385
|
+
|
|
1386
|
+
def po(self, *args):
|
|
1387
|
+
""" redirect printouts of this mocked main app instance to Python's print() function. """
|
|
1388
|
+
if self.verbose:
|
|
1389
|
+
print(f" : print-out via mocked main app instance {self}")
|
|
1390
|
+
print(*args)
|
|
1391
|
+
|
|
1392
|
+
dpo = vpo = po #: other mocked print out methods of this mocked main app instance
|
|
1393
|
+
|
|
1394
|
+
def get_option(self, name: str, default_value: Optional[Any] = None) -> Any:
|
|
1395
|
+
""" return simulated/mocked option value.
|
|
1396
|
+
|
|
1397
|
+
:param name: name of the option.
|
|
1398
|
+
:param default_value: default value.
|
|
1399
|
+
:return: the default value if specified and has a value that will result in True (passing it to
|
|
1400
|
+
the :func:`bool` function), else a boolean True value for all option names except of
|
|
1401
|
+
'force' and 'more_verbose' (for which this function will return a boolean True value).
|
|
1402
|
+
"""
|
|
1403
|
+
if self.verbose:
|
|
1404
|
+
print(f" : get option {name} via mocked main app instance {self}")
|
|
1405
|
+
return default_value or name not in ('force', 'more_verbose')
|
|
1406
|
+
|
|
1407
|
+
get_argument = get_option
|
|
1408
|
+
|
|
1409
|
+
def get_variable(self, name: str, **_kwargs) -> Any:
|
|
1410
|
+
""" determine value of a config variable (only .env variables, no command line options, nor config/ini vars)
|
|
1411
|
+
|
|
1412
|
+
:param name: name of the config variable.
|
|
1413
|
+
:param _kwargs: ignored kwargs (see original/mocked method :meth:`~ae.console.ConsoleApp,get_variable`).
|
|
1414
|
+
:return: value of the config variable (str values only) or None if the variable does not exist.
|
|
1415
|
+
"""
|
|
1416
|
+
if self.verbose:
|
|
1417
|
+
print(f" : get variable {name} via mocked main app instance {self}")
|
|
1418
|
+
return env_str(MAIN_SECTION_NAME + '_' + name, convert_name=True)
|
|
1419
|
+
|
|
1420
|
+
def set_option(self, *args, **kwargs) -> str:
|
|
1421
|
+
""" ignoring any change/update of config variables or options """
|
|
1422
|
+
print(f" : {self} dummy method ignores any change/update of config variable or options! {args=} {kwargs=}")
|
|
1423
|
+
return ""
|
|
1424
|
+
|
|
1425
|
+
set_variable = set_option
|
|
1426
|
+
|
|
1427
|
+
def show_help(self):
|
|
1428
|
+
""" print hint to console to simulate the :meth:`~ae.console.ConsoleApp,show_help` method. """
|
|
1429
|
+
print(f" : no help for mocked main_app instance {self}")
|
|
1430
|
+
|
|
1431
|
+
def shutdown(self, exit_code: Optional[int] = 0, timeout: Optional[float] = None):
|
|
1432
|
+
""" simulate :meth:`~ae.console.ConsoleApp.shutdown` but never shutdown/exit the Python interpreter. """
|
|
1433
|
+
print(f" : shutdown function called with {exit_code=} and {timeout=} for mocked main_app instance {self}")
|