dycw-utilities 0.146.2__py3-none-any.whl → 0.178.1__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.

Potentially problematic release.


This version of dycw-utilities might be problematic. Click here for more details.

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