dycw-utilities 0.166.30__py3-none-any.whl → 0.175.17__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.
- dycw_utilities-0.175.17.dist-info/METADATA +34 -0
- {dycw_utilities-0.166.30.dist-info → dycw_utilities-0.175.17.dist-info}/RECORD +43 -38
- dycw_utilities-0.175.17.dist-info/WHEEL +4 -0
- {dycw_utilities-0.166.30.dist-info → dycw_utilities-0.175.17.dist-info}/entry_points.txt +1 -0
- utilities/__init__.py +1 -1
- utilities/altair.py +9 -4
- utilities/asyncio.py +10 -16
- utilities/cachetools.py +9 -6
- utilities/click.py +76 -20
- utilities/docker.py +293 -0
- utilities/functions.py +1 -1
- utilities/grp.py +28 -0
- utilities/hypothesis.py +38 -6
- utilities/importlib.py +17 -1
- utilities/jinja2.py +148 -0
- utilities/logging.py +7 -9
- utilities/orjson.py +18 -18
- utilities/os.py +38 -0
- utilities/parse.py +2 -2
- utilities/pathlib.py +18 -1
- utilities/permissions.py +298 -0
- utilities/platform.py +1 -1
- utilities/polars.py +4 -1
- utilities/postgres.py +28 -29
- utilities/pwd.py +28 -0
- utilities/pydantic.py +11 -0
- utilities/pydantic_settings.py +81 -8
- utilities/pydantic_settings_sops.py +13 -0
- utilities/pytest.py +60 -30
- utilities/pytest_regressions.py +26 -7
- utilities/shutil.py +25 -0
- utilities/sqlalchemy.py +15 -0
- utilities/subprocess.py +1572 -0
- utilities/tempfile.py +60 -1
- utilities/text.py +48 -32
- utilities/timer.py +2 -2
- utilities/traceback.py +1 -1
- utilities/types.py +5 -0
- utilities/typing.py +8 -2
- utilities/whenever.py +36 -5
- dycw_utilities-0.166.30.dist-info/METADATA +0 -41
- dycw_utilities-0.166.30.dist-info/WHEEL +0 -4
- dycw_utilities-0.166.30.dist-info/licenses/LICENSE +0 -21
- utilities/aeventkit.py +0 -388
- utilities/typed_settings.py +0 -152
utilities/subprocess.py
ADDED
|
@@ -0,0 +1,1572 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from io import StringIO
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from re import search
|
|
10
|
+
from shlex import join
|
|
11
|
+
from shutil import copyfile, copytree, move, rmtree
|
|
12
|
+
from string import Template
|
|
13
|
+
from subprocess import PIPE, CalledProcessError, Popen
|
|
14
|
+
from threading import Thread
|
|
15
|
+
from time import sleep
|
|
16
|
+
from typing import IO, TYPE_CHECKING, Literal, assert_never, overload, override
|
|
17
|
+
|
|
18
|
+
from utilities.errors import ImpossibleCaseError
|
|
19
|
+
from utilities.iterables import always_iterable
|
|
20
|
+
from utilities.logging import to_logger
|
|
21
|
+
from utilities.pathlib import PWD
|
|
22
|
+
from utilities.permissions import Permissions, ensure_perms
|
|
23
|
+
from utilities.tempfile import TemporaryDirectory
|
|
24
|
+
from utilities.text import strip_and_dedent
|
|
25
|
+
from utilities.whenever import SECOND, to_seconds
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Callable, Iterator
|
|
29
|
+
|
|
30
|
+
from utilities.permissions import PermissionsLike
|
|
31
|
+
from utilities.types import (
|
|
32
|
+
Delta,
|
|
33
|
+
LoggerLike,
|
|
34
|
+
MaybeIterable,
|
|
35
|
+
PathLike,
|
|
36
|
+
Retry,
|
|
37
|
+
StrMapping,
|
|
38
|
+
StrStrMapping,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_HOST_KEY_ALGORITHMS = ["ssh-ed25519"]
|
|
43
|
+
APT_UPDATE = ["apt", "update", "-y"]
|
|
44
|
+
BASH_LC = ["bash", "-lc"]
|
|
45
|
+
BASH_LS = ["bash", "-ls"]
|
|
46
|
+
CHPASSWD = "chpasswd"
|
|
47
|
+
GIT_BRANCH_SHOW_CURRENT = ["git", "branch", "--show-current"]
|
|
48
|
+
KNOWN_HOSTS = Path.home() / ".ssh/known_hosts"
|
|
49
|
+
MKTEMP_DIR_CMD = ["mktemp", "-d"]
|
|
50
|
+
RESTART_SSHD = ["systemctl", "restart", "sshd"]
|
|
51
|
+
UPDATE_CA_CERTIFICATES: str = "update-ca-certificates"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
##
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def apt_install(package: str, /, *, update: bool = False, sudo: bool = False) -> None:
|
|
58
|
+
"""Install a package."""
|
|
59
|
+
if update: # pragma: no cover
|
|
60
|
+
run(*maybe_sudo_cmd(*APT_UPDATE, sudo=sudo))
|
|
61
|
+
run(*maybe_sudo_cmd(*apt_install_cmd(package), sudo=sudo))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def apt_install_cmd(package: str, /) -> list[str]:
|
|
65
|
+
"""Command to use 'apt' to install a package."""
|
|
66
|
+
return ["apt", "install", "-y", package]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cat_cmd(path: PathLike, /) -> list[str]:
|
|
73
|
+
"""Command to use 'cat' to concatenate and print files."""
|
|
74
|
+
return ["cat", str(path)]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
##
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cd_cmd(path: PathLike, /) -> list[str]:
|
|
81
|
+
"""Command to use 'cd' to change working directory."""
|
|
82
|
+
return ["cd", str(path)]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
##
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def chmod(path: PathLike, perms: PermissionsLike, /, *, sudo: bool = False) -> None:
|
|
89
|
+
"""Change file mode."""
|
|
90
|
+
if sudo: # pragma: no cover
|
|
91
|
+
run(*sudo_cmd(*chmod_cmd(path, perms)))
|
|
92
|
+
else:
|
|
93
|
+
Path(path).chmod(int(ensure_perms(perms)))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def chmod_cmd(path: PathLike, perms: PermissionsLike, /) -> list[str]:
|
|
97
|
+
"""Command to use 'chmod' to change file mode."""
|
|
98
|
+
return ["chmod", str(ensure_perms(perms)), str(path)]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
##
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def chown(
|
|
105
|
+
path: PathLike,
|
|
106
|
+
/,
|
|
107
|
+
*,
|
|
108
|
+
sudo: bool = False,
|
|
109
|
+
user: str | int | None = None,
|
|
110
|
+
group: str | int | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Change file owner and/or group."""
|
|
113
|
+
if sudo: # pragma: no cover
|
|
114
|
+
match user, group:
|
|
115
|
+
case None, None:
|
|
116
|
+
...
|
|
117
|
+
case str() | int() | None, str() | int() | None:
|
|
118
|
+
run(*sudo_cmd(*chown_cmd(path, user=user, group=group)))
|
|
119
|
+
case never:
|
|
120
|
+
assert_never(never)
|
|
121
|
+
else:
|
|
122
|
+
match user, group:
|
|
123
|
+
case None, None:
|
|
124
|
+
...
|
|
125
|
+
case str() | int(), None:
|
|
126
|
+
shutil.chown(path, user, group)
|
|
127
|
+
case None, str() | int():
|
|
128
|
+
shutil.chown(path, user, group)
|
|
129
|
+
case str() | int(), str() | int():
|
|
130
|
+
shutil.chown(path, user, group)
|
|
131
|
+
case never:
|
|
132
|
+
assert_never(never)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def chown_cmd(
|
|
136
|
+
path: PathLike, /, *, user: str | int | None = None, group: str | int | None = None
|
|
137
|
+
) -> list[str]:
|
|
138
|
+
"""Command to use 'chown' to change file owner and/or group."""
|
|
139
|
+
match user, group:
|
|
140
|
+
case None, None:
|
|
141
|
+
raise ChownCmdError
|
|
142
|
+
case str() | int(), None:
|
|
143
|
+
ownership = "user"
|
|
144
|
+
case None, str() | int():
|
|
145
|
+
ownership = f":{group}"
|
|
146
|
+
case str() | int(), str() | int():
|
|
147
|
+
ownership = f"{user}:{group}"
|
|
148
|
+
case never:
|
|
149
|
+
assert_never(never)
|
|
150
|
+
return ["chown", ownership, str(path)]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(kw_only=True, slots=True)
|
|
154
|
+
class ChownCmdError(Exception):
|
|
155
|
+
@override
|
|
156
|
+
def __str__(self) -> str:
|
|
157
|
+
return "At least one of 'user' and/or 'group' must be given; got None"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
##
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def chpasswd(user_name: str, password: str, /, *, sudo: bool = False) -> None:
|
|
164
|
+
"""Update passwords."""
|
|
165
|
+
run( # pragma: no cover
|
|
166
|
+
*maybe_sudo_cmd(CHPASSWD, sudo=sudo), input=f"{user_name}:{password}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
##
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cp(
|
|
174
|
+
src: PathLike,
|
|
175
|
+
dest: PathLike,
|
|
176
|
+
/,
|
|
177
|
+
*,
|
|
178
|
+
sudo: bool = False,
|
|
179
|
+
perms: PermissionsLike | None = None,
|
|
180
|
+
owner: str | int | None = None,
|
|
181
|
+
group: str | int | None = None,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Copy a file/directory."""
|
|
184
|
+
mkdir(dest, sudo=sudo, parent=True)
|
|
185
|
+
if sudo: # pragma: no cover
|
|
186
|
+
run(*sudo_cmd(*cp_cmd(src, dest)))
|
|
187
|
+
else:
|
|
188
|
+
src, dest = map(Path, [src, dest])
|
|
189
|
+
if src.is_file():
|
|
190
|
+
_ = copyfile(src, dest)
|
|
191
|
+
elif src.is_dir():
|
|
192
|
+
_ = copytree(src, dest, dirs_exist_ok=True)
|
|
193
|
+
else:
|
|
194
|
+
raise CpError(src=src, dest=dest)
|
|
195
|
+
if perms is not None:
|
|
196
|
+
chmod(dest, perms, sudo=sudo)
|
|
197
|
+
if (owner is not None) or (group is not None):
|
|
198
|
+
chown(dest, sudo=sudo, user=owner, group=group)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(kw_only=True, slots=True)
|
|
202
|
+
class CpError(Exception):
|
|
203
|
+
src: Path
|
|
204
|
+
dest: Path
|
|
205
|
+
|
|
206
|
+
@override
|
|
207
|
+
def __str__(self) -> str:
|
|
208
|
+
return f"Unable to copy {str(self.src)!r} to {str(self.dest)!r}; source does not exist"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def cp_cmd(src: PathLike, dest: PathLike, /) -> list[str]:
|
|
212
|
+
"""Command to use 'cp' to copy a file/directory."""
|
|
213
|
+
return ["cp", "-r", str(src), str(dest)]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
##
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def echo_cmd(text: str, /) -> list[str]:
|
|
220
|
+
"""Command to use 'echo' to write arguments to the standard output."""
|
|
221
|
+
return ["echo", text]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
##
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def env_cmds(env: StrStrMapping, /) -> list[str]:
|
|
228
|
+
return [f"{key}={value}" for key, value in env.items()]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
##
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def expand_path(
|
|
235
|
+
path: PathLike, /, *, subs: StrMapping | None = None, sudo: bool = False
|
|
236
|
+
) -> Path:
|
|
237
|
+
"""Expand a path using `subprocess`."""
|
|
238
|
+
if subs is not None:
|
|
239
|
+
path = Template(str(path)).substitute(**subs)
|
|
240
|
+
if sudo: # pragma: no cover
|
|
241
|
+
return Path(run(*sudo_cmd(*echo_cmd(str(path))), return_=True))
|
|
242
|
+
return Path(path).expanduser()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
##
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def git_branch_current(path: PathLike, /) -> str:
|
|
249
|
+
"""Show the current a branch."""
|
|
250
|
+
return run(*GIT_BRANCH_SHOW_CURRENT, cwd=path, return_=True)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
##
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def git_checkout(branch: str, path: PathLike, /) -> None:
|
|
257
|
+
"""Switch a branch."""
|
|
258
|
+
run(*git_checkout_cmd(branch), cwd=path)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def git_checkout_cmd(branch: str, /) -> list[str]:
|
|
262
|
+
"""Command to use 'git checkout' to switch a branch."""
|
|
263
|
+
return ["git", "checkout", branch]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
##
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def git_clone(
|
|
270
|
+
url: str, path: PathLike, /, *, sudo: bool = False, branch: str | None = None
|
|
271
|
+
) -> None:
|
|
272
|
+
"""Clone a repository."""
|
|
273
|
+
rm(path, sudo=sudo)
|
|
274
|
+
run(*maybe_sudo_cmd(*git_clone_cmd(url, path), sudo=sudo))
|
|
275
|
+
if branch is not None:
|
|
276
|
+
git_checkout(branch, path)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def git_clone_cmd(url: str, path: PathLike, /) -> list[str]:
|
|
280
|
+
"""Command to use 'git clone' to clone a repository."""
|
|
281
|
+
return ["git", "clone", "--recurse-submodules", url, str(path)]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
##
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def maybe_parent(path: PathLike, /, *, parent: bool = False) -> Path:
|
|
288
|
+
"""Get the parent of a path, if required."""
|
|
289
|
+
path = Path(path)
|
|
290
|
+
return path.parent if parent else path
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
##
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def mkdir(path: PathLike, /, *, sudo: bool = False, parent: bool = False) -> None:
|
|
297
|
+
"""Make a directory."""
|
|
298
|
+
if sudo: # pragma: no cover
|
|
299
|
+
run(*sudo_cmd(*mkdir_cmd(path, parent=parent)))
|
|
300
|
+
else:
|
|
301
|
+
maybe_parent(path, parent=parent).mkdir(parents=True, exist_ok=True)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
##
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def mkdir_cmd(path: PathLike, /, *, parent: bool = False) -> list[str]:
|
|
308
|
+
"""Command to use 'mv' to make a directory."""
|
|
309
|
+
return ["mkdir", "-p", str(maybe_parent(path, parent=parent))]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
##
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def mv(
|
|
316
|
+
src: PathLike,
|
|
317
|
+
dest: PathLike,
|
|
318
|
+
/,
|
|
319
|
+
*,
|
|
320
|
+
sudo: bool = False,
|
|
321
|
+
perms: PermissionsLike | None = None,
|
|
322
|
+
owner: str | int | None = None,
|
|
323
|
+
group: str | int | None = None,
|
|
324
|
+
) -> None:
|
|
325
|
+
"""Move a file/directory."""
|
|
326
|
+
mkdir(dest, sudo=sudo, parent=True)
|
|
327
|
+
if sudo: # pragma: no cover
|
|
328
|
+
run(*sudo_cmd(*cp_cmd(src, dest)))
|
|
329
|
+
else:
|
|
330
|
+
src, dest = map(Path, [src, dest])
|
|
331
|
+
if src.exists():
|
|
332
|
+
_ = move(src, dest)
|
|
333
|
+
else:
|
|
334
|
+
raise MvFileError(src=src, dest=dest)
|
|
335
|
+
if perms is not None:
|
|
336
|
+
chmod(dest, perms, sudo=sudo)
|
|
337
|
+
if (owner is not None) or (group is not None):
|
|
338
|
+
chown(dest, sudo=sudo, user=owner, group=group)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@dataclass(kw_only=True, slots=True)
|
|
342
|
+
class MvFileError(Exception):
|
|
343
|
+
src: Path
|
|
344
|
+
dest: Path
|
|
345
|
+
|
|
346
|
+
@override
|
|
347
|
+
def __str__(self) -> str:
|
|
348
|
+
return f"Unable to move {str(self.src)!r} to {str(self.dest)!r}; source does not exist"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def mv_cmd(src: PathLike, dest: PathLike, /) -> list[str]:
|
|
352
|
+
"""Command to use 'mv' to move a file/directory."""
|
|
353
|
+
return ["mv", str(src), str(dest)]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
##
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def ripgrep(*args: str, path: PathLike = PWD) -> str | None:
|
|
360
|
+
"""Search for lines."""
|
|
361
|
+
try: # skipif-ci
|
|
362
|
+
return run(*ripgrep_cmd(*args, path=path), return_=True)
|
|
363
|
+
except CalledProcessError as error: # skipif-ci
|
|
364
|
+
if error.returncode == 1:
|
|
365
|
+
return None
|
|
366
|
+
raise
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def ripgrep_cmd(*args: str, path: PathLike = PWD) -> list[str]:
|
|
370
|
+
"""Command to use 'ripgrep' to search for lines."""
|
|
371
|
+
return ["rg", *args, str(path)]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
##
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def rm(path: PathLike, /, *paths: PathLike, sudo: bool = False) -> None:
|
|
378
|
+
"""Remove a file/directory."""
|
|
379
|
+
if sudo: # pragma: no cover
|
|
380
|
+
run(*sudo_cmd(*rm_cmd(path, *paths)))
|
|
381
|
+
else:
|
|
382
|
+
for p in map(Path, [path, *paths]):
|
|
383
|
+
if p.is_file():
|
|
384
|
+
p.unlink(missing_ok=True)
|
|
385
|
+
elif p.is_dir():
|
|
386
|
+
rmtree(p, ignore_errors=True)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def rm_cmd(path: PathLike, /, *paths: PathLike) -> list[str]:
|
|
390
|
+
"""Command to use 'rm' to remove a file/directory."""
|
|
391
|
+
return ["rm", "-rf", str(path), *map(str, paths)]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
##
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def rsync(
|
|
398
|
+
src_or_srcs: MaybeIterable[PathLike],
|
|
399
|
+
user: str,
|
|
400
|
+
hostname: str,
|
|
401
|
+
dest: PathLike,
|
|
402
|
+
/,
|
|
403
|
+
*,
|
|
404
|
+
sudo: bool = False,
|
|
405
|
+
batch_mode: bool = True,
|
|
406
|
+
host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
|
|
407
|
+
strict_host_key_checking: bool = True,
|
|
408
|
+
print: bool = False, # noqa: A002
|
|
409
|
+
retry: Retry | None = None,
|
|
410
|
+
logger: LoggerLike | None = None,
|
|
411
|
+
chown_user: str | None = None,
|
|
412
|
+
chown_group: str | None = None,
|
|
413
|
+
exclude: MaybeIterable[str] | None = None,
|
|
414
|
+
chmod: PermissionsLike | None = None,
|
|
415
|
+
) -> None:
|
|
416
|
+
"""Remote & local file copying."""
|
|
417
|
+
mkdir_args = maybe_sudo_cmd(*mkdir_cmd(dest, parent=True), sudo=sudo) # skipif-ci
|
|
418
|
+
ssh( # skipif-ci
|
|
419
|
+
user,
|
|
420
|
+
hostname,
|
|
421
|
+
*mkdir_args,
|
|
422
|
+
batch_mode=batch_mode,
|
|
423
|
+
host_key_algorithms=host_key_algorithms,
|
|
424
|
+
strict_host_key_checking=strict_host_key_checking,
|
|
425
|
+
print=print,
|
|
426
|
+
retry=retry,
|
|
427
|
+
logger=logger,
|
|
428
|
+
)
|
|
429
|
+
srcs = list(always_iterable(src_or_srcs)) # skipif-ci
|
|
430
|
+
rsync_args = rsync_cmd( # skipif-ci
|
|
431
|
+
srcs,
|
|
432
|
+
user,
|
|
433
|
+
hostname,
|
|
434
|
+
dest,
|
|
435
|
+
archive=any(Path(s).is_dir() for s in srcs),
|
|
436
|
+
chown_user=chown_user,
|
|
437
|
+
chown_group=chown_group,
|
|
438
|
+
exclude=exclude,
|
|
439
|
+
batch_mode=batch_mode,
|
|
440
|
+
host_key_algorithms=host_key_algorithms,
|
|
441
|
+
strict_host_key_checking=strict_host_key_checking,
|
|
442
|
+
sudo=sudo,
|
|
443
|
+
)
|
|
444
|
+
run(*rsync_args, print=print, retry=retry, logger=logger) # skipif-ci
|
|
445
|
+
if chmod is not None: # skipif-ci
|
|
446
|
+
chmod_args = maybe_sudo_cmd(*chmod_cmd(dest, chmod), sudo=sudo)
|
|
447
|
+
ssh(
|
|
448
|
+
user,
|
|
449
|
+
hostname,
|
|
450
|
+
*chmod_args,
|
|
451
|
+
batch_mode=batch_mode,
|
|
452
|
+
host_key_algorithms=host_key_algorithms,
|
|
453
|
+
strict_host_key_checking=strict_host_key_checking,
|
|
454
|
+
print=print,
|
|
455
|
+
retry=retry,
|
|
456
|
+
logger=logger,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def rsync_cmd(
|
|
461
|
+
src_or_srcs: MaybeIterable[PathLike],
|
|
462
|
+
user: str,
|
|
463
|
+
hostname: str,
|
|
464
|
+
dest: PathLike,
|
|
465
|
+
/,
|
|
466
|
+
*,
|
|
467
|
+
archive: bool = False,
|
|
468
|
+
chown_user: str | None = None,
|
|
469
|
+
chown_group: str | None = None,
|
|
470
|
+
exclude: MaybeIterable[str] | None = None,
|
|
471
|
+
batch_mode: bool = True,
|
|
472
|
+
host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
|
|
473
|
+
strict_host_key_checking: bool = True,
|
|
474
|
+
sudo: bool = False,
|
|
475
|
+
) -> list[str]:
|
|
476
|
+
"""Command to use 'rsync' to do remote & local file copying."""
|
|
477
|
+
args: list[str] = ["rsync"]
|
|
478
|
+
if archive:
|
|
479
|
+
args.append("--archive")
|
|
480
|
+
args.append("--checksum")
|
|
481
|
+
match chown_user, chown_group:
|
|
482
|
+
case None, None:
|
|
483
|
+
...
|
|
484
|
+
case str(), None:
|
|
485
|
+
args.extend(["--chown", chown_user])
|
|
486
|
+
case None, str():
|
|
487
|
+
args.extend(["--chown", f":{chown_group}"])
|
|
488
|
+
case str(), str():
|
|
489
|
+
args.extend(["--chown", f"{chown_user}:{chown_group}"])
|
|
490
|
+
case never:
|
|
491
|
+
assert_never(never)
|
|
492
|
+
args.append("--compress")
|
|
493
|
+
if exclude is not None:
|
|
494
|
+
for exclude_i in always_iterable(exclude):
|
|
495
|
+
args.extend(["--exclude", exclude_i])
|
|
496
|
+
rsh_args: list[str] = ssh_opts_cmd(
|
|
497
|
+
batch_mode=batch_mode,
|
|
498
|
+
host_key_algorithms=host_key_algorithms,
|
|
499
|
+
strict_host_key_checking=strict_host_key_checking,
|
|
500
|
+
)
|
|
501
|
+
args.extend(["--rsh", join(rsh_args)])
|
|
502
|
+
if sudo:
|
|
503
|
+
args.extend(["--rsync-path", join(sudo_cmd("rsync"))])
|
|
504
|
+
srcs = list(always_iterable(src_or_srcs)) # do not Path()
|
|
505
|
+
if len(srcs) == 0:
|
|
506
|
+
raise RsyncCmdNoSourcesError(user=user, hostname=hostname, dest=dest)
|
|
507
|
+
missing = [s for s in srcs if not Path(s).exists()]
|
|
508
|
+
if len(missing) >= 1:
|
|
509
|
+
raise RsyncCmdSourcesNotFoundError(
|
|
510
|
+
sources=missing, user=user, hostname=hostname, dest=dest
|
|
511
|
+
)
|
|
512
|
+
return [*args, *map(str, srcs), f"{user}@{hostname}:{dest}"]
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@dataclass(kw_only=True, slots=True)
|
|
516
|
+
class RsyncCmdError(Exception):
|
|
517
|
+
user: str
|
|
518
|
+
hostname: str
|
|
519
|
+
dest: PathLike
|
|
520
|
+
|
|
521
|
+
@override
|
|
522
|
+
def __str__(self) -> str:
|
|
523
|
+
return f"No sources selected to send to {self.user}@{self.hostname}:{self.dest}"
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@dataclass(kw_only=True, slots=True)
|
|
527
|
+
class RsyncCmdNoSourcesError(RsyncCmdError):
|
|
528
|
+
@override
|
|
529
|
+
def __str__(self) -> str:
|
|
530
|
+
return f"No sources selected to send to {self.user}@{self.hostname}:{self.dest}"
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
@dataclass(kw_only=True, slots=True)
|
|
534
|
+
class RsyncCmdSourcesNotFoundError(RsyncCmdError):
|
|
535
|
+
sources: list[PathLike]
|
|
536
|
+
|
|
537
|
+
@override
|
|
538
|
+
def __str__(self) -> str:
|
|
539
|
+
desc = ", ".join(map(repr, map(str, self.sources)))
|
|
540
|
+
return f"Sources selected to send to {self.user}@{self.hostname}:{self.dest} but not found: {desc}"
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
##
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def rsync_many(
|
|
547
|
+
user: str,
|
|
548
|
+
hostname: str,
|
|
549
|
+
/,
|
|
550
|
+
*items: tuple[PathLike, PathLike]
|
|
551
|
+
| tuple[Literal["sudo"], PathLike, PathLike]
|
|
552
|
+
| tuple[PathLike, PathLike, PermissionsLike],
|
|
553
|
+
retry: Retry | None = None,
|
|
554
|
+
logger: LoggerLike | None = None,
|
|
555
|
+
keep: bool = False,
|
|
556
|
+
batch_mode: bool = True,
|
|
557
|
+
host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
|
|
558
|
+
strict_host_key_checking: bool = True,
|
|
559
|
+
print: bool = False, # noqa: A002
|
|
560
|
+
exclude: MaybeIterable[str] | None = None,
|
|
561
|
+
) -> None:
|
|
562
|
+
cmds: list[list[str]] = [] # skipif-ci
|
|
563
|
+
with ( # skipif-ci
|
|
564
|
+
TemporaryDirectory() as temp_src,
|
|
565
|
+
yield_ssh_temp_dir(
|
|
566
|
+
user, hostname, retry=retry, logger=logger, keep=keep
|
|
567
|
+
) as temp_dest,
|
|
568
|
+
):
|
|
569
|
+
for item in items:
|
|
570
|
+
match item:
|
|
571
|
+
case Path() | str() as src, Path() | str() as dest:
|
|
572
|
+
cmds.extend(_rsync_many_prepare(src, dest, temp_src, temp_dest))
|
|
573
|
+
case "sudo", Path() | str() as src, Path() | str() as dest:
|
|
574
|
+
cmds.extend(
|
|
575
|
+
_rsync_many_prepare(src, dest, temp_src, temp_dest, sudo=True)
|
|
576
|
+
)
|
|
577
|
+
case (
|
|
578
|
+
Path() | str() as src,
|
|
579
|
+
Path() | str() as dest,
|
|
580
|
+
Permissions() | int() | str() as perms,
|
|
581
|
+
):
|
|
582
|
+
cmds.extend(
|
|
583
|
+
_rsync_many_prepare(src, dest, temp_src, temp_dest, perms=perms)
|
|
584
|
+
)
|
|
585
|
+
case never:
|
|
586
|
+
assert_never(never)
|
|
587
|
+
rsync(
|
|
588
|
+
f"{temp_src}/",
|
|
589
|
+
user,
|
|
590
|
+
hostname,
|
|
591
|
+
temp_dest,
|
|
592
|
+
batch_mode=batch_mode,
|
|
593
|
+
host_key_algorithms=host_key_algorithms,
|
|
594
|
+
strict_host_key_checking=strict_host_key_checking,
|
|
595
|
+
print=print,
|
|
596
|
+
retry=retry,
|
|
597
|
+
logger=logger,
|
|
598
|
+
exclude=exclude,
|
|
599
|
+
)
|
|
600
|
+
ssh(
|
|
601
|
+
user,
|
|
602
|
+
hostname,
|
|
603
|
+
*BASH_LS,
|
|
604
|
+
input="\n".join(map(join, cmds)),
|
|
605
|
+
print=print,
|
|
606
|
+
retry=retry,
|
|
607
|
+
logger=logger,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _rsync_many_prepare(
|
|
612
|
+
src: PathLike,
|
|
613
|
+
dest: PathLike,
|
|
614
|
+
temp_src: PathLike,
|
|
615
|
+
temp_dest: PathLike,
|
|
616
|
+
/,
|
|
617
|
+
*,
|
|
618
|
+
sudo: bool = False,
|
|
619
|
+
perms: PermissionsLike | None = None,
|
|
620
|
+
) -> list[list[str]]:
|
|
621
|
+
dest, temp_src, temp_dest = map(Path, [dest, temp_src, temp_dest])
|
|
622
|
+
n = len(list(temp_src.iterdir()))
|
|
623
|
+
name = str(n)
|
|
624
|
+
match src:
|
|
625
|
+
case Path():
|
|
626
|
+
cp(src, temp_src / name)
|
|
627
|
+
case str():
|
|
628
|
+
if Path(src).exists():
|
|
629
|
+
cp(src, temp_src / name)
|
|
630
|
+
else:
|
|
631
|
+
tee(temp_src / name, src)
|
|
632
|
+
case never:
|
|
633
|
+
assert_never(never)
|
|
634
|
+
cmds: list[list[str]] = [
|
|
635
|
+
maybe_sudo_cmd(*mkdir_cmd(dest, parent=True), sudo=sudo),
|
|
636
|
+
maybe_sudo_cmd(*cp_cmd(temp_dest / name, dest), sudo=sudo),
|
|
637
|
+
]
|
|
638
|
+
if perms is not None:
|
|
639
|
+
cmds.append(maybe_sudo_cmd(*chmod_cmd(dest, perms), sudo=sudo))
|
|
640
|
+
return cmds
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
##
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@overload
|
|
647
|
+
def run(
|
|
648
|
+
cmd: str,
|
|
649
|
+
/,
|
|
650
|
+
*cmds_or_args: str,
|
|
651
|
+
user: str | int | None = None,
|
|
652
|
+
executable: str | None = None,
|
|
653
|
+
shell: bool = False,
|
|
654
|
+
cwd: PathLike | None = None,
|
|
655
|
+
env: StrStrMapping | None = None,
|
|
656
|
+
input: str | None = None,
|
|
657
|
+
print: bool = False,
|
|
658
|
+
print_stdout: bool = False,
|
|
659
|
+
print_stderr: bool = False,
|
|
660
|
+
return_: Literal[True],
|
|
661
|
+
return_stdout: bool = False,
|
|
662
|
+
return_stderr: bool = False,
|
|
663
|
+
retry: Retry | None = None,
|
|
664
|
+
retry_skip: Callable[[int, str, str], bool] | None = None,
|
|
665
|
+
logger: LoggerLike | None = None,
|
|
666
|
+
) -> str: ...
|
|
667
|
+
@overload
|
|
668
|
+
def run(
|
|
669
|
+
cmd: str,
|
|
670
|
+
/,
|
|
671
|
+
*cmds_or_args: str,
|
|
672
|
+
user: str | int | None = None,
|
|
673
|
+
executable: str | None = None,
|
|
674
|
+
shell: bool = False,
|
|
675
|
+
cwd: PathLike | None = None,
|
|
676
|
+
env: StrStrMapping | None = None,
|
|
677
|
+
input: str | None = None,
|
|
678
|
+
print: bool = False,
|
|
679
|
+
print_stdout: bool = False,
|
|
680
|
+
print_stderr: bool = False,
|
|
681
|
+
return_: bool = False,
|
|
682
|
+
return_stdout: Literal[True],
|
|
683
|
+
return_stderr: bool = False,
|
|
684
|
+
retry: Retry | None = None,
|
|
685
|
+
retry_skip: Callable[[int, str, str], bool] | None = None,
|
|
686
|
+
logger: LoggerLike | None = None,
|
|
687
|
+
) -> str: ...
|
|
688
|
+
@overload
|
|
689
|
+
def run(
|
|
690
|
+
cmd: str,
|
|
691
|
+
/,
|
|
692
|
+
*cmds_or_args: str,
|
|
693
|
+
user: str | int | None = None,
|
|
694
|
+
executable: str | None = None,
|
|
695
|
+
shell: bool = False,
|
|
696
|
+
cwd: PathLike | None = None,
|
|
697
|
+
env: StrStrMapping | None = None,
|
|
698
|
+
input: str | None = None,
|
|
699
|
+
print: bool = False,
|
|
700
|
+
print_stdout: bool = False,
|
|
701
|
+
print_stderr: bool = False,
|
|
702
|
+
return_: bool = False,
|
|
703
|
+
return_stdout: bool = False,
|
|
704
|
+
return_stderr: Literal[True],
|
|
705
|
+
retry: Retry | None = None,
|
|
706
|
+
retry_skip: Callable[[int, str, str], bool] | None = None,
|
|
707
|
+
logger: LoggerLike | None = None,
|
|
708
|
+
) -> str: ...
|
|
709
|
+
@overload
|
|
710
|
+
def run(
|
|
711
|
+
cmd: str,
|
|
712
|
+
/,
|
|
713
|
+
*cmds_or_args: str,
|
|
714
|
+
user: str | int | None = None,
|
|
715
|
+
executable: str | None = None,
|
|
716
|
+
shell: bool = False,
|
|
717
|
+
cwd: PathLike | None = None,
|
|
718
|
+
env: StrStrMapping | None = None,
|
|
719
|
+
input: str | None = None,
|
|
720
|
+
print: bool = False,
|
|
721
|
+
print_stdout: bool = False,
|
|
722
|
+
print_stderr: bool = False,
|
|
723
|
+
return_: Literal[False] = False,
|
|
724
|
+
return_stdout: Literal[False] = False,
|
|
725
|
+
return_stderr: Literal[False] = False,
|
|
726
|
+
retry: Retry | None = None,
|
|
727
|
+
retry_skip: Callable[[int, str, str], bool] | None = None,
|
|
728
|
+
logger: LoggerLike | None = None,
|
|
729
|
+
) -> None: ...
|
|
730
|
+
@overload
|
|
731
|
+
def run(
|
|
732
|
+
cmd: str,
|
|
733
|
+
/,
|
|
734
|
+
*cmds_or_args: str,
|
|
735
|
+
user: str | int | None = None,
|
|
736
|
+
executable: str | None = None,
|
|
737
|
+
shell: bool = False,
|
|
738
|
+
cwd: PathLike | None = None,
|
|
739
|
+
env: StrStrMapping | None = None,
|
|
740
|
+
input: str | None = None,
|
|
741
|
+
print: bool = False,
|
|
742
|
+
print_stdout: bool = False,
|
|
743
|
+
print_stderr: bool = False,
|
|
744
|
+
return_: bool = False,
|
|
745
|
+
return_stdout: bool = False,
|
|
746
|
+
return_stderr: bool = False,
|
|
747
|
+
retry: Retry | None = None,
|
|
748
|
+
retry_skip: Callable[[int, str, str], bool] | None = None,
|
|
749
|
+
logger: LoggerLike | None = None,
|
|
750
|
+
) -> str | None: ...
|
|
751
|
+
def run(
|
|
752
|
+
cmd: str,
|
|
753
|
+
/,
|
|
754
|
+
*cmds_or_args: str,
|
|
755
|
+
user: str | int | None = None,
|
|
756
|
+
executable: str | None = None,
|
|
757
|
+
shell: bool = False,
|
|
758
|
+
cwd: PathLike | None = None,
|
|
759
|
+
env: StrStrMapping | None = None,
|
|
760
|
+
input: str | None = None, # noqa: A002
|
|
761
|
+
print: bool = False, # noqa: A002
|
|
762
|
+
print_stdout: bool = False,
|
|
763
|
+
print_stderr: bool = False,
|
|
764
|
+
return_: bool = False,
|
|
765
|
+
return_stdout: bool = False,
|
|
766
|
+
return_stderr: bool = False,
|
|
767
|
+
retry: Retry | None = None,
|
|
768
|
+
retry_skip: Callable[[int, str, str], bool] | None = None,
|
|
769
|
+
logger: LoggerLike | None = None,
|
|
770
|
+
) -> str | None:
|
|
771
|
+
"""Run a command in a subprocess."""
|
|
772
|
+
args: list[str] = []
|
|
773
|
+
if user is not None: # pragma: no cover
|
|
774
|
+
args.extend(["su", "-", str(user)])
|
|
775
|
+
args.extend([cmd, *cmds_or_args])
|
|
776
|
+
buffer = StringIO()
|
|
777
|
+
stdout = StringIO()
|
|
778
|
+
stderr = StringIO()
|
|
779
|
+
stdout_outputs: list[IO[str]] = [buffer, stdout]
|
|
780
|
+
if print or print_stdout:
|
|
781
|
+
stdout_outputs.append(sys.stdout)
|
|
782
|
+
stderr_outputs: list[IO[str]] = [buffer, stderr]
|
|
783
|
+
if print or print_stderr:
|
|
784
|
+
stderr_outputs.append(sys.stderr)
|
|
785
|
+
with Popen(
|
|
786
|
+
args,
|
|
787
|
+
bufsize=1,
|
|
788
|
+
executable=executable,
|
|
789
|
+
stdin=PIPE,
|
|
790
|
+
stdout=PIPE,
|
|
791
|
+
stderr=PIPE,
|
|
792
|
+
shell=shell,
|
|
793
|
+
cwd=cwd,
|
|
794
|
+
env=env,
|
|
795
|
+
text=True,
|
|
796
|
+
user=user,
|
|
797
|
+
) as proc:
|
|
798
|
+
if proc.stdin is None: # pragma: no cover
|
|
799
|
+
raise ImpossibleCaseError(case=[f"{proc.stdin=}"])
|
|
800
|
+
if proc.stdout is None: # pragma: no cover
|
|
801
|
+
raise ImpossibleCaseError(case=[f"{proc.stdout=}"])
|
|
802
|
+
if proc.stderr is None: # pragma: no cover
|
|
803
|
+
raise ImpossibleCaseError(case=[f"{proc.stderr=}"])
|
|
804
|
+
with (
|
|
805
|
+
_run_yield_write(proc.stdout, *stdout_outputs),
|
|
806
|
+
_run_yield_write(proc.stderr, *stderr_outputs),
|
|
807
|
+
):
|
|
808
|
+
if input is not None:
|
|
809
|
+
_ = proc.stdin.write(input)
|
|
810
|
+
proc.stdin.flush()
|
|
811
|
+
proc.stdin.close()
|
|
812
|
+
return_code = proc.wait()
|
|
813
|
+
match return_code, return_ or return_stdout, return_ or return_stderr:
|
|
814
|
+
case 0, True, True:
|
|
815
|
+
_ = buffer.seek(0)
|
|
816
|
+
return buffer.read().rstrip("\n")
|
|
817
|
+
case 0, True, False:
|
|
818
|
+
_ = stdout.seek(0)
|
|
819
|
+
return stdout.read().rstrip("\n")
|
|
820
|
+
case 0, False, True:
|
|
821
|
+
_ = stderr.seek(0)
|
|
822
|
+
return stderr.read().rstrip("\n")
|
|
823
|
+
case 0, False, False:
|
|
824
|
+
return None
|
|
825
|
+
case _, _, _:
|
|
826
|
+
_ = stdout.seek(0)
|
|
827
|
+
stdout_text = stdout.read()
|
|
828
|
+
_ = stderr.seek(0)
|
|
829
|
+
stderr_text = stderr.read()
|
|
830
|
+
if (retry is None) or (
|
|
831
|
+
(retry is not None)
|
|
832
|
+
and (retry_skip is not None)
|
|
833
|
+
and retry_skip(return_code, stdout_text, stderr_text)
|
|
834
|
+
):
|
|
835
|
+
attempts = delta = None
|
|
836
|
+
else:
|
|
837
|
+
attempts, delta = retry
|
|
838
|
+
if logger is not None:
|
|
839
|
+
msg = strip_and_dedent(f"""
|
|
840
|
+
'run' failed with:
|
|
841
|
+
- cmd = {cmd}
|
|
842
|
+
- cmds_or_args = {cmds_or_args}
|
|
843
|
+
- user = {user}
|
|
844
|
+
- executable = {executable}
|
|
845
|
+
- shell = {shell}
|
|
846
|
+
- cwd = {cwd}
|
|
847
|
+
- env = {env}
|
|
848
|
+
|
|
849
|
+
-- stdin ----------------------------------------------------------------------
|
|
850
|
+
{"" if input is None else input}-------------------------------------------------------------------------------
|
|
851
|
+
-- stdout ---------------------------------------------------------------------
|
|
852
|
+
{stdout_text}-------------------------------------------------------------------------------
|
|
853
|
+
-- stderr ---------------------------------------------------------------------
|
|
854
|
+
{stderr_text}-------------------------------------------------------------------------------
|
|
855
|
+
""")
|
|
856
|
+
if (attempts is not None) and (attempts >= 1):
|
|
857
|
+
if delta is None:
|
|
858
|
+
msg = f"{msg}\n\nRetrying {attempts} more time(s)..."
|
|
859
|
+
else:
|
|
860
|
+
msg = f"{msg}\n\nRetrying {attempts} more time(s) after {delta}..."
|
|
861
|
+
to_logger(logger).error(msg)
|
|
862
|
+
error = CalledProcessError(
|
|
863
|
+
return_code, args, output=stdout_text, stderr=stderr_text
|
|
864
|
+
)
|
|
865
|
+
if (attempts is None) or (attempts <= 0):
|
|
866
|
+
raise error
|
|
867
|
+
if delta is not None:
|
|
868
|
+
sleep(to_seconds(delta))
|
|
869
|
+
return run(
|
|
870
|
+
cmd,
|
|
871
|
+
*cmds_or_args,
|
|
872
|
+
user=user,
|
|
873
|
+
executable=executable,
|
|
874
|
+
shell=shell,
|
|
875
|
+
cwd=cwd,
|
|
876
|
+
env=env,
|
|
877
|
+
input=input,
|
|
878
|
+
print=print,
|
|
879
|
+
print_stdout=print_stdout,
|
|
880
|
+
print_stderr=print_stderr,
|
|
881
|
+
return_=return_,
|
|
882
|
+
return_stdout=return_stdout,
|
|
883
|
+
return_stderr=return_stderr,
|
|
884
|
+
retry=(attempts - 1, delta),
|
|
885
|
+
logger=logger,
|
|
886
|
+
)
|
|
887
|
+
case never:
|
|
888
|
+
assert_never(never)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
@contextmanager
|
|
892
|
+
def _run_yield_write(input_: IO[str], /, *outputs: IO[str]) -> Iterator[None]:
|
|
893
|
+
thread = Thread(target=_run_daemon_target, args=(input_, *outputs), daemon=True)
|
|
894
|
+
thread.start()
|
|
895
|
+
try:
|
|
896
|
+
yield
|
|
897
|
+
finally:
|
|
898
|
+
thread.join()
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _run_daemon_target(input_: IO[str], /, *outputs: IO[str]) -> None:
|
|
902
|
+
with input_:
|
|
903
|
+
for text in iter(input_.readline, ""):
|
|
904
|
+
_run_write_to_streams(text, *outputs)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _run_write_to_streams(text: str, /, *outputs: IO[str]) -> None:
|
|
908
|
+
for output in outputs:
|
|
909
|
+
_ = output.write(text)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
##
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def set_hostname_cmd(hostname: str, /) -> list[str]:
|
|
916
|
+
"""Command to set the system hostname."""
|
|
917
|
+
return ["hostnamectl", "set-hostname", hostname]
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
##
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
@overload
|
|
924
|
+
def ssh(
|
|
925
|
+
user: str,
|
|
926
|
+
hostname: str,
|
|
927
|
+
/,
|
|
928
|
+
*cmd_and_args: str,
|
|
929
|
+
batch_mode: bool = True,
|
|
930
|
+
host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
|
|
931
|
+
strict_host_key_checking: bool = True,
|
|
932
|
+
port: int | None = None,
|
|
933
|
+
env: StrStrMapping | None = None,
|
|
934
|
+
input: str | None = None,
|
|
935
|
+
print: bool = False,
|
|
936
|
+
print_stdout: bool = False,
|
|
937
|
+
print_stderr: bool = False,
|
|
938
|
+
return_: Literal[True],
|
|
939
|
+
return_stdout: bool = False,
|
|
940
|
+
return_stderr: bool = False,
|
|
941
|
+
retry: Retry | None = None,
|
|
942
|
+
logger: LoggerLike | None = None,
|
|
943
|
+
) -> str: ...
|
|
944
|
+
@overload
|
|
945
|
+
def ssh(
|
|
946
|
+
user: str,
|
|
947
|
+
hostname: str,
|
|
948
|
+
/,
|
|
949
|
+
*cmd_and_args: str,
|
|
950
|
+
batch_mode: bool = True,
|
|
951
|
+
host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
|
|
952
|
+
strict_host_key_checking: bool = True,
|
|
953
|
+
port: int | None = None,
|
|
954
|
+
env: StrStrMapping | None = None,
|
|
955
|
+
input: str | None = None,
|
|
956
|
+
print: bool = False,
|
|
957
|
+
print_stdout: bool = False,
|
|
958
|
+
print_stderr: bool = False,
|
|
959
|
+
return_: bool = False,
|
|
960
|
+
return_stdout: Literal[True],
|
|
961
|
+
return_stderr: bool = False,
|
|
962
|
+
retry: Retry | None = None,
|
|
963
|
+
logger: LoggerLike | None = None,
|
|
964
|
+
) -> str: ...
|
|
965
|
+
@overload
|
|
966
|
+
def ssh(
|
|
967
|
+
user: str,
|
|
968
|
+
hostname: str,
|
|
969
|
+
/,
|
|
970
|
+
*cmd_and_args: str,
|
|
971
|
+
batch_mode: bool = True,
|
|
972
|
+
host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
|
|
973
|
+
strict_host_key_checking: bool = True,
|
|
974
|
+
port: int | None = None,
|
|
975
|
+
env: StrStrMapping | None = None,
|
|
976
|
+
input: str | None = None,
|
|
977
|
+
print: bool = False,
|
|
978
|
+
print_stdout: bool = False,
|
|
979
|
+
print_stderr: bool = False,
|
|
980
|
+
return_: bool = False,
|
|
981
|
+
return_stdout: bool = False,
|
|
982
|
+
return_stderr: Literal[True],
|
|
983
|
+
retry: Retry | None = None,
|
|
984
|
+
logger: LoggerLike | None = None,
|
|
985
|
+
) -> str: ...
|
|
986
|
+
@overload
|
|
987
|
+
def ssh(
|
|
988
|
+
user: str,
|
|
989
|
+
hostname: str,
|
|
990
|
+
/,
|
|
991
|
+
*cmd_and_args: str,
|
|
992
|
+
batch_mode: bool = True,
|
|
993
|
+
host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
|
|
994
|
+
strict_host_key_checking: bool = True,
|
|
995
|
+
port: int | None = None,
|
|
996
|
+
env: StrStrMapping | None = None,
|
|
997
|
+
input: str | None = None,
|
|
998
|
+
print: bool = False,
|
|
999
|
+
print_stdout: bool = False,
|
|
1000
|
+
print_stderr: bool = False,
|
|
1001
|
+
return_: Literal[False] = False,
|
|
1002
|
+
return_stdout: Literal[False] = False,
|
|
1003
|
+
return_stderr: Literal[False] = False,
|
|
1004
|
+
retry: Retry | None = None,
|
|
1005
|
+
logger: LoggerLike | None = None,
|
|
1006
|
+
) -> None: ...
|
|
1007
|
+
@overload
|
|
1008
|
+
def ssh(
|
|
1009
|
+
user: str,
|
|
1010
|
+
hostname: str,
|
|
1011
|
+
/,
|
|
1012
|
+
*cmd_and_args: str,
|
|
1013
|
+
batch_mode: bool = True,
|
|
1014
|
+
host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
|
|
1015
|
+
strict_host_key_checking: bool = True,
|
|
1016
|
+
port: int | None = None,
|
|
1017
|
+
env: StrStrMapping | None = None,
|
|
1018
|
+
input: str | None = None,
|
|
1019
|
+
print: bool = False,
|
|
1020
|
+
print_stdout: bool = False,
|
|
1021
|
+
print_stderr: bool = False,
|
|
1022
|
+
return_: bool = False,
|
|
1023
|
+
return_stdout: bool = False,
|
|
1024
|
+
return_stderr: bool = False,
|
|
1025
|
+
retry: Retry | None = None,
|
|
1026
|
+
logger: LoggerLike | None = None,
|
|
1027
|
+
) -> str | None: ...
|
|
1028
|
+
def ssh(
|
|
1029
|
+
user: str,
|
|
1030
|
+
hostname: str,
|
|
1031
|
+
/,
|
|
1032
|
+
*cmd_and_args: str,
|
|
1033
|
+
batch_mode: bool = True,
|
|
1034
|
+
host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
|
|
1035
|
+
strict_host_key_checking: bool = True,
|
|
1036
|
+
port: int | None = None,
|
|
1037
|
+
env: StrStrMapping | None = None,
|
|
1038
|
+
input: str | None = None, # noqa: A002
|
|
1039
|
+
print: bool = False, # noqa: A002
|
|
1040
|
+
print_stdout: bool = False,
|
|
1041
|
+
print_stderr: bool = False,
|
|
1042
|
+
return_: bool = False,
|
|
1043
|
+
return_stdout: bool = False,
|
|
1044
|
+
return_stderr: bool = False,
|
|
1045
|
+
retry: Retry | None = None,
|
|
1046
|
+
logger: LoggerLike | None = None,
|
|
1047
|
+
) -> str | None:
|
|
1048
|
+
"""Execute a command on a remote machine."""
|
|
1049
|
+
run_cmd_and_args = ssh_cmd( # skipif-ci
|
|
1050
|
+
user,
|
|
1051
|
+
hostname,
|
|
1052
|
+
*cmd_and_args,
|
|
1053
|
+
batch_mode=batch_mode,
|
|
1054
|
+
host_key_algorithms=host_key_algorithms,
|
|
1055
|
+
strict_host_key_checking=strict_host_key_checking,
|
|
1056
|
+
port=port,
|
|
1057
|
+
env=env,
|
|
1058
|
+
)
|
|
1059
|
+
try: # skipif-ci
|
|
1060
|
+
return run(
|
|
1061
|
+
*run_cmd_and_args,
|
|
1062
|
+
input=input,
|
|
1063
|
+
print=print,
|
|
1064
|
+
print_stdout=print_stdout,
|
|
1065
|
+
print_stderr=print_stderr,
|
|
1066
|
+
return_=return_,
|
|
1067
|
+
return_stdout=return_stdout,
|
|
1068
|
+
return_stderr=return_stderr,
|
|
1069
|
+
retry=retry,
|
|
1070
|
+
retry_skip=_ssh_retry_skip,
|
|
1071
|
+
logger=logger,
|
|
1072
|
+
)
|
|
1073
|
+
except CalledProcessError as error: # skipif-ci
|
|
1074
|
+
if not _ssh_is_strict_checking_error(error.stderr):
|
|
1075
|
+
raise
|
|
1076
|
+
ssh_keyscan(hostname, port=port)
|
|
1077
|
+
return ssh(
|
|
1078
|
+
user,
|
|
1079
|
+
hostname,
|
|
1080
|
+
*cmd_and_args,
|
|
1081
|
+
batch_mode=batch_mode,
|
|
1082
|
+
host_key_algorithms=host_key_algorithms,
|
|
1083
|
+
strict_host_key_checking=strict_host_key_checking,
|
|
1084
|
+
port=port,
|
|
1085
|
+
input=input,
|
|
1086
|
+
print=print,
|
|
1087
|
+
print_stdout=print_stdout,
|
|
1088
|
+
print_stderr=print_stderr,
|
|
1089
|
+
return_=return_,
|
|
1090
|
+
return_stdout=return_stdout,
|
|
1091
|
+
return_stderr=return_stderr,
|
|
1092
|
+
retry=retry,
|
|
1093
|
+
logger=logger,
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _ssh_retry_skip(return_code: int, stdout: str, stderr: str, /) -> bool:
|
|
1098
|
+
_ = (return_code, stdout)
|
|
1099
|
+
return _ssh_is_strict_checking_error(stderr)
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _ssh_is_strict_checking_error(text: str, /) -> bool:
|
|
1103
|
+
match = search(
|
|
1104
|
+
"No ED25519 host key is known for .* and you have requested strict checking",
|
|
1105
|
+
text,
|
|
1106
|
+
)
|
|
1107
|
+
return match is not None
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def ssh_cmd(
|
|
1111
|
+
user: str,
|
|
1112
|
+
hostname: str,
|
|
1113
|
+
/,
|
|
1114
|
+
*cmd_and_args: str,
|
|
1115
|
+
batch_mode: bool = True,
|
|
1116
|
+
host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
|
|
1117
|
+
strict_host_key_checking: bool = True,
|
|
1118
|
+
port: int | None = None,
|
|
1119
|
+
env: StrStrMapping | None = None,
|
|
1120
|
+
) -> list[str]:
|
|
1121
|
+
"""Command to use 'ssh' to execute a command on a remote machine."""
|
|
1122
|
+
args: list[str] = ssh_opts_cmd(
|
|
1123
|
+
batch_mode=batch_mode,
|
|
1124
|
+
host_key_algorithms=host_key_algorithms,
|
|
1125
|
+
strict_host_key_checking=strict_host_key_checking,
|
|
1126
|
+
port=port,
|
|
1127
|
+
)
|
|
1128
|
+
args.append(f"{user}@{hostname}")
|
|
1129
|
+
if env is not None:
|
|
1130
|
+
args.extend(env_cmds(env))
|
|
1131
|
+
return [*args, *cmd_and_args]
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def ssh_opts_cmd(
|
|
1135
|
+
*,
|
|
1136
|
+
batch_mode: bool = True,
|
|
1137
|
+
host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
|
|
1138
|
+
strict_host_key_checking: bool = True,
|
|
1139
|
+
port: int | None = None,
|
|
1140
|
+
) -> list[str]:
|
|
1141
|
+
"""Command to use prepare 'ssh' to execute a command on a remote machine."""
|
|
1142
|
+
args: list[str] = ["ssh"]
|
|
1143
|
+
if batch_mode:
|
|
1144
|
+
args.extend(["-o", "BatchMode=yes"])
|
|
1145
|
+
args.extend(["-o", f"HostKeyAlgorithms={','.join(host_key_algorithms)}"])
|
|
1146
|
+
if strict_host_key_checking:
|
|
1147
|
+
args.extend(["-o", "StrictHostKeyChecking=yes"])
|
|
1148
|
+
if port is not None:
|
|
1149
|
+
args.extend(["-p", str(port)])
|
|
1150
|
+
return [*args, "-T"]
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
##
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def ssh_await(
|
|
1157
|
+
user: str,
|
|
1158
|
+
hostname: str,
|
|
1159
|
+
/,
|
|
1160
|
+
*,
|
|
1161
|
+
logger: LoggerLike | None = None,
|
|
1162
|
+
delta: Delta = SECOND,
|
|
1163
|
+
) -> None:
|
|
1164
|
+
while True: # skipif-ci
|
|
1165
|
+
if logger is not None:
|
|
1166
|
+
to_logger(logger).info("Waiting for '%s'...", hostname)
|
|
1167
|
+
try:
|
|
1168
|
+
ssh(user, hostname, "true")
|
|
1169
|
+
except CalledProcessError:
|
|
1170
|
+
sleep(to_seconds(delta))
|
|
1171
|
+
else:
|
|
1172
|
+
if logger is not None:
|
|
1173
|
+
to_logger(logger).info("'%s' is up", hostname)
|
|
1174
|
+
return
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
##
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def ssh_keyscan(
|
|
1181
|
+
hostname: str, /, *, path: PathLike = KNOWN_HOSTS, port: int | None = None
|
|
1182
|
+
) -> None:
|
|
1183
|
+
"""Add a known host."""
|
|
1184
|
+
ssh_keygen_remove(hostname, path=path) # skipif-ci
|
|
1185
|
+
mkdir(path, parent=True) # skipif-ci
|
|
1186
|
+
with Path(path).open(mode="a") as fh: # skipif-ci
|
|
1187
|
+
_ = fh.write(run(*ssh_keyscan_cmd(hostname, port=port), return_=True))
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def ssh_keyscan_cmd(hostname: str, /, *, port: int | None = None) -> list[str]:
|
|
1191
|
+
"""Command to use 'ssh-keyscan' to add a known host."""
|
|
1192
|
+
args: list[str] = ["ssh-keyscan"]
|
|
1193
|
+
if port is not None:
|
|
1194
|
+
args.extend(["-p", str(port)])
|
|
1195
|
+
return [*args, "-q", "-t", "ed25519", hostname]
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
##
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def ssh_keygen_remove(hostname: str, /, *, path: PathLike = KNOWN_HOSTS) -> None:
|
|
1202
|
+
"""Remove a known host."""
|
|
1203
|
+
path = Path(path)
|
|
1204
|
+
if path.exists():
|
|
1205
|
+
run(*ssh_keygen_remove_cmd(hostname, path=path))
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def ssh_keygen_remove_cmd(
|
|
1209
|
+
hostname: str, /, *, path: PathLike = KNOWN_HOSTS
|
|
1210
|
+
) -> list[str]:
|
|
1211
|
+
"""Command to use 'ssh-keygen' to remove a known host."""
|
|
1212
|
+
return ["ssh-keygen", "-f", str(path), "-R", hostname]
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
##
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def sudo_cmd(cmd: str, /, *args: str) -> list[str]:
|
|
1219
|
+
"""Command to use 'sudo' to execute a command as another user."""
|
|
1220
|
+
return ["sudo", cmd, *args]
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def maybe_sudo_cmd(cmd: str, /, *args: str, sudo: bool = False) -> list[str]:
|
|
1224
|
+
"""Command to use 'sudo' to execute a command as another user, if required."""
|
|
1225
|
+
parts: list[str] = [cmd, *args]
|
|
1226
|
+
return sudo_cmd(*parts) if sudo else parts
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
##
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def sudo_nopasswd_cmd(user: str, /) -> str:
|
|
1233
|
+
"""Command to allow a user to use password-free `sudo`."""
|
|
1234
|
+
return f"{user} ALL=(ALL) NOPASSWD: ALL"
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
##
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def symlink(target: PathLike, link: PathLike, /, *, sudo: bool = False) -> None:
|
|
1241
|
+
"""Make a symbolic link."""
|
|
1242
|
+
rm(link, sudo=sudo)
|
|
1243
|
+
mkdir(link, sudo=sudo, parent=True)
|
|
1244
|
+
if sudo: # pragma: no cover
|
|
1245
|
+
run(*sudo_cmd(*symlink_cmd(target, link)))
|
|
1246
|
+
else:
|
|
1247
|
+
target, link = map(Path, [target, link])
|
|
1248
|
+
link.symlink_to(target)
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def symlink_cmd(target: PathLike, link: PathLike, /) -> list[str]:
|
|
1252
|
+
"""Command to use 'symlink' to make a symbolic link."""
|
|
1253
|
+
return ["ln", "-s", str(target), str(link)]
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
##
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def tee(
|
|
1260
|
+
path: PathLike, text: str, /, *, sudo: bool = False, append: bool = False
|
|
1261
|
+
) -> None:
|
|
1262
|
+
"""Use 'tee' to duplicate standard input."""
|
|
1263
|
+
if sudo: # pragma: no cover
|
|
1264
|
+
run(*sudo_cmd(*tee_cmd(path, append=append)), input=text)
|
|
1265
|
+
else:
|
|
1266
|
+
path = Path(path)
|
|
1267
|
+
with path.open(mode="a" if append else "w") as fh:
|
|
1268
|
+
_ = fh.write(text)
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def tee_cmd(path: PathLike, /, *, append: bool = False) -> list[str]:
|
|
1272
|
+
"""Command to use 'tee' to duplicate standard input."""
|
|
1273
|
+
args: list[str] = ["tee"]
|
|
1274
|
+
if append:
|
|
1275
|
+
args.append("-a")
|
|
1276
|
+
return [*args, str(path)]
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
##
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def touch(path: PathLike, /, *, sudo: bool = False) -> None:
|
|
1283
|
+
"""Change file access and modification times."""
|
|
1284
|
+
run(*maybe_sudo_cmd(*touch_cmd(path), sudo=sudo))
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def touch_cmd(path: PathLike, /) -> list[str]:
|
|
1288
|
+
"""Command to use 'touch' to change file access and modification times."""
|
|
1289
|
+
return ["touch", str(path)]
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
##
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def update_ca_certificates(*, sudo: bool = False) -> None:
|
|
1296
|
+
"""Update the system CA certificates."""
|
|
1297
|
+
run(*maybe_sudo_cmd(UPDATE_CA_CERTIFICATES, sudo=sudo)) # pragma: no cover
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
##
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def useradd(
|
|
1304
|
+
login: str,
|
|
1305
|
+
/,
|
|
1306
|
+
*,
|
|
1307
|
+
create_home: bool = True,
|
|
1308
|
+
groups: MaybeIterable[str] | None = None,
|
|
1309
|
+
shell: PathLike | None = None,
|
|
1310
|
+
sudo: bool = False,
|
|
1311
|
+
password: str | None = None,
|
|
1312
|
+
) -> None:
|
|
1313
|
+
"""Create a new user."""
|
|
1314
|
+
args = maybe_sudo_cmd( # pragma: no cover
|
|
1315
|
+
*useradd_cmd(login, create_home=create_home, groups=groups, shell=shell)
|
|
1316
|
+
)
|
|
1317
|
+
run(*args) # pragma: no cover
|
|
1318
|
+
if password is not None: # pragma: no cover
|
|
1319
|
+
chpasswd(login, password, sudo=sudo)
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def useradd_cmd(
|
|
1323
|
+
login: str,
|
|
1324
|
+
/,
|
|
1325
|
+
*,
|
|
1326
|
+
create_home: bool = True,
|
|
1327
|
+
groups: MaybeIterable[str] | None = None,
|
|
1328
|
+
shell: PathLike | None = None,
|
|
1329
|
+
) -> list[str]:
|
|
1330
|
+
"""Command to use 'useradd' to create a new user."""
|
|
1331
|
+
args: list[str] = ["useradd"]
|
|
1332
|
+
if create_home:
|
|
1333
|
+
args.append("--create-home")
|
|
1334
|
+
if groups is not None:
|
|
1335
|
+
args.extend(["--groups", *always_iterable(groups)])
|
|
1336
|
+
if shell is not None:
|
|
1337
|
+
args.extend(["--shell", str(shell)])
|
|
1338
|
+
return [*args, login]
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
##
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
@overload
|
|
1345
|
+
def uv_run(
|
|
1346
|
+
module: str,
|
|
1347
|
+
/,
|
|
1348
|
+
*args: str,
|
|
1349
|
+
cwd: PathLike | None = None,
|
|
1350
|
+
print: bool = False,
|
|
1351
|
+
print_stdout: bool = False,
|
|
1352
|
+
print_stderr: bool = False,
|
|
1353
|
+
return_: Literal[True],
|
|
1354
|
+
return_stdout: bool = False,
|
|
1355
|
+
return_stderr: bool = False,
|
|
1356
|
+
retry: Retry | None = None,
|
|
1357
|
+
logger: LoggerLike | None = None,
|
|
1358
|
+
) -> str: ...
|
|
1359
|
+
@overload
|
|
1360
|
+
def uv_run(
|
|
1361
|
+
module: str,
|
|
1362
|
+
/,
|
|
1363
|
+
*args: str,
|
|
1364
|
+
cwd: PathLike | None = None,
|
|
1365
|
+
print: bool = False,
|
|
1366
|
+
print_stdout: bool = False,
|
|
1367
|
+
print_stderr: bool = False,
|
|
1368
|
+
return_: bool = False,
|
|
1369
|
+
return_stdout: Literal[True],
|
|
1370
|
+
return_stderr: bool = False,
|
|
1371
|
+
retry: Retry | None = None,
|
|
1372
|
+
logger: LoggerLike | None = None,
|
|
1373
|
+
) -> str: ...
|
|
1374
|
+
@overload
|
|
1375
|
+
def uv_run(
|
|
1376
|
+
module: str,
|
|
1377
|
+
/,
|
|
1378
|
+
*args: str,
|
|
1379
|
+
cwd: PathLike | None = None,
|
|
1380
|
+
print: bool = False,
|
|
1381
|
+
print_stdout: bool = False,
|
|
1382
|
+
print_stderr: bool = False,
|
|
1383
|
+
return_: bool = False,
|
|
1384
|
+
return_stdout: bool = False,
|
|
1385
|
+
return_stderr: Literal[True],
|
|
1386
|
+
retry: Retry | None = None,
|
|
1387
|
+
logger: LoggerLike | None = None,
|
|
1388
|
+
) -> str: ...
|
|
1389
|
+
@overload
|
|
1390
|
+
def uv_run(
|
|
1391
|
+
module: str,
|
|
1392
|
+
/,
|
|
1393
|
+
*args: str,
|
|
1394
|
+
cwd: PathLike | None = None,
|
|
1395
|
+
print: bool = False,
|
|
1396
|
+
print_stdout: bool = False,
|
|
1397
|
+
print_stderr: bool = False,
|
|
1398
|
+
return_: Literal[False] = False,
|
|
1399
|
+
return_stdout: Literal[False] = False,
|
|
1400
|
+
return_stderr: Literal[False] = False,
|
|
1401
|
+
retry: Retry | None = None,
|
|
1402
|
+
logger: LoggerLike | None = None,
|
|
1403
|
+
) -> None: ...
|
|
1404
|
+
@overload
|
|
1405
|
+
def uv_run(
|
|
1406
|
+
module: str,
|
|
1407
|
+
/,
|
|
1408
|
+
*args: str,
|
|
1409
|
+
cwd: PathLike | None = None,
|
|
1410
|
+
print: bool = False,
|
|
1411
|
+
print_stdout: bool = False,
|
|
1412
|
+
print_stderr: bool = False,
|
|
1413
|
+
return_: bool = False,
|
|
1414
|
+
return_stdout: bool = False,
|
|
1415
|
+
return_stderr: bool = False,
|
|
1416
|
+
retry: Retry | None = None,
|
|
1417
|
+
logger: LoggerLike | None = None,
|
|
1418
|
+
) -> str | None: ...
|
|
1419
|
+
def uv_run(
|
|
1420
|
+
module: str,
|
|
1421
|
+
/,
|
|
1422
|
+
*args: str,
|
|
1423
|
+
cwd: PathLike | None = None,
|
|
1424
|
+
print: bool = False, # noqa: A002
|
|
1425
|
+
print_stdout: bool = False,
|
|
1426
|
+
print_stderr: bool = False,
|
|
1427
|
+
return_: bool = False,
|
|
1428
|
+
return_stdout: bool = False,
|
|
1429
|
+
return_stderr: bool = False,
|
|
1430
|
+
retry: Retry | None = None,
|
|
1431
|
+
logger: LoggerLike | None = None,
|
|
1432
|
+
) -> str | None:
|
|
1433
|
+
"""Run a command or script."""
|
|
1434
|
+
return run( # pragma: no cover
|
|
1435
|
+
*uv_run_cmd(module, *args),
|
|
1436
|
+
cwd=cwd,
|
|
1437
|
+
print=print,
|
|
1438
|
+
print_stdout=print_stdout,
|
|
1439
|
+
print_stderr=print_stderr,
|
|
1440
|
+
return_=return_,
|
|
1441
|
+
return_stdout=return_stdout,
|
|
1442
|
+
return_stderr=return_stderr,
|
|
1443
|
+
retry=retry,
|
|
1444
|
+
logger=logger,
|
|
1445
|
+
)
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def uv_run_cmd(module: str, /, *args: str) -> list[str]:
|
|
1449
|
+
"""Command to use 'uv' to run a command or script."""
|
|
1450
|
+
return [
|
|
1451
|
+
"uv",
|
|
1452
|
+
"run",
|
|
1453
|
+
"--no-dev",
|
|
1454
|
+
"--active",
|
|
1455
|
+
"--prerelease=disallow",
|
|
1456
|
+
"--managed-python",
|
|
1457
|
+
"python",
|
|
1458
|
+
"-m",
|
|
1459
|
+
module,
|
|
1460
|
+
*args,
|
|
1461
|
+
]
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
##
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
@contextmanager
|
|
1468
|
+
def yield_git_repo(url: str, /, *, branch: str | None = None) -> Iterator[Path]:
|
|
1469
|
+
"""Yield a temporary git repository."""
|
|
1470
|
+
with TemporaryDirectory() as temp_dir:
|
|
1471
|
+
git_clone(url, temp_dir, branch=branch)
|
|
1472
|
+
yield temp_dir
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
##
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
@contextmanager
|
|
1479
|
+
def yield_ssh_temp_dir(
|
|
1480
|
+
user: str,
|
|
1481
|
+
hostname: str,
|
|
1482
|
+
/,
|
|
1483
|
+
*,
|
|
1484
|
+
retry: Retry | None = None,
|
|
1485
|
+
logger: LoggerLike | None = None,
|
|
1486
|
+
keep: bool = False,
|
|
1487
|
+
) -> Iterator[Path]:
|
|
1488
|
+
"""Yield a temporary directory on a remote machine."""
|
|
1489
|
+
path = Path( # skipif-ci
|
|
1490
|
+
ssh(user, hostname, *MKTEMP_DIR_CMD, return_=True, retry=retry, logger=logger)
|
|
1491
|
+
)
|
|
1492
|
+
try: # skipif-ci
|
|
1493
|
+
yield path
|
|
1494
|
+
finally: # skipif-ci
|
|
1495
|
+
if keep:
|
|
1496
|
+
if logger is not None:
|
|
1497
|
+
to_logger(logger).info("Keeping temporary directory '%s'...", path)
|
|
1498
|
+
else:
|
|
1499
|
+
ssh(user, hostname, *rm_cmd(path), retry=retry, logger=logger)
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
__all__ = [
|
|
1503
|
+
"APT_UPDATE",
|
|
1504
|
+
"BASH_LC",
|
|
1505
|
+
"BASH_LS",
|
|
1506
|
+
"CHPASSWD",
|
|
1507
|
+
"GIT_BRANCH_SHOW_CURRENT",
|
|
1508
|
+
"MKTEMP_DIR_CMD",
|
|
1509
|
+
"RESTART_SSHD",
|
|
1510
|
+
"UPDATE_CA_CERTIFICATES",
|
|
1511
|
+
"ChownCmdError",
|
|
1512
|
+
"CpError",
|
|
1513
|
+
"MvFileError",
|
|
1514
|
+
"RsyncCmdError",
|
|
1515
|
+
"RsyncCmdNoSourcesError",
|
|
1516
|
+
"RsyncCmdSourcesNotFoundError",
|
|
1517
|
+
"apt_install",
|
|
1518
|
+
"apt_install_cmd",
|
|
1519
|
+
"cd_cmd",
|
|
1520
|
+
"chmod",
|
|
1521
|
+
"chmod_cmd",
|
|
1522
|
+
"chown",
|
|
1523
|
+
"chown_cmd",
|
|
1524
|
+
"chpasswd",
|
|
1525
|
+
"cp",
|
|
1526
|
+
"cp_cmd",
|
|
1527
|
+
"echo_cmd",
|
|
1528
|
+
"env_cmds",
|
|
1529
|
+
"expand_path",
|
|
1530
|
+
"git_branch_current",
|
|
1531
|
+
"git_checkout",
|
|
1532
|
+
"git_checkout_cmd",
|
|
1533
|
+
"git_clone",
|
|
1534
|
+
"git_clone_cmd",
|
|
1535
|
+
"maybe_parent",
|
|
1536
|
+
"maybe_sudo_cmd",
|
|
1537
|
+
"mkdir",
|
|
1538
|
+
"mkdir_cmd",
|
|
1539
|
+
"mv",
|
|
1540
|
+
"mv_cmd",
|
|
1541
|
+
"ripgrep",
|
|
1542
|
+
"ripgrep_cmd",
|
|
1543
|
+
"rm",
|
|
1544
|
+
"rm_cmd",
|
|
1545
|
+
"rsync",
|
|
1546
|
+
"rsync_cmd",
|
|
1547
|
+
"rsync_many",
|
|
1548
|
+
"run",
|
|
1549
|
+
"set_hostname_cmd",
|
|
1550
|
+
"ssh",
|
|
1551
|
+
"ssh_await",
|
|
1552
|
+
"ssh_cmd",
|
|
1553
|
+
"ssh_keygen_remove",
|
|
1554
|
+
"ssh_keygen_remove_cmd",
|
|
1555
|
+
"ssh_keyscan",
|
|
1556
|
+
"ssh_keyscan_cmd",
|
|
1557
|
+
"ssh_opts_cmd",
|
|
1558
|
+
"sudo_cmd",
|
|
1559
|
+
"sudo_nopasswd_cmd",
|
|
1560
|
+
"symlink",
|
|
1561
|
+
"symlink_cmd",
|
|
1562
|
+
"tee_cmd",
|
|
1563
|
+
"touch",
|
|
1564
|
+
"touch_cmd",
|
|
1565
|
+
"update_ca_certificates",
|
|
1566
|
+
"useradd",
|
|
1567
|
+
"useradd_cmd",
|
|
1568
|
+
"uv_run",
|
|
1569
|
+
"uv_run_cmd",
|
|
1570
|
+
"yield_git_repo",
|
|
1571
|
+
"yield_ssh_temp_dir",
|
|
1572
|
+
]
|