dycw-utilities 0.166.30__py3-none-any.whl → 0.185.8__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.
Files changed (96) hide show
  1. dycw_utilities-0.185.8.dist-info/METADATA +33 -0
  2. dycw_utilities-0.185.8.dist-info/RECORD +90 -0
  3. {dycw_utilities-0.166.30.dist-info → dycw_utilities-0.185.8.dist-info}/WHEEL +1 -1
  4. {dycw_utilities-0.166.30.dist-info → dycw_utilities-0.185.8.dist-info}/entry_points.txt +1 -0
  5. utilities/__init__.py +1 -1
  6. utilities/altair.py +17 -10
  7. utilities/asyncio.py +50 -72
  8. utilities/atools.py +9 -11
  9. utilities/cachetools.py +16 -11
  10. utilities/click.py +76 -19
  11. utilities/concurrent.py +1 -1
  12. utilities/constants.py +492 -0
  13. utilities/contextlib.py +23 -30
  14. utilities/contextvars.py +1 -23
  15. utilities/core.py +2581 -0
  16. utilities/dataclasses.py +16 -119
  17. utilities/docker.py +387 -0
  18. utilities/enum.py +1 -1
  19. utilities/errors.py +2 -16
  20. utilities/fastapi.py +5 -5
  21. utilities/fpdf2.py +2 -1
  22. utilities/functions.py +34 -265
  23. utilities/http.py +2 -3
  24. utilities/hypothesis.py +84 -29
  25. utilities/importlib.py +17 -1
  26. utilities/iterables.py +39 -575
  27. utilities/jinja2.py +145 -0
  28. utilities/jupyter.py +5 -3
  29. utilities/libcst.py +1 -1
  30. utilities/lightweight_charts.py +4 -6
  31. utilities/logging.py +24 -24
  32. utilities/math.py +1 -36
  33. utilities/more_itertools.py +4 -6
  34. utilities/numpy.py +2 -1
  35. utilities/operator.py +2 -2
  36. utilities/orjson.py +42 -43
  37. utilities/os.py +4 -147
  38. utilities/packaging.py +129 -0
  39. utilities/parse.py +35 -15
  40. utilities/pathlib.py +3 -120
  41. utilities/platform.py +8 -90
  42. utilities/polars.py +38 -32
  43. utilities/postgres.py +37 -33
  44. utilities/pottery.py +20 -18
  45. utilities/pqdm.py +3 -4
  46. utilities/psutil.py +2 -3
  47. utilities/pydantic.py +25 -0
  48. utilities/pydantic_settings.py +87 -16
  49. utilities/pydantic_settings_sops.py +16 -3
  50. utilities/pyinstrument.py +4 -4
  51. utilities/pytest.py +96 -125
  52. utilities/pytest_plugins/pytest_regressions.py +2 -2
  53. utilities/pytest_regressions.py +32 -11
  54. utilities/random.py +2 -8
  55. utilities/redis.py +98 -94
  56. utilities/reprlib.py +11 -118
  57. utilities/shellingham.py +66 -0
  58. utilities/shutil.py +25 -0
  59. utilities/slack_sdk.py +13 -12
  60. utilities/sqlalchemy.py +57 -30
  61. utilities/sqlalchemy_polars.py +16 -25
  62. utilities/subprocess.py +2590 -0
  63. utilities/tabulate.py +32 -0
  64. utilities/testbook.py +8 -8
  65. utilities/text.py +24 -99
  66. utilities/throttle.py +159 -0
  67. utilities/time.py +18 -0
  68. utilities/timer.py +31 -14
  69. utilities/traceback.py +16 -23
  70. utilities/types.py +42 -2
  71. utilities/typing.py +26 -14
  72. utilities/uuid.py +1 -1
  73. utilities/version.py +202 -45
  74. utilities/whenever.py +53 -150
  75. dycw_utilities-0.166.30.dist-info/METADATA +0 -41
  76. dycw_utilities-0.166.30.dist-info/RECORD +0 -98
  77. dycw_utilities-0.166.30.dist-info/licenses/LICENSE +0 -21
  78. utilities/aeventkit.py +0 -388
  79. utilities/atomicwrites.py +0 -182
  80. utilities/cryptography.py +0 -41
  81. utilities/getpass.py +0 -8
  82. utilities/git.py +0 -19
  83. utilities/gzip.py +0 -31
  84. utilities/json.py +0 -70
  85. utilities/pickle.py +0 -25
  86. utilities/re.py +0 -156
  87. utilities/sentinel.py +0 -73
  88. utilities/socket.py +0 -8
  89. utilities/string.py +0 -20
  90. utilities/tempfile.py +0 -77
  91. utilities/typed_settings.py +0 -152
  92. utilities/tzdata.py +0 -11
  93. utilities/tzlocal.py +0 -28
  94. utilities/warnings.py +0 -65
  95. utilities/zipfile.py +0 -25
  96. utilities/zoneinfo.py +0 -133
@@ -0,0 +1,2590 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from io import StringIO
7
+ from itertools import chain, repeat
8
+ from json import JSONDecodeError
9
+ from pathlib import Path
10
+ from re import MULTILINE, escape, search
11
+ from shlex import join
12
+ from shutil import rmtree
13
+ from string import Template
14
+ from subprocess import PIPE, CalledProcessError, Popen
15
+ from threading import Thread
16
+ from typing import IO, TYPE_CHECKING, Literal, assert_never, overload, override
17
+
18
+ import utilities.core
19
+ from utilities.constants import HOME, PWD, SECOND
20
+ from utilities.contextlib import enhanced_context_manager
21
+ from utilities.core import (
22
+ OneEmptyError,
23
+ Permissions,
24
+ TemporaryDirectory,
25
+ _CopyOrMoveSourceNotFoundError,
26
+ always_iterable,
27
+ copy,
28
+ file_or_dir,
29
+ move,
30
+ normalize_multi_line_str,
31
+ one,
32
+ repr_str,
33
+ )
34
+ from utilities.errors import ImpossibleCaseError
35
+ from utilities.functions import in_timedelta
36
+ from utilities.logging import to_logger
37
+ from utilities.time import sleep
38
+ from utilities.version import (
39
+ ParseVersion2Or3Error,
40
+ Version2,
41
+ Version3,
42
+ parse_version_2_or_3,
43
+ )
44
+
45
+ if TYPE_CHECKING:
46
+ from collections.abc import Callable, Iterable, Iterator
47
+
48
+ from utilities.core import PermissionsLike
49
+ from utilities.types import (
50
+ Duration,
51
+ LoggerLike,
52
+ MaybeIterable,
53
+ MaybeSequenceStr,
54
+ PathLike,
55
+ Retry,
56
+ StrMapping,
57
+ StrStrMapping,
58
+ )
59
+
60
+
61
+ _HOST_KEY_ALGORITHMS = ["ssh-ed25519"]
62
+ APT_UPDATE = ["apt", "update", "-y"]
63
+ BASH_LC = ["bash", "-lc"]
64
+ BASH_LS = ["bash", "-ls"]
65
+ CHPASSWD = "chpasswd"
66
+ GIT_BRANCH_SHOW_CURRENT = ["git", "branch", "--show-current"]
67
+ ISOLATED = "--isolated"
68
+ KNOWN_HOSTS = HOME / ".ssh/known_hosts"
69
+ MANAGED_PYTHON = "--managed-python"
70
+ MKTEMP_DIR_CMD = ["mktemp", "-d"]
71
+ PRERELEASE_DISALLOW = ["--prerelease", "disallow"]
72
+ RESOLUTION_HIGHEST = ["--resolution", "highest"]
73
+ RESTART_SSHD = ["systemctl", "restart", "sshd"]
74
+ UPDATE_CA_CERTIFICATES: str = "update-ca-certificates"
75
+
76
+
77
+ ##
78
+
79
+
80
+ def append_text(
81
+ path: PathLike,
82
+ text: str,
83
+ /,
84
+ *,
85
+ sudo: bool = False,
86
+ skip_if_present: bool = False,
87
+ flags: int = 0,
88
+ blank_lines: int = 1,
89
+ ) -> None:
90
+ """Append text to a file."""
91
+ try:
92
+ existing = cat(path, sudo=sudo)
93
+ except (CalledProcessError, FileNotFoundError):
94
+ tee(path, text, sudo=sudo)
95
+ return
96
+ if existing == "":
97
+ tee(path, text, sudo=sudo)
98
+ return
99
+ if skip_if_present and (search(escape(text), existing, flags=flags) is not None):
100
+ return
101
+ full = "".join([*repeat("\n", times=blank_lines), text])
102
+ tee(path, full, sudo=sudo, append=True)
103
+
104
+
105
+ ##
106
+
107
+
108
+ def apt_install(
109
+ package: str, /, *packages: str, update: bool = False, sudo: bool = False
110
+ ) -> None:
111
+ """Install packages."""
112
+ if update: # pragma: no cover
113
+ apt_update(sudo=sudo)
114
+ args = maybe_sudo_cmd( # pragma: no cover
115
+ *apt_install_cmd(package, *packages), sudo=sudo
116
+ )
117
+ run(*args) # pragma: no cover
118
+
119
+
120
+ def apt_install_cmd(package: str, /, *packages: str) -> list[str]:
121
+ """Command to use 'apt' to install packages."""
122
+ return ["apt", "install", "-y", package, *packages]
123
+
124
+
125
+ ##
126
+
127
+
128
+ def apt_remove(package: str, /, *packages: str, sudo: bool = False) -> None:
129
+ """Remove a package."""
130
+ args = maybe_sudo_cmd( # pragma: no cover
131
+ *apt_remove_cmd(package, *packages), sudo=sudo
132
+ )
133
+ run(*args) # pragma: no cover
134
+
135
+
136
+ def apt_remove_cmd(package: str, /, *packages: str) -> list[str]:
137
+ """Command to use 'apt' to remove packages."""
138
+ return ["apt", "remove", "-y", package, *packages]
139
+
140
+
141
+ ##
142
+
143
+
144
+ def apt_update(*, sudo: bool = False) -> None:
145
+ """Update 'apt'."""
146
+ run(*maybe_sudo_cmd(*APT_UPDATE, sudo=sudo)) # pragma: no cover
147
+
148
+
149
+ ##
150
+
151
+
152
+ def cat(path: PathLike, /, *paths: PathLike, sudo: bool = False) -> str:
153
+ """Concatenate and print files."""
154
+ if sudo: # pragma: no cover
155
+ return run(*sudo_cmd(*cat_cmd(path, *paths)), return_=True)
156
+ all_paths = list(map(Path, [path, *paths]))
157
+ return "\n".join(p.read_text() for p in all_paths)
158
+
159
+
160
+ def cat_cmd(path: PathLike, /, *paths: PathLike) -> list[str]:
161
+ """Command to use 'cat' to concatenate and print files."""
162
+ return ["cat", str(path), *map(str, paths)]
163
+
164
+
165
+ ##
166
+
167
+
168
+ def cd_cmd(path: PathLike, /) -> list[str]:
169
+ """Command to use 'cd' to change working directory."""
170
+ return ["cd", str(path)]
171
+
172
+
173
+ ##
174
+
175
+
176
+ def chattr(
177
+ path: PathLike, /, *, immutable: bool | None = None, sudo: bool = False
178
+ ) -> None:
179
+ """Change file attributes."""
180
+ args = maybe_sudo_cmd( # pragma: no cover
181
+ *chattr_cmd(path, immutable=immutable), sudo=sudo
182
+ )
183
+ run(*args) # pragma: no cover
184
+
185
+
186
+ def chattr_cmd(path: PathLike, /, *, immutable: bool | None = None) -> list[str]:
187
+ """Command to use 'chattr' to change file attributes."""
188
+ args: list[str] = ["chattr"]
189
+ if immutable is True:
190
+ args.append("+i")
191
+ elif immutable is False:
192
+ args.append("-i")
193
+ return [*args, str(path)]
194
+
195
+
196
+ ##
197
+
198
+
199
+ def chmod(path: PathLike, perms: PermissionsLike, /, *, sudo: bool = False) -> None:
200
+ """Change file mode."""
201
+ if sudo: # pragma: no cover
202
+ run(*sudo_cmd(*chmod_cmd(path, perms)))
203
+ else: # pragma: no cover
204
+ utilities.core.chmod(path, perms)
205
+
206
+
207
+ def chmod_cmd(path: PathLike, perms: PermissionsLike, /) -> list[str]:
208
+ """Command to use 'chmod' to change file mode."""
209
+ return ["chmod", str(Permissions.new(perms)), str(path)]
210
+
211
+
212
+ ##
213
+
214
+
215
+ def chown(
216
+ path: PathLike,
217
+ /,
218
+ *,
219
+ sudo: bool = False,
220
+ recursive: bool = False,
221
+ user: str | int | None = None,
222
+ group: str | int | None = None,
223
+ ) -> None:
224
+ """Change file owner and/or group."""
225
+ if sudo: # pragma: no cover
226
+ if (user is not None) or (group is not None):
227
+ args = sudo_cmd(
228
+ *chown_cmd(path, recursive=recursive, user=user, group=group)
229
+ )
230
+ run(*args)
231
+ else: # pragma: no cover
232
+ utilities.core.chown(path, recursive=recursive, user=user, group=group)
233
+
234
+
235
+ def chown_cmd(
236
+ path: PathLike,
237
+ /,
238
+ *,
239
+ recursive: bool = False,
240
+ user: str | int | None = None,
241
+ group: str | int | None = None,
242
+ ) -> list[str]:
243
+ """Command to use 'chown' to change file owner and/or group."""
244
+ args: list[str] = ["chown"]
245
+ if recursive:
246
+ args.append("-R")
247
+ match user, group:
248
+ case None, None:
249
+ raise ChownCmdError
250
+ case str() | int(), None:
251
+ ownership = "user"
252
+ case None, str() | int():
253
+ ownership = f":{group}"
254
+ case str() | int(), str() | int():
255
+ ownership = f"{user}:{group}"
256
+ case never:
257
+ assert_never(never)
258
+ return [*args, ownership, str(path)]
259
+
260
+
261
+ @dataclass(kw_only=True, slots=True)
262
+ class ChownCmdError(Exception):
263
+ @override
264
+ def __str__(self) -> str:
265
+ return "At least one of 'user' and/or 'group' must be given; got None"
266
+
267
+
268
+ ##
269
+
270
+
271
+ def chpasswd(user_name: str, password: str, /, *, sudo: bool = False) -> None:
272
+ """Update passwords."""
273
+ args = maybe_sudo_cmd(CHPASSWD, sudo=sudo) # pragma: no cover
274
+ run(*args, input=f"{user_name}:{password}") # pragma: no cover
275
+
276
+
277
+ ##
278
+
279
+
280
+ def copy_text(
281
+ src: PathLike,
282
+ dest: PathLike,
283
+ /,
284
+ *,
285
+ sudo: bool = False,
286
+ substitutions: StrMapping | None = None,
287
+ ) -> None:
288
+ """Copy the text contents of a file."""
289
+ text = cat(src, sudo=sudo)
290
+ if substitutions is not None:
291
+ text = Template(text).substitute(**substitutions)
292
+ tee(dest, text, sudo=sudo)
293
+
294
+
295
+ ##
296
+
297
+
298
+ def cp(
299
+ src: PathLike,
300
+ dest: PathLike,
301
+ /,
302
+ *,
303
+ sudo: bool = False,
304
+ perms: PermissionsLike | None = None,
305
+ owner: str | int | None = None,
306
+ group: str | int | None = None,
307
+ ) -> None:
308
+ """Copy a file/directory."""
309
+ mkdir(dest, sudo=sudo, parent=True)
310
+ if sudo: # pragma: no cover
311
+ run(*sudo_cmd(*cp_cmd(src, dest)))
312
+ if perms is not None:
313
+ chmod(dest, perms, sudo=True)
314
+ if (owner is not None) or (group is not None):
315
+ chown(dest, sudo=True, user=owner, group=group)
316
+ else:
317
+ try:
318
+ copy(src, dest, overwrite=True, perms=perms, owner=owner, group=group)
319
+ except _CopyOrMoveSourceNotFoundError as error:
320
+ raise CpError(src=error.src) from None
321
+
322
+
323
+ @dataclass(kw_only=True, slots=True)
324
+ class CpError(Exception):
325
+ src: Path
326
+
327
+ @override
328
+ def __str__(self) -> str:
329
+ return f"Source {repr_str(self.src)} does not exist"
330
+
331
+
332
+ def cp_cmd(src: PathLike, dest: PathLike, /) -> list[str]:
333
+ """Command to use 'cp' to copy a file/directory."""
334
+ return ["cp", "-r", str(src), str(dest)]
335
+
336
+
337
+ ##
338
+
339
+
340
+ @overload
341
+ def curl(
342
+ url: str,
343
+ /,
344
+ *,
345
+ fail: bool = True,
346
+ location: bool = True,
347
+ output: PathLike | None = None,
348
+ show_error: bool = True,
349
+ silent: bool = True,
350
+ sudo: bool = False,
351
+ print: bool = False,
352
+ print_stdout: bool = False,
353
+ print_stderr: bool = False,
354
+ return_: Literal[True],
355
+ return_stdout: Literal[False] = False,
356
+ return_stderr: Literal[False] = False,
357
+ retry: Retry | None = None,
358
+ retry_skip: Callable[[int, str, str], bool] | None = None,
359
+ logger: LoggerLike | None = None,
360
+ ) -> str: ...
361
+ @overload
362
+ def curl(
363
+ url: str,
364
+ /,
365
+ *,
366
+ fail: bool = True,
367
+ location: bool = True,
368
+ output: PathLike | None = None,
369
+ show_error: bool = True,
370
+ silent: bool = True,
371
+ sudo: bool = False,
372
+ print: bool = False,
373
+ print_stdout: bool = False,
374
+ print_stderr: bool = False,
375
+ return_: Literal[False] = False,
376
+ return_stdout: Literal[True],
377
+ return_stderr: Literal[False] = False,
378
+ retry: Retry | None = None,
379
+ retry_skip: Callable[[int, str, str], bool] | None = None,
380
+ logger: LoggerLike | None = None,
381
+ ) -> str: ...
382
+ @overload
383
+ def curl(
384
+ url: str,
385
+ /,
386
+ *,
387
+ fail: bool = True,
388
+ location: bool = True,
389
+ output: PathLike | None = None,
390
+ show_error: bool = True,
391
+ silent: bool = True,
392
+ sudo: bool = False,
393
+ print: bool = False,
394
+ print_stdout: bool = False,
395
+ print_stderr: bool = False,
396
+ return_: Literal[False] = False,
397
+ return_stdout: Literal[False] = False,
398
+ return_stderr: Literal[True],
399
+ retry: Retry | None = None,
400
+ retry_skip: Callable[[int, str, str], bool] | None = None,
401
+ logger: LoggerLike | None = None,
402
+ ) -> str: ...
403
+ @overload
404
+ def curl(
405
+ url: str,
406
+ /,
407
+ *,
408
+ fail: bool = True,
409
+ location: bool = True,
410
+ output: PathLike | None = None,
411
+ show_error: bool = True,
412
+ silent: bool = True,
413
+ sudo: bool = False,
414
+ print: bool = False,
415
+ print_stdout: bool = False,
416
+ print_stderr: bool = False,
417
+ return_: Literal[False] = False,
418
+ return_stdout: Literal[False] = False,
419
+ return_stderr: Literal[False] = False,
420
+ retry: Retry | None = None,
421
+ retry_skip: Callable[[int, str, str], bool] | None = None,
422
+ logger: LoggerLike | None = None,
423
+ ) -> None: ...
424
+ @overload
425
+ def curl(
426
+ url: str,
427
+ /,
428
+ *,
429
+ fail: bool = True,
430
+ location: bool = True,
431
+ output: PathLike | None = None,
432
+ show_error: bool = True,
433
+ silent: bool = True,
434
+ sudo: bool = False,
435
+ print: bool = False,
436
+ print_stdout: bool = False,
437
+ print_stderr: bool = False,
438
+ return_: bool = False,
439
+ return_stdout: bool = False,
440
+ return_stderr: bool = False,
441
+ retry: Retry | None = None,
442
+ retry_skip: Callable[[int, str, str], bool] | None = None,
443
+ logger: LoggerLike | None = None,
444
+ ) -> str | None: ...
445
+ def curl(
446
+ url: str,
447
+ /,
448
+ *,
449
+ fail: bool = True,
450
+ location: bool = True,
451
+ output: PathLike | None = None,
452
+ show_error: bool = True,
453
+ silent: bool = True,
454
+ sudo: bool = False,
455
+ print: bool = False, # noqa: A002
456
+ print_stdout: bool = False,
457
+ print_stderr: bool = False,
458
+ return_: bool = False,
459
+ return_stdout: bool = False,
460
+ return_stderr: bool = False,
461
+ retry: Retry | None = None,
462
+ retry_skip: Callable[[int, str, str], bool] | None = None,
463
+ logger: LoggerLike | None = None,
464
+ ) -> str | None:
465
+ """Transfer a URL."""
466
+ args = maybe_sudo_cmd( # skipif-ci
467
+ *curl_cmd(
468
+ url,
469
+ fail=fail,
470
+ location=location,
471
+ output=output,
472
+ show_error=show_error,
473
+ silent=silent,
474
+ ),
475
+ sudo=sudo,
476
+ )
477
+ return run( # skipif-ci
478
+ *args,
479
+ print=print,
480
+ print_stdout=print_stdout,
481
+ print_stderr=print_stderr,
482
+ return_=return_,
483
+ return_stdout=return_stdout,
484
+ return_stderr=return_stderr,
485
+ retry=retry,
486
+ retry_skip=retry_skip,
487
+ logger=logger,
488
+ )
489
+
490
+
491
+ def curl_cmd(
492
+ url: str,
493
+ /,
494
+ *,
495
+ fail: bool = True,
496
+ location: bool = True,
497
+ output: PathLike | None = None,
498
+ show_error: bool = True,
499
+ silent: bool = True,
500
+ ) -> list[str]:
501
+ """Command to use 'curl' to transfer a URL."""
502
+ args: list[str] = ["curl"]
503
+ if fail:
504
+ args.append("--fail")
505
+ if location:
506
+ args.append("--location")
507
+ if output is not None:
508
+ args.extend(["--create-dirs", "--output", str(output)])
509
+ if show_error:
510
+ args.append("--show-error")
511
+ if silent:
512
+ args.append("--silent")
513
+ return [*args, url]
514
+
515
+
516
+ ##
517
+
518
+
519
+ def echo_cmd(text: str, /) -> list[str]:
520
+ """Command to use 'echo' to write arguments to the standard output."""
521
+ return ["echo", text]
522
+
523
+
524
+ ##
525
+
526
+
527
+ def env_cmds(env: StrStrMapping, /) -> list[str]:
528
+ return [f"{key}={value}" for key, value in env.items()]
529
+
530
+
531
+ ##
532
+
533
+
534
+ def expand_path(
535
+ path: PathLike, /, *, subs: StrMapping | None = None, sudo: bool = False
536
+ ) -> Path:
537
+ """Expand a path using `subprocess`."""
538
+ if subs is not None:
539
+ path = Template(str(path)).substitute(**subs)
540
+ if sudo: # pragma: no cover
541
+ return Path(run(*sudo_cmd(*echo_cmd(str(path))), return_=True))
542
+ return Path(path).expanduser()
543
+
544
+
545
+ ##
546
+
547
+
548
+ def git_branch_current(path: PathLike, /) -> str:
549
+ """Show the current a branch."""
550
+ return run(*GIT_BRANCH_SHOW_CURRENT, cwd=path, return_=True)
551
+
552
+
553
+ ##
554
+
555
+
556
+ def git_checkout(branch: str, path: PathLike, /) -> None:
557
+ """Switch a branch."""
558
+ run(*git_checkout_cmd(branch), cwd=path)
559
+
560
+
561
+ def git_checkout_cmd(branch: str, /) -> list[str]:
562
+ """Command to use 'git checkout' to switch a branch."""
563
+ return ["git", "checkout", branch]
564
+
565
+
566
+ ##
567
+
568
+
569
+ def git_clone(
570
+ url: str, path: PathLike, /, *, sudo: bool = False, branch: str | None = None
571
+ ) -> None:
572
+ """Clone a repository."""
573
+ rm(path, sudo=sudo)
574
+ run(*maybe_sudo_cmd(*git_clone_cmd(url, path), sudo=sudo))
575
+ if branch is not None:
576
+ git_checkout(branch, path)
577
+
578
+
579
+ def git_clone_cmd(url: str, path: PathLike, /) -> list[str]:
580
+ """Command to use 'git clone' to clone a repository."""
581
+ return ["git", "clone", "--recurse-submodules", url, str(path)]
582
+
583
+
584
+ ##
585
+
586
+
587
+ def install(
588
+ path: PathLike,
589
+ /,
590
+ *,
591
+ directory: bool = False,
592
+ mode: PermissionsLike | None = None,
593
+ owner: str | int | None = None,
594
+ group: str | int | None = None,
595
+ sudo: bool = False,
596
+ ) -> None:
597
+ """Install a binary."""
598
+ args = maybe_sudo_cmd(
599
+ *install_cmd(path, directory=directory, mode=mode, owner=owner, group=group),
600
+ sudo=sudo,
601
+ )
602
+ run(*args)
603
+
604
+
605
+ def install_cmd(
606
+ path: PathLike,
607
+ /,
608
+ *,
609
+ directory: bool = False,
610
+ mode: PermissionsLike | None = None,
611
+ owner: str | int | None = None,
612
+ group: str | int | None = None,
613
+ ) -> list[str]:
614
+ """Command to use 'install' to install a binary."""
615
+ args: list[str] = ["install"]
616
+ if directory:
617
+ args.append("-d")
618
+ if mode is not None:
619
+ args.extend(["-m", str(Permissions.new(mode))])
620
+ if owner is not None:
621
+ args.extend(["-o", str(owner)])
622
+ if group is not None:
623
+ args.extend(["-g", str(group)])
624
+ if directory:
625
+ args.append(str(path))
626
+ else:
627
+ args.extend(["/dev/null", str(path)])
628
+ return args
629
+
630
+
631
+ ##
632
+
633
+
634
+ def maybe_parent(path: PathLike, /, *, parent: bool = False) -> Path:
635
+ """Get the parent of a path, if required."""
636
+ path = Path(path)
637
+ return path.parent if parent else path
638
+
639
+
640
+ ##
641
+
642
+
643
+ def mkdir(path: PathLike, /, *, sudo: bool = False, parent: bool = False) -> None:
644
+ """Make a directory."""
645
+ if sudo: # pragma: no cover
646
+ run(*sudo_cmd(*mkdir_cmd(path, parent=parent)))
647
+ else:
648
+ maybe_parent(path, parent=parent).mkdir(parents=True, exist_ok=True)
649
+
650
+
651
+ ##
652
+
653
+
654
+ def mkdir_cmd(path: PathLike, /, *, parent: bool = False) -> list[str]:
655
+ """Command to use 'mv' to make a directory."""
656
+ return ["mkdir", "-p", str(maybe_parent(path, parent=parent))]
657
+
658
+
659
+ ##
660
+
661
+
662
+ def mv(
663
+ src: PathLike,
664
+ dest: PathLike,
665
+ /,
666
+ *,
667
+ sudo: bool = False,
668
+ perms: PermissionsLike | None = None,
669
+ owner: str | int | None = None,
670
+ group: str | int | None = None,
671
+ ) -> None:
672
+ """Move a file/directory."""
673
+ mkdir(dest, sudo=sudo, parent=True)
674
+ if sudo: # pragma: no cover
675
+ run(*sudo_cmd(*cp_cmd(src, dest)))
676
+ if perms is not None:
677
+ chmod(dest, perms, sudo=True)
678
+ if (owner is not None) or (group is not None):
679
+ chown(dest, sudo=True, user=owner, group=group)
680
+ else:
681
+ try:
682
+ move(src, dest, overwrite=True, perms=perms, owner=owner, group=group)
683
+ except _CopyOrMoveSourceNotFoundError as error:
684
+ raise MvFileError(src=error.src) from None
685
+
686
+
687
+ @dataclass(kw_only=True, slots=True)
688
+ class MvFileError(Exception):
689
+ src: Path
690
+
691
+ @override
692
+ def __str__(self) -> str:
693
+ return f"Source {repr_str(self.src)} does not exist"
694
+
695
+
696
+ def mv_cmd(src: PathLike, dest: PathLike, /) -> list[str]:
697
+ """Command to use 'mv' to move a file/directory."""
698
+ return ["mv", str(src), str(dest)]
699
+
700
+
701
+ ##
702
+
703
+
704
+ def replace_text(
705
+ path: PathLike, /, *replacements: tuple[str, str], sudo: bool = False
706
+ ) -> None:
707
+ """Replace the text in a file."""
708
+ path = Path(path)
709
+ text = cat(path, sudo=sudo)
710
+ for old, new in replacements:
711
+ text = text.replace(old, new)
712
+ tee(path, text, sudo=sudo)
713
+
714
+
715
+ ##
716
+
717
+
718
+ def ripgrep(*args: str, path: PathLike = PWD) -> str | None:
719
+ """Search for lines."""
720
+ try: # skipif-ci
721
+ return run(*ripgrep_cmd(*args, path=path), return_=True)
722
+ except CalledProcessError as error: # skipif-ci
723
+ if error.returncode == 1:
724
+ return None
725
+ raise
726
+
727
+
728
+ def ripgrep_cmd(*args: str, path: PathLike = PWD) -> list[str]:
729
+ """Command to use 'ripgrep' to search for lines."""
730
+ return ["rg", *args, str(path)]
731
+
732
+
733
+ ##
734
+
735
+
736
+ def rm(path: PathLike, /, *paths: PathLike, sudo: bool = False) -> None:
737
+ """Remove a file/directory."""
738
+ if sudo: # pragma: no cover
739
+ run(*sudo_cmd(*rm_cmd(path, *paths)))
740
+ else:
741
+ all_paths = list(map(Path, [path, *paths]))
742
+ for p in all_paths:
743
+ match file_or_dir(p):
744
+ case "file":
745
+ p.unlink(missing_ok=True)
746
+ case "dir":
747
+ rmtree(p, ignore_errors=True)
748
+ case None:
749
+ ...
750
+ case never:
751
+ assert_never(never)
752
+
753
+
754
+ def rm_cmd(path: PathLike, /, *paths: PathLike) -> list[str]:
755
+ """Command to use 'rm' to remove a file/directory."""
756
+ return ["rm", "-rf", str(path), *map(str, paths)]
757
+
758
+
759
+ ##
760
+
761
+
762
+ def rsync(
763
+ src_or_srcs: MaybeIterable[PathLike],
764
+ user: str,
765
+ hostname: str,
766
+ dest: PathLike,
767
+ /,
768
+ *,
769
+ sudo: bool = False,
770
+ batch_mode: bool = True,
771
+ host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
772
+ strict_host_key_checking: bool = True,
773
+ print: bool = False, # noqa: A002
774
+ retry: Retry | None = None,
775
+ logger: LoggerLike | None = None,
776
+ chown_user: str | None = None,
777
+ chown_group: str | None = None,
778
+ exclude: MaybeIterable[str] | None = None,
779
+ chmod: PermissionsLike | None = None,
780
+ ) -> None:
781
+ """Remote & local file copying."""
782
+ mkdir_args = maybe_sudo_cmd(*mkdir_cmd(dest, parent=True), sudo=sudo) # skipif-ci
783
+ ssh( # skipif-ci
784
+ user,
785
+ hostname,
786
+ *mkdir_args,
787
+ batch_mode=batch_mode,
788
+ host_key_algorithms=host_key_algorithms,
789
+ strict_host_key_checking=strict_host_key_checking,
790
+ print=print,
791
+ retry=retry,
792
+ logger=logger,
793
+ )
794
+ srcs = list(always_iterable(src_or_srcs)) # skipif-ci
795
+ rsync_args = rsync_cmd( # skipif-ci
796
+ srcs,
797
+ user,
798
+ hostname,
799
+ dest,
800
+ archive=any(Path(s).is_dir() for s in srcs),
801
+ chown_user=chown_user,
802
+ chown_group=chown_group,
803
+ exclude=exclude,
804
+ batch_mode=batch_mode,
805
+ host_key_algorithms=host_key_algorithms,
806
+ strict_host_key_checking=strict_host_key_checking,
807
+ sudo=sudo,
808
+ )
809
+ run(*rsync_args, print=print, retry=retry, logger=logger) # skipif-ci
810
+ if chmod is not None: # skipif-ci
811
+ chmod_args = maybe_sudo_cmd(*chmod_cmd(dest, chmod), sudo=sudo)
812
+ ssh(
813
+ user,
814
+ hostname,
815
+ *chmod_args,
816
+ batch_mode=batch_mode,
817
+ host_key_algorithms=host_key_algorithms,
818
+ strict_host_key_checking=strict_host_key_checking,
819
+ print=print,
820
+ retry=retry,
821
+ logger=logger,
822
+ )
823
+
824
+
825
+ def rsync_cmd(
826
+ src_or_srcs: MaybeIterable[PathLike],
827
+ user: str,
828
+ hostname: str,
829
+ dest: PathLike,
830
+ /,
831
+ *,
832
+ archive: bool = False,
833
+ chown_user: str | None = None,
834
+ chown_group: str | None = None,
835
+ exclude: MaybeIterable[str] | None = None,
836
+ batch_mode: bool = True,
837
+ host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
838
+ strict_host_key_checking: bool = True,
839
+ sudo: bool = False,
840
+ ) -> list[str]:
841
+ """Command to use 'rsync' to do remote & local file copying."""
842
+ args: list[str] = ["rsync"]
843
+ if archive:
844
+ args.append("--archive")
845
+ args.append("--checksum")
846
+ match chown_user, chown_group:
847
+ case None, None:
848
+ ...
849
+ case str(), None:
850
+ args.extend(["--chown", chown_user])
851
+ case None, str():
852
+ args.extend(["--chown", f":{chown_group}"])
853
+ case str(), str():
854
+ args.extend(["--chown", f"{chown_user}:{chown_group}"])
855
+ case never:
856
+ assert_never(never)
857
+ args.append("--compress")
858
+ if exclude is not None:
859
+ for exclude_i in always_iterable(exclude):
860
+ args.extend(["--exclude", exclude_i])
861
+ rsh_args: list[str] = ssh_opts_cmd(
862
+ batch_mode=batch_mode,
863
+ host_key_algorithms=host_key_algorithms,
864
+ strict_host_key_checking=strict_host_key_checking,
865
+ )
866
+ args.extend(["--rsh", join(rsh_args)])
867
+ if sudo:
868
+ args.extend(["--rsync-path", join(sudo_cmd("rsync"))])
869
+ srcs = list(always_iterable(src_or_srcs)) # do not Path()
870
+ if len(srcs) == 0:
871
+ raise _RsyncCmdNoSourcesError(user=user, hostname=hostname, dest=dest)
872
+ missing = [s for s in srcs if not Path(s).exists()]
873
+ if len(missing) >= 1:
874
+ raise _RsyncCmdSourcesNotFoundError(
875
+ sources=missing, user=user, hostname=hostname, dest=dest
876
+ )
877
+ return [*args, *map(str, srcs), f"{user}@{hostname}:{dest}"]
878
+
879
+
880
+ @dataclass(kw_only=True, slots=True)
881
+ class RsyncCmdError(Exception):
882
+ user: str
883
+ hostname: str
884
+ dest: PathLike
885
+
886
+
887
+ @dataclass(kw_only=True, slots=True)
888
+ class _RsyncCmdNoSourcesError(RsyncCmdError):
889
+ @override
890
+ def __str__(self) -> str:
891
+ return f"No sources selected to send to {self.user}@{self.hostname}:{self.dest}"
892
+
893
+
894
+ @dataclass(kw_only=True, slots=True)
895
+ class _RsyncCmdSourcesNotFoundError(RsyncCmdError):
896
+ sources: list[PathLike]
897
+
898
+ @override
899
+ def __str__(self) -> str:
900
+ desc = ", ".join(map(repr, map(str, self.sources)))
901
+ return f"Sources selected to send to {self.user}@{self.hostname}:{self.dest} but not found: {desc}"
902
+
903
+
904
+ ##
905
+
906
+
907
+ def rsync_many(
908
+ user: str,
909
+ hostname: str,
910
+ /,
911
+ *items: tuple[PathLike, PathLike]
912
+ | tuple[Literal["sudo"], PathLike, PathLike]
913
+ | tuple[PathLike, PathLike, PermissionsLike]
914
+ | tuple[Literal["sudo"], PathLike, PathLike, PermissionsLike],
915
+ retry: Retry | None = None,
916
+ logger: LoggerLike | None = None,
917
+ keep: bool = False,
918
+ batch_mode: bool = True,
919
+ host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
920
+ strict_host_key_checking: bool = True,
921
+ print: bool = False, # noqa: A002
922
+ exclude: MaybeIterable[str] | None = None,
923
+ ) -> None:
924
+ cmds: list[list[str]] = [] # skipif-ci
925
+ with ( # skipif-ci
926
+ TemporaryDirectory() as temp_src,
927
+ yield_ssh_temp_dir(
928
+ user, hostname, retry=retry, logger=logger, keep=keep
929
+ ) as temp_dest,
930
+ ):
931
+ for item in items:
932
+ match item:
933
+ case Path() | str() as src, Path() | str() as dest:
934
+ cmds.extend(_rsync_many_prepare(src, dest, temp_src, temp_dest))
935
+ case ( # pragma: no cover
936
+ "sudo",
937
+ Path() | str() as src,
938
+ Path() | str() as dest,
939
+ ):
940
+ cmds.extend(
941
+ _rsync_many_prepare(src, dest, temp_src, temp_dest, sudo=True)
942
+ )
943
+ case (
944
+ Path() | str() as src,
945
+ Path() | str() as dest,
946
+ Permissions() | int() | str() as perms,
947
+ ):
948
+ cmds.extend(
949
+ _rsync_many_prepare(src, dest, temp_src, temp_dest, perms=perms)
950
+ )
951
+ case ( # pragma: no cover
952
+ "sudo",
953
+ Path() | str() as src,
954
+ Path() | str() as dest,
955
+ Permissions() | int() | str() as perms,
956
+ ):
957
+ cmds.extend(
958
+ _rsync_many_prepare(
959
+ src, dest, temp_src, temp_dest, sudo=True, perms=perms
960
+ )
961
+ )
962
+ case never:
963
+ assert_never(never)
964
+ rsync(
965
+ f"{temp_src}/",
966
+ user,
967
+ hostname,
968
+ temp_dest,
969
+ batch_mode=batch_mode,
970
+ host_key_algorithms=host_key_algorithms,
971
+ strict_host_key_checking=strict_host_key_checking,
972
+ print=print,
973
+ retry=retry,
974
+ logger=logger,
975
+ exclude=exclude,
976
+ )
977
+ ssh(
978
+ user,
979
+ hostname,
980
+ *BASH_LS,
981
+ input="\n".join(map(join, cmds)),
982
+ print=print,
983
+ retry=retry,
984
+ logger=logger,
985
+ )
986
+
987
+
988
+ def _rsync_many_prepare(
989
+ src: PathLike,
990
+ dest: PathLike,
991
+ temp_src: PathLike,
992
+ temp_dest: PathLike,
993
+ /,
994
+ *,
995
+ sudo: bool = False,
996
+ perms: PermissionsLike | None = None,
997
+ ) -> list[list[str]]:
998
+ dest, temp_src, temp_dest = map(Path, [dest, temp_src, temp_dest])
999
+ n = len(list(temp_src.iterdir()))
1000
+ name = str(n)
1001
+ match src:
1002
+ case Path():
1003
+ cp(src, temp_src / name)
1004
+ case str():
1005
+ try:
1006
+ exists = Path(src).exists()
1007
+ except OSError:
1008
+ tee(temp_src / name, src)
1009
+ else:
1010
+ if exists:
1011
+ cp(src, temp_src / name)
1012
+ else:
1013
+ tee(temp_src / name, src)
1014
+ case never:
1015
+ assert_never(never)
1016
+ cmds: list[list[str]] = [
1017
+ maybe_sudo_cmd(*rm_cmd(dest), sudo=sudo),
1018
+ maybe_sudo_cmd(*mkdir_cmd(dest, parent=True), sudo=sudo),
1019
+ maybe_sudo_cmd(*cp_cmd(temp_dest / name, dest), sudo=sudo),
1020
+ ]
1021
+ if perms is not None:
1022
+ cmds.append(maybe_sudo_cmd(*chmod_cmd(dest, perms), sudo=sudo))
1023
+ return cmds
1024
+
1025
+
1026
+ ##
1027
+
1028
+
1029
+ @overload
1030
+ def run(
1031
+ cmd: str,
1032
+ /,
1033
+ *cmds_or_args: str,
1034
+ user: str | int | None = None,
1035
+ executable: str | None = None,
1036
+ shell: bool = False,
1037
+ cwd: PathLike | None = None,
1038
+ env: StrStrMapping | None = None,
1039
+ input: str | None = None,
1040
+ print: bool = False,
1041
+ print_stdout: bool = False,
1042
+ print_stderr: bool = False,
1043
+ return_: Literal[True],
1044
+ return_stdout: bool = False,
1045
+ return_stderr: bool = False,
1046
+ retry: Retry | None = None,
1047
+ retry_skip: Callable[[int, str, str], bool] | None = None,
1048
+ logger: LoggerLike | None = None,
1049
+ ) -> str: ...
1050
+ @overload
1051
+ def run(
1052
+ cmd: str,
1053
+ /,
1054
+ *cmds_or_args: str,
1055
+ user: str | int | None = None,
1056
+ executable: str | None = None,
1057
+ shell: bool = False,
1058
+ cwd: PathLike | None = None,
1059
+ env: StrStrMapping | None = None,
1060
+ input: str | None = None,
1061
+ print: bool = False,
1062
+ print_stdout: bool = False,
1063
+ print_stderr: bool = False,
1064
+ return_: bool = False,
1065
+ return_stdout: Literal[True],
1066
+ return_stderr: bool = False,
1067
+ retry: Retry | None = None,
1068
+ retry_skip: Callable[[int, str, str], bool] | None = None,
1069
+ logger: LoggerLike | None = None,
1070
+ ) -> str: ...
1071
+ @overload
1072
+ def run(
1073
+ cmd: str,
1074
+ /,
1075
+ *cmds_or_args: str,
1076
+ user: str | int | None = None,
1077
+ executable: str | None = None,
1078
+ shell: bool = False,
1079
+ cwd: PathLike | None = None,
1080
+ env: StrStrMapping | None = None,
1081
+ input: str | None = None,
1082
+ print: bool = False,
1083
+ print_stdout: bool = False,
1084
+ print_stderr: bool = False,
1085
+ return_: bool = False,
1086
+ return_stdout: bool = False,
1087
+ return_stderr: Literal[True],
1088
+ retry: Retry | None = None,
1089
+ retry_skip: Callable[[int, str, str], bool] | None = None,
1090
+ logger: LoggerLike | None = None,
1091
+ ) -> str: ...
1092
+ @overload
1093
+ def run(
1094
+ cmd: str,
1095
+ /,
1096
+ *cmds_or_args: str,
1097
+ user: str | int | None = None,
1098
+ executable: str | None = None,
1099
+ shell: bool = False,
1100
+ cwd: PathLike | None = None,
1101
+ env: StrStrMapping | None = None,
1102
+ input: str | None = None,
1103
+ print: bool = False,
1104
+ print_stdout: bool = False,
1105
+ print_stderr: bool = False,
1106
+ return_: Literal[False] = False,
1107
+ return_stdout: Literal[False] = False,
1108
+ return_stderr: Literal[False] = False,
1109
+ retry: Retry | None = None,
1110
+ retry_skip: Callable[[int, str, str], bool] | None = None,
1111
+ logger: LoggerLike | None = None,
1112
+ ) -> None: ...
1113
+ @overload
1114
+ def run(
1115
+ cmd: str,
1116
+ /,
1117
+ *cmds_or_args: str,
1118
+ user: str | int | None = None,
1119
+ executable: str | None = None,
1120
+ shell: bool = False,
1121
+ cwd: PathLike | None = None,
1122
+ env: StrStrMapping | None = None,
1123
+ input: str | None = None,
1124
+ print: bool = False,
1125
+ print_stdout: bool = False,
1126
+ print_stderr: bool = False,
1127
+ return_: bool = False,
1128
+ return_stdout: bool = False,
1129
+ return_stderr: bool = False,
1130
+ retry: Retry | None = None,
1131
+ retry_skip: Callable[[int, str, str], bool] | None = None,
1132
+ logger: LoggerLike | None = None,
1133
+ ) -> str | None: ...
1134
+ def run(
1135
+ cmd: str,
1136
+ /,
1137
+ *cmds_or_args: str,
1138
+ user: str | int | None = None,
1139
+ executable: str | None = None,
1140
+ shell: bool = False,
1141
+ cwd: PathLike | None = None,
1142
+ env: StrStrMapping | None = None,
1143
+ input: str | None = None, # noqa: A002
1144
+ print: bool = False, # noqa: A002
1145
+ print_stdout: bool = False,
1146
+ print_stderr: bool = False,
1147
+ return_: bool = False,
1148
+ return_stdout: bool = False,
1149
+ return_stderr: bool = False,
1150
+ retry: Retry | None = None,
1151
+ retry_skip: Callable[[int, str, str], bool] | None = None,
1152
+ logger: LoggerLike | None = None,
1153
+ ) -> str | None:
1154
+ """Run a command in a subprocess."""
1155
+ args: list[str] = []
1156
+ if user is not None: # pragma: no cover
1157
+ args.extend(["su", "-", str(user)])
1158
+ args.extend([cmd, *cmds_or_args])
1159
+ buffer = StringIO()
1160
+ stdout = StringIO()
1161
+ stderr = StringIO()
1162
+ stdout_outputs: list[IO[str]] = [buffer, stdout]
1163
+ if print or print_stdout:
1164
+ stdout_outputs.append(sys.stdout)
1165
+ stderr_outputs: list[IO[str]] = [buffer, stderr]
1166
+ if print or print_stderr:
1167
+ stderr_outputs.append(sys.stderr)
1168
+ with Popen(
1169
+ args,
1170
+ bufsize=1,
1171
+ executable=executable,
1172
+ stdin=PIPE,
1173
+ stdout=PIPE,
1174
+ stderr=PIPE,
1175
+ shell=shell,
1176
+ cwd=cwd,
1177
+ env=env,
1178
+ text=True,
1179
+ user=user,
1180
+ ) as proc:
1181
+ if proc.stdin is None: # pragma: no cover
1182
+ raise ImpossibleCaseError(case=[f"{proc.stdin=}"])
1183
+ if proc.stdout is None: # pragma: no cover
1184
+ raise ImpossibleCaseError(case=[f"{proc.stdout=}"])
1185
+ if proc.stderr is None: # pragma: no cover
1186
+ raise ImpossibleCaseError(case=[f"{proc.stderr=}"])
1187
+ with (
1188
+ _run_yield_write(proc.stdout, *stdout_outputs),
1189
+ _run_yield_write(proc.stderr, *stderr_outputs),
1190
+ ):
1191
+ if input is not None:
1192
+ _ = proc.stdin.write(input)
1193
+ proc.stdin.flush()
1194
+ proc.stdin.close()
1195
+ return_code = proc.wait()
1196
+ match return_code, return_ or return_stdout, return_ or return_stderr:
1197
+ case 0, True, True:
1198
+ _ = buffer.seek(0)
1199
+ return buffer.read().rstrip("\n")
1200
+ case 0, True, False:
1201
+ _ = stdout.seek(0)
1202
+ return stdout.read().rstrip("\n")
1203
+ case 0, False, True:
1204
+ _ = stderr.seek(0)
1205
+ return stderr.read().rstrip("\n")
1206
+ case 0, False, False:
1207
+ return None
1208
+ case _, _, _:
1209
+ _ = stdout.seek(0)
1210
+ stdout_text = stdout.read()
1211
+ _ = stderr.seek(0)
1212
+ stderr_text = stderr.read()
1213
+ if (retry is None) or (
1214
+ (retry is not None)
1215
+ and (retry_skip is not None)
1216
+ and retry_skip(return_code, stdout_text, stderr_text)
1217
+ ):
1218
+ attempts = duration = None
1219
+ else:
1220
+ attempts, duration = retry
1221
+ if logger is not None:
1222
+ msg = normalize_multi_line_str(f"""
1223
+ 'run' failed with:
1224
+ - cmd = {cmd}
1225
+ - cmds_or_args = {cmds_or_args}
1226
+ - user = {user}
1227
+ - executable = {executable}
1228
+ - shell = {shell}
1229
+ - cwd = {cwd}
1230
+ - env = {env}
1231
+
1232
+ -- stdin ----------------------------------------------------------------------
1233
+ {"" if input is None else input}-------------------------------------------------------------------------------
1234
+ -- stdout ---------------------------------------------------------------------
1235
+ {stdout_text}-------------------------------------------------------------------------------
1236
+ -- stderr ---------------------------------------------------------------------
1237
+ {stderr_text}-------------------------------------------------------------------------------
1238
+ """)
1239
+ if (attempts is not None) and (attempts >= 1):
1240
+ if duration is None:
1241
+ msg = f"{msg}\n\nRetrying {attempts} more time(s)..."
1242
+ else:
1243
+ msg = f"{msg}\n\nRetrying {attempts} more time(s) after {in_timedelta(duration)}..."
1244
+ to_logger(logger).error(msg)
1245
+ error = CalledProcessError(
1246
+ return_code, args, output=stdout_text, stderr=stderr_text
1247
+ )
1248
+ if (attempts is None) or (attempts <= 0):
1249
+ raise error
1250
+ if duration is not None:
1251
+ sleep(duration)
1252
+ return run(
1253
+ cmd,
1254
+ *cmds_or_args,
1255
+ user=user,
1256
+ executable=executable,
1257
+ shell=shell,
1258
+ cwd=cwd,
1259
+ env=env,
1260
+ input=input,
1261
+ print=print,
1262
+ print_stdout=print_stdout,
1263
+ print_stderr=print_stderr,
1264
+ return_=return_,
1265
+ return_stdout=return_stdout,
1266
+ return_stderr=return_stderr,
1267
+ retry=(attempts - 1, duration),
1268
+ logger=logger,
1269
+ )
1270
+ case never:
1271
+ assert_never(never)
1272
+
1273
+
1274
+ @enhanced_context_manager
1275
+ def _run_yield_write(input_: IO[str], /, *outputs: IO[str]) -> Iterator[None]:
1276
+ thread = Thread(target=_run_daemon_target, args=(input_, *outputs), daemon=True)
1277
+ thread.start()
1278
+ try:
1279
+ yield
1280
+ finally:
1281
+ thread.join()
1282
+
1283
+
1284
+ def _run_daemon_target(input_: IO[str], /, *outputs: IO[str]) -> None:
1285
+ with input_:
1286
+ for text in iter(input_.readline, ""):
1287
+ _run_write_to_streams(text, *outputs)
1288
+
1289
+
1290
+ def _run_write_to_streams(text: str, /, *outputs: IO[str]) -> None:
1291
+ for output in outputs:
1292
+ _ = output.write(text)
1293
+
1294
+
1295
+ ##
1296
+
1297
+
1298
+ def set_hostname_cmd(hostname: str, /) -> list[str]:
1299
+ """Command to set the system hostname."""
1300
+ return ["hostnamectl", "set-hostname", hostname]
1301
+
1302
+
1303
+ ##
1304
+
1305
+
1306
+ @overload
1307
+ def ssh(
1308
+ user: str,
1309
+ hostname: str,
1310
+ /,
1311
+ *cmd_and_args: str,
1312
+ batch_mode: bool = True,
1313
+ host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
1314
+ strict_host_key_checking: bool = True,
1315
+ port: int | None = None,
1316
+ env: StrStrMapping | None = None,
1317
+ input: str | None = None,
1318
+ print: bool = False,
1319
+ print_stdout: bool = False,
1320
+ print_stderr: bool = False,
1321
+ return_: Literal[True],
1322
+ return_stdout: bool = False,
1323
+ return_stderr: bool = False,
1324
+ retry: Retry | None = None,
1325
+ logger: LoggerLike | None = None,
1326
+ ) -> str: ...
1327
+ @overload
1328
+ def ssh(
1329
+ user: str,
1330
+ hostname: str,
1331
+ /,
1332
+ *cmd_and_args: str,
1333
+ batch_mode: bool = True,
1334
+ host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
1335
+ strict_host_key_checking: bool = True,
1336
+ port: int | None = None,
1337
+ env: StrStrMapping | None = None,
1338
+ input: str | None = None,
1339
+ print: bool = False,
1340
+ print_stdout: bool = False,
1341
+ print_stderr: bool = False,
1342
+ return_: bool = False,
1343
+ return_stdout: Literal[True],
1344
+ return_stderr: bool = False,
1345
+ retry: Retry | None = None,
1346
+ logger: LoggerLike | None = None,
1347
+ ) -> str: ...
1348
+ @overload
1349
+ def ssh(
1350
+ user: str,
1351
+ hostname: str,
1352
+ /,
1353
+ *cmd_and_args: str,
1354
+ batch_mode: bool = True,
1355
+ host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
1356
+ strict_host_key_checking: bool = True,
1357
+ port: int | None = None,
1358
+ env: StrStrMapping | None = None,
1359
+ input: str | None = None,
1360
+ print: bool = False,
1361
+ print_stdout: bool = False,
1362
+ print_stderr: bool = False,
1363
+ return_: bool = False,
1364
+ return_stdout: bool = False,
1365
+ return_stderr: Literal[True],
1366
+ retry: Retry | None = None,
1367
+ logger: LoggerLike | None = None,
1368
+ ) -> str: ...
1369
+ @overload
1370
+ def ssh(
1371
+ user: str,
1372
+ hostname: str,
1373
+ /,
1374
+ *cmd_and_args: str,
1375
+ batch_mode: bool = True,
1376
+ host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
1377
+ strict_host_key_checking: bool = True,
1378
+ port: int | None = None,
1379
+ env: StrStrMapping | None = None,
1380
+ input: str | None = None,
1381
+ print: bool = False,
1382
+ print_stdout: bool = False,
1383
+ print_stderr: bool = False,
1384
+ return_: Literal[False] = False,
1385
+ return_stdout: Literal[False] = False,
1386
+ return_stderr: Literal[False] = False,
1387
+ retry: Retry | None = None,
1388
+ logger: LoggerLike | None = None,
1389
+ ) -> None: ...
1390
+ @overload
1391
+ def ssh(
1392
+ user: str,
1393
+ hostname: str,
1394
+ /,
1395
+ *cmd_and_args: str,
1396
+ batch_mode: bool = True,
1397
+ host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
1398
+ strict_host_key_checking: bool = True,
1399
+ port: int | None = None,
1400
+ env: StrStrMapping | None = None,
1401
+ input: str | None = None,
1402
+ print: bool = False,
1403
+ print_stdout: bool = False,
1404
+ print_stderr: bool = False,
1405
+ return_: bool = False,
1406
+ return_stdout: bool = False,
1407
+ return_stderr: bool = False,
1408
+ retry: Retry | None = None,
1409
+ logger: LoggerLike | None = None,
1410
+ ) -> str | None: ...
1411
+ def ssh(
1412
+ user: str,
1413
+ hostname: str,
1414
+ /,
1415
+ *cmd_and_args: str,
1416
+ batch_mode: bool = True,
1417
+ host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
1418
+ strict_host_key_checking: bool = True,
1419
+ port: int | None = None,
1420
+ env: StrStrMapping | None = None,
1421
+ input: str | None = None, # noqa: A002
1422
+ print: bool = False, # noqa: A002
1423
+ print_stdout: bool = False,
1424
+ print_stderr: bool = False,
1425
+ return_: bool = False,
1426
+ return_stdout: bool = False,
1427
+ return_stderr: bool = False,
1428
+ retry: Retry | None = None,
1429
+ logger: LoggerLike | None = None,
1430
+ ) -> str | None:
1431
+ """Execute a command on a remote machine."""
1432
+ run_cmd_and_args = ssh_cmd( # skipif-ci
1433
+ user,
1434
+ hostname,
1435
+ *cmd_and_args,
1436
+ batch_mode=batch_mode,
1437
+ host_key_algorithms=host_key_algorithms,
1438
+ strict_host_key_checking=strict_host_key_checking,
1439
+ port=port,
1440
+ env=env,
1441
+ )
1442
+ try: # skipif-ci
1443
+ return run(
1444
+ *run_cmd_and_args,
1445
+ input=input,
1446
+ print=print,
1447
+ print_stdout=print_stdout,
1448
+ print_stderr=print_stderr,
1449
+ return_=return_,
1450
+ return_stdout=return_stdout,
1451
+ return_stderr=return_stderr,
1452
+ retry=retry,
1453
+ retry_skip=_ssh_retry_skip,
1454
+ logger=logger,
1455
+ )
1456
+ except CalledProcessError as error: # pragma: no cover
1457
+ if not _ssh_is_strict_checking_error(error.stderr):
1458
+ raise
1459
+ ssh_keyscan(hostname, port=port)
1460
+ return ssh(
1461
+ user,
1462
+ hostname,
1463
+ *cmd_and_args,
1464
+ batch_mode=batch_mode,
1465
+ host_key_algorithms=host_key_algorithms,
1466
+ strict_host_key_checking=strict_host_key_checking,
1467
+ port=port,
1468
+ input=input,
1469
+ print=print,
1470
+ print_stdout=print_stdout,
1471
+ print_stderr=print_stderr,
1472
+ return_=return_,
1473
+ return_stdout=return_stdout,
1474
+ return_stderr=return_stderr,
1475
+ retry=retry,
1476
+ logger=logger,
1477
+ )
1478
+
1479
+
1480
+ def _ssh_retry_skip(return_code: int, stdout: str, stderr: str, /) -> bool:
1481
+ _ = (return_code, stdout)
1482
+ return _ssh_is_strict_checking_error(stderr)
1483
+
1484
+
1485
+ def _ssh_is_strict_checking_error(text: str, /) -> bool:
1486
+ match = search(
1487
+ "(Host key for .* has changed|No ED25519 host key is known for .*) and you have requested strict checking",
1488
+ text,
1489
+ flags=MULTILINE,
1490
+ )
1491
+ return match is not None
1492
+
1493
+
1494
+ def ssh_cmd(
1495
+ user: str,
1496
+ hostname: str,
1497
+ /,
1498
+ *cmd_and_args: str,
1499
+ batch_mode: bool = True,
1500
+ host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
1501
+ strict_host_key_checking: bool = True,
1502
+ port: int | None = None,
1503
+ env: StrStrMapping | None = None,
1504
+ ) -> list[str]:
1505
+ """Command to use 'ssh' to execute a command on a remote machine."""
1506
+ args: list[str] = ssh_opts_cmd(
1507
+ batch_mode=batch_mode,
1508
+ host_key_algorithms=host_key_algorithms,
1509
+ strict_host_key_checking=strict_host_key_checking,
1510
+ port=port,
1511
+ )
1512
+ args.append(f"{user}@{hostname}")
1513
+ if env is not None:
1514
+ args.extend(env_cmds(env))
1515
+ return [*args, *cmd_and_args]
1516
+
1517
+
1518
+ def ssh_opts_cmd(
1519
+ *,
1520
+ batch_mode: bool = True,
1521
+ host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
1522
+ strict_host_key_checking: bool = True,
1523
+ port: int | None = None,
1524
+ ) -> list[str]:
1525
+ """Command to use prepare 'ssh' to execute a command on a remote machine."""
1526
+ args: list[str] = ["ssh"]
1527
+ if batch_mode:
1528
+ args.extend(["-o", "BatchMode=yes"])
1529
+ args.extend(["-o", f"HostKeyAlgorithms={','.join(host_key_algorithms)}"])
1530
+ if strict_host_key_checking:
1531
+ args.extend(["-o", "StrictHostKeyChecking=yes"])
1532
+ if port is not None:
1533
+ args.extend(["-p", str(port)])
1534
+ return [*args, "-T"]
1535
+
1536
+
1537
+ ##
1538
+
1539
+
1540
+ def ssh_await(
1541
+ user: str,
1542
+ hostname: str,
1543
+ /,
1544
+ *,
1545
+ logger: LoggerLike | None = None,
1546
+ duration: Duration = SECOND,
1547
+ ) -> None:
1548
+ while True: # skipif-ci
1549
+ if logger is not None:
1550
+ to_logger(logger).info("Waiting for '%s'...", hostname)
1551
+ try:
1552
+ ssh(user, hostname, "true")
1553
+ except CalledProcessError: # pragma: no cover
1554
+ sleep(duration)
1555
+ else:
1556
+ if logger is not None:
1557
+ to_logger(logger).info("'%s' is up", hostname)
1558
+ return
1559
+
1560
+
1561
+ ##
1562
+
1563
+
1564
+ def ssh_keyscan(
1565
+ hostname: str,
1566
+ /,
1567
+ *,
1568
+ path: PathLike = KNOWN_HOSTS,
1569
+ retry: Retry | None = None,
1570
+ port: int | None = None,
1571
+ ) -> None:
1572
+ """Add a known host."""
1573
+ ssh_keygen_remove(hostname, path=path, retry=retry) # skipif-ci
1574
+ result = run( # skipif-ci
1575
+ *ssh_keyscan_cmd(hostname, port=port), return_=True, retry=retry
1576
+ )
1577
+ tee(path, result, append=True) # skipif-ci
1578
+
1579
+
1580
+ def ssh_keyscan_cmd(hostname: str, /, *, port: int | None = None) -> list[str]:
1581
+ """Command to use 'ssh-keyscan' to add a known host."""
1582
+ args: list[str] = ["ssh-keyscan"]
1583
+ if port is not None:
1584
+ args.extend(["-p", str(port)])
1585
+ return [*args, "-q", "-t", "ed25519", hostname]
1586
+
1587
+
1588
+ ##
1589
+
1590
+
1591
+ def ssh_keygen_remove(
1592
+ hostname: str, /, *, path: PathLike = KNOWN_HOSTS, retry: Retry | None = None
1593
+ ) -> None:
1594
+ """Remove a known host."""
1595
+ path = Path(path)
1596
+ if path.exists():
1597
+ run(*ssh_keygen_remove_cmd(hostname, path=path), retry=retry)
1598
+
1599
+
1600
+ def ssh_keygen_remove_cmd(
1601
+ hostname: str, /, *, path: PathLike = KNOWN_HOSTS
1602
+ ) -> list[str]:
1603
+ """Command to use 'ssh-keygen' to remove a known host."""
1604
+ return ["ssh-keygen", "-f", str(path), "-R", hostname]
1605
+
1606
+
1607
+ ##
1608
+
1609
+
1610
+ def sudo_cmd(cmd: str, /, *args: str) -> list[str]:
1611
+ """Command to use 'sudo' to execute a command as another user."""
1612
+ return ["sudo", cmd, *args]
1613
+
1614
+
1615
+ def maybe_sudo_cmd(cmd: str, /, *args: str, sudo: bool = False) -> list[str]:
1616
+ """Command to use 'sudo' to execute a command as another user, if required."""
1617
+ parts: list[str] = [cmd, *args]
1618
+ return sudo_cmd(*parts) if sudo else parts
1619
+
1620
+
1621
+ ##
1622
+
1623
+
1624
+ def sudo_nopasswd_cmd(user: str, /) -> str:
1625
+ """Command to allow a user to use password-free `sudo`."""
1626
+ return f"{user} ALL=(ALL) NOPASSWD: ALL"
1627
+
1628
+
1629
+ ##
1630
+
1631
+
1632
+ def symlink(target: PathLike, link: PathLike, /, *, sudo: bool = False) -> None:
1633
+ """Make a symbolic link."""
1634
+ rm(link, sudo=sudo)
1635
+ mkdir(link, sudo=sudo, parent=True)
1636
+ if sudo: # pragma: no cover
1637
+ run(*sudo_cmd(*symlink_cmd(target, link)))
1638
+ else:
1639
+ target, link = map(Path, [target, link])
1640
+ link.symlink_to(target)
1641
+
1642
+
1643
+ def symlink_cmd(target: PathLike, link: PathLike, /) -> list[str]:
1644
+ """Command to use 'symlink' to make a symbolic link."""
1645
+ return ["ln", "-s", str(target), str(link)]
1646
+
1647
+
1648
+ ##
1649
+
1650
+
1651
+ def tee(
1652
+ path: PathLike, text: str, /, *, sudo: bool = False, append: bool = False
1653
+ ) -> None:
1654
+ """Duplicate standard input."""
1655
+ mkdir(path, sudo=sudo, parent=True)
1656
+ if sudo: # pragma: no cover
1657
+ run(*sudo_cmd(*tee_cmd(path, append=append)), input=text)
1658
+ else:
1659
+ path = Path(path)
1660
+ with path.open(mode="a" if append else "w") as fh:
1661
+ _ = fh.write(text)
1662
+
1663
+
1664
+ def tee_cmd(path: PathLike, /, *, append: bool = False) -> list[str]:
1665
+ """Command to use 'tee' to duplicate standard input."""
1666
+ args: list[str] = ["tee"]
1667
+ if append:
1668
+ args.append("-a")
1669
+ return [*args, str(path)]
1670
+
1671
+
1672
+ ##
1673
+
1674
+
1675
+ def touch(path: PathLike, /, *, sudo: bool = False) -> None:
1676
+ """Change file access and modification times."""
1677
+ run(*maybe_sudo_cmd(*touch_cmd(path), sudo=sudo))
1678
+
1679
+
1680
+ def touch_cmd(path: PathLike, /) -> list[str]:
1681
+ """Command to use 'touch' to change file access and modification times."""
1682
+ return ["touch", str(path)]
1683
+
1684
+
1685
+ ##
1686
+
1687
+
1688
+ def update_ca_certificates(*, sudo: bool = False) -> None:
1689
+ """Update the system CA certificates."""
1690
+ run(*maybe_sudo_cmd(UPDATE_CA_CERTIFICATES, sudo=sudo)) # pragma: no cover
1691
+
1692
+
1693
+ ##
1694
+
1695
+
1696
+ def useradd(
1697
+ login: str,
1698
+ /,
1699
+ *,
1700
+ create_home: bool = True,
1701
+ groups: MaybeIterable[str] | None = None,
1702
+ shell: PathLike | None = None,
1703
+ sudo: bool = False,
1704
+ password: str | None = None,
1705
+ ) -> None:
1706
+ """Create a new user."""
1707
+ args = maybe_sudo_cmd( # pragma: no cover
1708
+ *useradd_cmd(login, create_home=create_home, groups=groups, shell=shell)
1709
+ )
1710
+ run(*args) # pragma: no cover
1711
+ if password is not None: # pragma: no cover
1712
+ chpasswd(login, password, sudo=sudo)
1713
+
1714
+
1715
+ def useradd_cmd(
1716
+ login: str,
1717
+ /,
1718
+ *,
1719
+ create_home: bool = True,
1720
+ groups: MaybeIterable[str] | None = None,
1721
+ shell: PathLike | None = None,
1722
+ ) -> list[str]:
1723
+ """Command to use 'useradd' to create a new user."""
1724
+ args: list[str] = ["useradd"]
1725
+ if create_home:
1726
+ args.append("--create-home")
1727
+ if groups is not None:
1728
+ args.extend(["--groups", *always_iterable(groups)])
1729
+ if shell is not None:
1730
+ args.extend(["--shell", str(shell)])
1731
+ return [*args, login]
1732
+
1733
+
1734
+ ##
1735
+
1736
+
1737
+ def uv_index_cmd(*, index: MaybeSequenceStr | None = None) -> list[str]:
1738
+ """Generate the `--index` command if necessary."""
1739
+ return [] if index is None else ["--index", ",".join(always_iterable(index))]
1740
+
1741
+
1742
+ ##
1743
+
1744
+
1745
+ type _UvPipListFormat = Literal["columns", "freeze", "json"]
1746
+
1747
+
1748
+ @dataclass(order=True, unsafe_hash=True, kw_only=True, slots=True)
1749
+ class _UvPipListOutput:
1750
+ name: str
1751
+ version: Version2 | Version3
1752
+ editable_project_location: Path | None = None
1753
+ latest_version: Version2 | Version3 | None = None
1754
+ latest_filetype: str | None = None
1755
+
1756
+
1757
+ def uv_pip_list(
1758
+ *,
1759
+ editable: bool = False,
1760
+ exclude_editable: bool = False,
1761
+ index: MaybeSequenceStr | None = None,
1762
+ native_tls: bool = False,
1763
+ ) -> list[_UvPipListOutput]:
1764
+ """List packages installed in an environment."""
1765
+ cmds_base, cmds_outdated = [
1766
+ uv_pip_list_cmd(
1767
+ editable=editable,
1768
+ exclude_editable=exclude_editable,
1769
+ format_="json",
1770
+ outdated=outdated,
1771
+ index=index,
1772
+ native_tls=native_tls,
1773
+ )
1774
+ for outdated in [False, True]
1775
+ ]
1776
+ text_base, text_outdated = [
1777
+ run(*cmds, return_stdout=True) for cmds in [cmds_base, cmds_outdated]
1778
+ ]
1779
+ dicts_base, dicts_outdated = list(
1780
+ map(_uv_pip_list_loads, [text_base, text_outdated])
1781
+ )
1782
+ return [_uv_pip_list_assemble_output(d, dicts_outdated) for d in dicts_base]
1783
+
1784
+
1785
+ def _uv_pip_list_loads(text: str, /) -> list[StrMapping]:
1786
+ try:
1787
+ return json.loads(text)
1788
+ except JSONDecodeError:
1789
+ raise _UvPipListJsonError(text=text) from None
1790
+
1791
+
1792
+ def _uv_pip_list_assemble_output(
1793
+ dict_: StrMapping, outdated: Iterable[StrMapping], /
1794
+ ) -> _UvPipListOutput:
1795
+ name = dict_["name"]
1796
+ try:
1797
+ version = parse_version_2_or_3(dict_["version"])
1798
+ except ParseVersion2Or3Error:
1799
+ raise _UvPipListBaseVersionError(data=dict_) from None
1800
+ try:
1801
+ location = Path(dict_["editable_project_location"])
1802
+ except KeyError:
1803
+ location = None
1804
+ try:
1805
+ outdated_i = one(d for d in outdated if d["name"] == name)
1806
+ except OneEmptyError:
1807
+ latest_version = latest_filetype = None
1808
+ else:
1809
+ try:
1810
+ latest_version = parse_version_2_or_3(outdated_i["latest_version"])
1811
+ except ParseVersion2Or3Error:
1812
+ raise _UvPipListOutdatedVersionError(data=outdated_i) from None
1813
+ latest_filetype = outdated_i["latest_filetype"]
1814
+ return _UvPipListOutput(
1815
+ name=dict_["name"],
1816
+ version=version,
1817
+ editable_project_location=location,
1818
+ latest_version=latest_version,
1819
+ latest_filetype=latest_filetype,
1820
+ )
1821
+
1822
+
1823
+ @dataclass(kw_only=True, slots=True)
1824
+ class UvPipListError(Exception): ...
1825
+
1826
+
1827
+ @dataclass(kw_only=True, slots=True)
1828
+ class _UvPipListJsonError(UvPipListError):
1829
+ text: str
1830
+
1831
+ @override
1832
+ def __str__(self) -> str:
1833
+ return f"Unable to parse JSON; got {self.text!r}"
1834
+
1835
+
1836
+ @dataclass(kw_only=True, slots=True)
1837
+ class _UvPipListBaseVersionError(UvPipListError):
1838
+ data: StrMapping
1839
+
1840
+ @override
1841
+ def __str__(self) -> str:
1842
+ return f"Unable to parse version; got {self.data}"
1843
+
1844
+
1845
+ @dataclass(kw_only=True, slots=True)
1846
+ class _UvPipListOutdatedVersionError(UvPipListError):
1847
+ data: StrMapping
1848
+
1849
+ @override
1850
+ def __str__(self) -> str:
1851
+ return f"Unable to parse version; got {self.data}"
1852
+
1853
+
1854
+ def uv_pip_list_cmd(
1855
+ *,
1856
+ editable: bool = False,
1857
+ exclude_editable: bool = False,
1858
+ format_: _UvPipListFormat = "columns",
1859
+ outdated: bool = False,
1860
+ index: MaybeSequenceStr | None = None,
1861
+ native_tls: bool = False,
1862
+ ) -> list[str]:
1863
+ """Command to use 'uv' to list packages installed in an environment."""
1864
+ args: list[str] = ["uv", "pip", "list"]
1865
+ if editable:
1866
+ args.append("--editable")
1867
+ if exclude_editable:
1868
+ args.append("--exclude-editable")
1869
+ args.extend(["--format", format_])
1870
+ if outdated:
1871
+ args.append("--outdated")
1872
+ return [
1873
+ *args,
1874
+ "--strict",
1875
+ *uv_index_cmd(index=index),
1876
+ MANAGED_PYTHON,
1877
+ *uv_native_tls_cmd(native_tls=native_tls),
1878
+ ]
1879
+
1880
+
1881
+ ##
1882
+
1883
+
1884
+ def uv_native_tls_cmd(*, native_tls: bool = False) -> list[str]:
1885
+ """Generate the `--native-tls` command if necessary."""
1886
+ return ["--native-tls"] if native_tls else []
1887
+
1888
+
1889
+ ##
1890
+
1891
+
1892
+ @overload
1893
+ def uv_run(
1894
+ module: str,
1895
+ /,
1896
+ *args: str,
1897
+ extra: MaybeSequenceStr | None = None,
1898
+ all_extras: bool = False,
1899
+ group: MaybeSequenceStr | None = None,
1900
+ all_groups: bool = False,
1901
+ only_dev: bool = False,
1902
+ with_: MaybeSequenceStr | None = None,
1903
+ index: MaybeSequenceStr | None = None,
1904
+ native_tls: bool = False,
1905
+ env: StrStrMapping | None = None,
1906
+ cwd: PathLike | None = None,
1907
+ print: bool = False,
1908
+ print_stdout: bool = False,
1909
+ print_stderr: bool = False,
1910
+ return_: Literal[True],
1911
+ return_stdout: bool = False,
1912
+ return_stderr: bool = False,
1913
+ retry: Retry | None = None,
1914
+ logger: LoggerLike | None = None,
1915
+ ) -> str: ...
1916
+ @overload
1917
+ def uv_run(
1918
+ module: str,
1919
+ /,
1920
+ *args: str,
1921
+ extra: MaybeSequenceStr | None = None,
1922
+ all_extras: bool = False,
1923
+ group: MaybeSequenceStr | None = None,
1924
+ all_groups: bool = False,
1925
+ only_dev: bool = False,
1926
+ with_: MaybeSequenceStr | None = None,
1927
+ index: MaybeSequenceStr | None = None,
1928
+ native_tls: bool = False,
1929
+ env: StrStrMapping | None = None,
1930
+ cwd: PathLike | None = None,
1931
+ print: bool = False,
1932
+ print_stdout: bool = False,
1933
+ print_stderr: bool = False,
1934
+ return_: bool = False,
1935
+ return_stdout: Literal[True],
1936
+ return_stderr: bool = False,
1937
+ retry: Retry | None = None,
1938
+ logger: LoggerLike | None = None,
1939
+ ) -> str: ...
1940
+ @overload
1941
+ def uv_run(
1942
+ module: str,
1943
+ /,
1944
+ *args: str,
1945
+ extra: MaybeSequenceStr | None = None,
1946
+ all_extras: bool = False,
1947
+ group: MaybeSequenceStr | None = None,
1948
+ all_groups: bool = False,
1949
+ only_dev: bool = False,
1950
+ with_: MaybeSequenceStr | None = None,
1951
+ index: MaybeSequenceStr | None = None,
1952
+ native_tls: bool = False,
1953
+ env: StrStrMapping | None = None,
1954
+ cwd: PathLike | None = None,
1955
+ print: bool = False,
1956
+ print_stdout: bool = False,
1957
+ print_stderr: bool = False,
1958
+ return_: bool = False,
1959
+ return_stdout: bool = False,
1960
+ return_stderr: Literal[True],
1961
+ retry: Retry | None = None,
1962
+ logger: LoggerLike | None = None,
1963
+ ) -> str: ...
1964
+ @overload
1965
+ def uv_run(
1966
+ module: str,
1967
+ /,
1968
+ *args: str,
1969
+ extra: MaybeSequenceStr | None = None,
1970
+ all_extras: bool = False,
1971
+ group: MaybeSequenceStr | None = None,
1972
+ all_groups: bool = False,
1973
+ only_dev: bool = False,
1974
+ with_: MaybeSequenceStr | None = None,
1975
+ index: MaybeSequenceStr | None = None,
1976
+ native_tls: bool = False,
1977
+ env: StrStrMapping | None = None,
1978
+ cwd: PathLike | None = None,
1979
+ print: bool = False,
1980
+ print_stdout: bool = False,
1981
+ print_stderr: bool = False,
1982
+ return_: Literal[False] = False,
1983
+ return_stdout: Literal[False] = False,
1984
+ return_stderr: Literal[False] = False,
1985
+ retry: Retry | None = None,
1986
+ logger: LoggerLike | None = None,
1987
+ ) -> None: ...
1988
+ @overload
1989
+ def uv_run(
1990
+ module: str,
1991
+ /,
1992
+ *args: str,
1993
+ extra: MaybeSequenceStr | None = None,
1994
+ all_extras: bool = False,
1995
+ group: MaybeSequenceStr | None = None,
1996
+ all_groups: bool = False,
1997
+ only_dev: bool = False,
1998
+ with_: MaybeSequenceStr | None = None,
1999
+ index: MaybeSequenceStr | None = None,
2000
+ native_tls: bool = False,
2001
+ env: StrStrMapping | None = None,
2002
+ cwd: PathLike | None = None,
2003
+ print: bool = False,
2004
+ print_stdout: bool = False,
2005
+ print_stderr: bool = False,
2006
+ return_: bool = False,
2007
+ return_stdout: bool = False,
2008
+ return_stderr: bool = False,
2009
+ retry: Retry | None = None,
2010
+ logger: LoggerLike | None = None,
2011
+ ) -> str | None: ...
2012
+ def uv_run(
2013
+ module: str,
2014
+ /,
2015
+ *args: str,
2016
+ extra: MaybeSequenceStr | None = None,
2017
+ all_extras: bool = False,
2018
+ group: MaybeSequenceStr | None = None,
2019
+ all_groups: bool = False,
2020
+ only_dev: bool = False,
2021
+ with_: MaybeSequenceStr | None = None,
2022
+ index: MaybeSequenceStr | None = None,
2023
+ native_tls: bool = False,
2024
+ cwd: PathLike | None = None,
2025
+ env: StrStrMapping | None = None,
2026
+ print: bool = False, # noqa: A002
2027
+ print_stdout: bool = False,
2028
+ print_stderr: bool = False,
2029
+ return_: bool = False,
2030
+ return_stdout: bool = False,
2031
+ return_stderr: bool = False,
2032
+ retry: Retry | None = None,
2033
+ logger: LoggerLike | None = None,
2034
+ ) -> str | None:
2035
+ """Run a command or script."""
2036
+ return run( # pragma: no cover
2037
+ *uv_run_cmd(
2038
+ module,
2039
+ *args,
2040
+ extra=extra,
2041
+ all_extras=all_extras,
2042
+ group=group,
2043
+ all_groups=all_groups,
2044
+ only_dev=only_dev,
2045
+ with_=with_,
2046
+ index=index,
2047
+ native_tls=native_tls,
2048
+ ),
2049
+ cwd=cwd,
2050
+ env=env,
2051
+ print=print,
2052
+ print_stdout=print_stdout,
2053
+ print_stderr=print_stderr,
2054
+ return_=return_,
2055
+ return_stdout=return_stdout,
2056
+ return_stderr=return_stderr,
2057
+ retry=retry,
2058
+ logger=logger,
2059
+ )
2060
+
2061
+
2062
+ def uv_run_cmd(
2063
+ module: str,
2064
+ /,
2065
+ *args: str,
2066
+ extra: MaybeSequenceStr | None = None,
2067
+ all_extras: bool = False,
2068
+ group: MaybeSequenceStr | None = None,
2069
+ all_groups: bool = False,
2070
+ only_dev: bool = False,
2071
+ with_: MaybeSequenceStr | None = None,
2072
+ index: MaybeSequenceStr | None = None,
2073
+ native_tls: bool = False,
2074
+ ) -> list[str]:
2075
+ """Command to use 'uv' to run a command or script."""
2076
+ parts: list[str] = ["uv", "run"]
2077
+ if extra is not None:
2078
+ for extra_i in always_iterable(extra):
2079
+ parts.extend(["--extra", extra_i])
2080
+ if all_extras:
2081
+ parts.append("--all-extras")
2082
+ if not only_dev:
2083
+ parts.append("--no-dev")
2084
+ if group is not None:
2085
+ for group_i in always_iterable(group):
2086
+ parts.extend(["--group", group_i])
2087
+ if all_groups:
2088
+ parts.append("--all-groups")
2089
+ if only_dev:
2090
+ parts.append("--only-dev")
2091
+ return [
2092
+ *parts,
2093
+ "--exact",
2094
+ *uv_with_cmd(with_=with_),
2095
+ ISOLATED,
2096
+ *uv_index_cmd(index=index),
2097
+ *RESOLUTION_HIGHEST,
2098
+ *PRERELEASE_DISALLOW,
2099
+ "--reinstall",
2100
+ *uv_native_tls_cmd(native_tls=native_tls),
2101
+ MANAGED_PYTHON,
2102
+ "python",
2103
+ "-m",
2104
+ module,
2105
+ *args,
2106
+ ]
2107
+
2108
+
2109
+ ##
2110
+
2111
+
2112
+ @overload
2113
+ def uv_tool_install(
2114
+ package: str,
2115
+ /,
2116
+ *,
2117
+ with_: MaybeSequenceStr | None = None,
2118
+ index: MaybeSequenceStr | None = None,
2119
+ native_tls: bool = False,
2120
+ cwd: PathLike | None = None,
2121
+ env: StrStrMapping | None = None,
2122
+ print: bool = False,
2123
+ print_stdout: bool = False,
2124
+ print_stderr: bool = False,
2125
+ return_: Literal[True],
2126
+ return_stdout: bool = False,
2127
+ return_stderr: bool = False,
2128
+ retry: Retry | None = None,
2129
+ logger: LoggerLike | None = None,
2130
+ ) -> str: ...
2131
+ @overload
2132
+ def uv_tool_install(
2133
+ package: str,
2134
+ /,
2135
+ *,
2136
+ with_: MaybeSequenceStr | None = None,
2137
+ index: MaybeSequenceStr | None = None,
2138
+ native_tls: bool = False,
2139
+ cwd: PathLike | None = None,
2140
+ env: StrStrMapping | None = None,
2141
+ print: bool = False,
2142
+ print_stdout: bool = False,
2143
+ print_stderr: bool = False,
2144
+ return_: bool = False,
2145
+ return_stdout: Literal[True],
2146
+ return_stderr: bool = False,
2147
+ retry: Retry | None = None,
2148
+ logger: LoggerLike | None = None,
2149
+ ) -> str: ...
2150
+ @overload
2151
+ def uv_tool_install(
2152
+ package: str,
2153
+ /,
2154
+ *,
2155
+ with_: MaybeSequenceStr | None = None,
2156
+ index: MaybeSequenceStr | None = None,
2157
+ native_tls: bool = False,
2158
+ cwd: PathLike | None = None,
2159
+ env: StrStrMapping | None = None,
2160
+ print: bool = False,
2161
+ print_stdout: bool = False,
2162
+ print_stderr: bool = False,
2163
+ return_: bool = False,
2164
+ return_stdout: bool = False,
2165
+ return_stderr: Literal[True],
2166
+ retry: Retry | None = None,
2167
+ logger: LoggerLike | None = None,
2168
+ ) -> str: ...
2169
+ @overload
2170
+ def uv_tool_install(
2171
+ package: str,
2172
+ /,
2173
+ *,
2174
+ with_: MaybeSequenceStr | None = None,
2175
+ index: MaybeSequenceStr | None = None,
2176
+ native_tls: bool = False,
2177
+ cwd: PathLike | None = None,
2178
+ env: StrStrMapping | None = None,
2179
+ print: bool = False,
2180
+ print_stdout: bool = False,
2181
+ print_stderr: bool = False,
2182
+ return_: Literal[False] = False,
2183
+ return_stdout: Literal[False] = False,
2184
+ return_stderr: Literal[False] = False,
2185
+ retry: Retry | None = None,
2186
+ logger: LoggerLike | None = None,
2187
+ ) -> None: ...
2188
+ @overload
2189
+ def uv_tool_install(
2190
+ package: str,
2191
+ /,
2192
+ *,
2193
+ with_: MaybeSequenceStr | None = None,
2194
+ index: MaybeSequenceStr | None = None,
2195
+ native_tls: bool = False,
2196
+ cwd: PathLike | None = None,
2197
+ env: StrStrMapping | None = None,
2198
+ print: bool = False,
2199
+ print_stdout: bool = False,
2200
+ print_stderr: bool = False,
2201
+ return_: bool = False,
2202
+ return_stdout: bool = False,
2203
+ return_stderr: bool = False,
2204
+ retry: Retry | None = None,
2205
+ logger: LoggerLike | None = None,
2206
+ ) -> str | None: ...
2207
+ def uv_tool_install(
2208
+ package: str,
2209
+ /,
2210
+ *,
2211
+ with_: MaybeSequenceStr | None = None,
2212
+ index: MaybeSequenceStr | None = None,
2213
+ native_tls: bool = False,
2214
+ cwd: PathLike | None = None,
2215
+ env: StrStrMapping | None = None,
2216
+ print: bool = False, # noqa: A002
2217
+ print_stdout: bool = False,
2218
+ print_stderr: bool = False,
2219
+ return_: bool = False,
2220
+ return_stdout: bool = False,
2221
+ return_stderr: bool = False,
2222
+ retry: Retry | None = None,
2223
+ logger: LoggerLike | None = None,
2224
+ ) -> str | None:
2225
+ """Install commands provided by a Python package."""
2226
+ return run( # pragma: no cover
2227
+ *uv_tool_install_cmd(package, with_=with_, index=index, native_tls=native_tls),
2228
+ cwd=cwd,
2229
+ env=env,
2230
+ print=print,
2231
+ print_stdout=print_stdout,
2232
+ print_stderr=print_stderr,
2233
+ return_=return_,
2234
+ return_stdout=return_stdout,
2235
+ return_stderr=return_stderr,
2236
+ retry=retry,
2237
+ logger=logger,
2238
+ )
2239
+
2240
+
2241
+ def uv_tool_install_cmd(
2242
+ package: str,
2243
+ /,
2244
+ *,
2245
+ with_: MaybeSequenceStr | None = None,
2246
+ index: MaybeSequenceStr | None = None,
2247
+ native_tls: bool = False,
2248
+ ) -> list[str]:
2249
+ """Command to use 'uv' to install commands provided by a Python package."""
2250
+ return [
2251
+ "uv",
2252
+ "tool",
2253
+ "install",
2254
+ *uv_with_cmd(with_=with_),
2255
+ *uv_index_cmd(index=index),
2256
+ *RESOLUTION_HIGHEST,
2257
+ *PRERELEASE_DISALLOW,
2258
+ "--reinstall",
2259
+ MANAGED_PYTHON,
2260
+ *uv_native_tls_cmd(native_tls=native_tls),
2261
+ package,
2262
+ ]
2263
+
2264
+
2265
+ ##
2266
+
2267
+
2268
+ @overload
2269
+ def uv_tool_run(
2270
+ command: str,
2271
+ /,
2272
+ *args: str,
2273
+ from_: str | None = None,
2274
+ latest: bool = True,
2275
+ with_: MaybeSequenceStr | None = None,
2276
+ index: MaybeSequenceStr | None = None,
2277
+ native_tls: bool = False,
2278
+ cwd: PathLike | None = None,
2279
+ env: StrStrMapping | None = None,
2280
+ print: bool = False,
2281
+ print_stdout: bool = False,
2282
+ print_stderr: bool = False,
2283
+ return_: Literal[True],
2284
+ return_stdout: bool = False,
2285
+ return_stderr: bool = False,
2286
+ retry: Retry | None = None,
2287
+ logger: LoggerLike | None = None,
2288
+ ) -> str: ...
2289
+ @overload
2290
+ def uv_tool_run(
2291
+ command: str,
2292
+ /,
2293
+ *args: str,
2294
+ from_: str | None = None,
2295
+ latest: bool = True,
2296
+ with_: MaybeSequenceStr | None = None,
2297
+ index: MaybeSequenceStr | None = None,
2298
+ native_tls: bool = False,
2299
+ cwd: PathLike | None = None,
2300
+ env: StrStrMapping | None = None,
2301
+ print: bool = False,
2302
+ print_stdout: bool = False,
2303
+ print_stderr: bool = False,
2304
+ return_: bool = False,
2305
+ return_stdout: Literal[True],
2306
+ return_stderr: bool = False,
2307
+ retry: Retry | None = None,
2308
+ logger: LoggerLike | None = None,
2309
+ ) -> str: ...
2310
+ @overload
2311
+ def uv_tool_run(
2312
+ command: str,
2313
+ /,
2314
+ *args: str,
2315
+ from_: str | None = None,
2316
+ latest: bool = True,
2317
+ with_: MaybeSequenceStr | None = None,
2318
+ index: MaybeSequenceStr | None = None,
2319
+ native_tls: bool = False,
2320
+ cwd: PathLike | None = None,
2321
+ env: StrStrMapping | None = None,
2322
+ print: bool = False,
2323
+ print_stdout: bool = False,
2324
+ print_stderr: bool = False,
2325
+ return_: bool = False,
2326
+ return_stdout: bool = False,
2327
+ return_stderr: Literal[True],
2328
+ retry: Retry | None = None,
2329
+ logger: LoggerLike | None = None,
2330
+ ) -> str: ...
2331
+ @overload
2332
+ def uv_tool_run(
2333
+ command: str,
2334
+ /,
2335
+ *,
2336
+ from_: str | None = None,
2337
+ latest: bool = True,
2338
+ with_: MaybeSequenceStr | None = None,
2339
+ index: MaybeSequenceStr | None = None,
2340
+ native_tls: bool = False,
2341
+ cwd: PathLike | None = None,
2342
+ env: StrStrMapping | None = None,
2343
+ print: bool = False,
2344
+ print_stdout: bool = False,
2345
+ print_stderr: bool = False,
2346
+ return_: Literal[False] = False,
2347
+ return_stdout: Literal[False] = False,
2348
+ return_stderr: Literal[False] = False,
2349
+ retry: Retry | None = None,
2350
+ logger: LoggerLike | None = None,
2351
+ ) -> None: ...
2352
+ @overload
2353
+ def uv_tool_run(
2354
+ command: str,
2355
+ /,
2356
+ *args: str,
2357
+ from_: str | None = None,
2358
+ latest: bool = True,
2359
+ with_: MaybeSequenceStr | None = None,
2360
+ index: MaybeSequenceStr | None = None,
2361
+ native_tls: bool = False,
2362
+ cwd: PathLike | None = None,
2363
+ env: StrStrMapping | None = None,
2364
+ print: bool = False,
2365
+ print_stdout: bool = False,
2366
+ print_stderr: bool = False,
2367
+ return_: bool = False,
2368
+ return_stdout: bool = False,
2369
+ return_stderr: bool = False,
2370
+ retry: Retry | None = None,
2371
+ logger: LoggerLike | None = None,
2372
+ ) -> str | None: ...
2373
+ def uv_tool_run(
2374
+ command: str,
2375
+ /,
2376
+ *args: str,
2377
+ from_: str | None = None,
2378
+ latest: bool = True,
2379
+ with_: MaybeSequenceStr | None = None,
2380
+ index: MaybeSequenceStr | None = None,
2381
+ native_tls: bool = False,
2382
+ cwd: PathLike | None = None,
2383
+ env: StrStrMapping | None = None,
2384
+ print: bool = False, # noqa: A002
2385
+ print_stdout: bool = False,
2386
+ print_stderr: bool = False,
2387
+ return_: bool = False,
2388
+ return_stdout: bool = False,
2389
+ return_stderr: bool = False,
2390
+ retry: Retry | None = None,
2391
+ logger: LoggerLike | None = None,
2392
+ ) -> str | None:
2393
+ """Run a command provided by a Python package."""
2394
+ return run( # pragma: no cover
2395
+ *uv_tool_run_cmd(
2396
+ command,
2397
+ *args,
2398
+ from_=from_,
2399
+ latest=latest,
2400
+ with_=with_,
2401
+ index=index,
2402
+ native_tls=native_tls,
2403
+ ),
2404
+ cwd=cwd,
2405
+ env=env,
2406
+ print=print,
2407
+ print_stdout=print_stdout,
2408
+ print_stderr=print_stderr,
2409
+ return_=return_,
2410
+ return_stdout=return_stdout,
2411
+ return_stderr=return_stderr,
2412
+ retry=retry,
2413
+ logger=logger,
2414
+ )
2415
+
2416
+
2417
+ def uv_tool_run_cmd(
2418
+ command: str,
2419
+ /,
2420
+ *args: str,
2421
+ from_: str | None = None,
2422
+ latest: bool = True,
2423
+ with_: MaybeSequenceStr | None = None,
2424
+ index: MaybeSequenceStr | None = None,
2425
+ native_tls: bool = False,
2426
+ ) -> list[str]:
2427
+ """Command to use 'uv' to run a command provided by a Python package."""
2428
+ parts: list[str] = ["uv", "tool", "run"]
2429
+ if from_ is not None:
2430
+ from_use = f"{from_}@latest" if latest else from_
2431
+ parts.extend(["--from", from_use])
2432
+ return [
2433
+ *parts,
2434
+ *uv_with_cmd(with_=with_),
2435
+ ISOLATED,
2436
+ *uv_index_cmd(index=index),
2437
+ *RESOLUTION_HIGHEST,
2438
+ *PRERELEASE_DISALLOW,
2439
+ MANAGED_PYTHON,
2440
+ *uv_native_tls_cmd(native_tls=native_tls),
2441
+ command,
2442
+ *args,
2443
+ ]
2444
+
2445
+
2446
+ ##
2447
+
2448
+
2449
+ def uv_with_cmd(*, with_: MaybeSequenceStr | None = None) -> list[str]:
2450
+ """Generate the `--with` commands if necessary."""
2451
+ return (
2452
+ []
2453
+ if with_ is None
2454
+ else list(chain.from_iterable(["--with", w] for w in always_iterable(with_)))
2455
+ )
2456
+
2457
+
2458
+ ##
2459
+
2460
+
2461
+ @enhanced_context_manager
2462
+ def yield_git_repo(url: str, /, *, branch: str | None = None) -> Iterator[Path]:
2463
+ """Yield a temporary git repository."""
2464
+ with TemporaryDirectory() as temp_dir:
2465
+ git_clone(url, temp_dir, branch=branch)
2466
+ yield temp_dir
2467
+
2468
+
2469
+ ##
2470
+
2471
+
2472
+ @enhanced_context_manager
2473
+ def yield_ssh_temp_dir(
2474
+ user: str,
2475
+ hostname: str,
2476
+ /,
2477
+ *,
2478
+ retry: Retry | None = None,
2479
+ logger: LoggerLike | None = None,
2480
+ keep: bool = False,
2481
+ ) -> Iterator[Path]:
2482
+ """Yield a temporary directory on a remote machine."""
2483
+ path = Path( # skipif-ci
2484
+ ssh(user, hostname, *MKTEMP_DIR_CMD, return_=True, retry=retry, logger=logger)
2485
+ )
2486
+ try: # skipif-ci
2487
+ yield path
2488
+ finally: # skipif-ci
2489
+ if keep:
2490
+ if logger is not None:
2491
+ to_logger(logger).info("Keeping temporary directory '%s'...", path)
2492
+ else:
2493
+ ssh(user, hostname, *rm_cmd(path), retry=retry, logger=logger)
2494
+
2495
+
2496
+ __all__ = [
2497
+ "APT_UPDATE",
2498
+ "BASH_LC",
2499
+ "BASH_LS",
2500
+ "CHPASSWD",
2501
+ "GIT_BRANCH_SHOW_CURRENT",
2502
+ "ISOLATED",
2503
+ "MANAGED_PYTHON",
2504
+ "MKTEMP_DIR_CMD",
2505
+ "PRERELEASE_DISALLOW",
2506
+ "RESOLUTION_HIGHEST",
2507
+ "RESTART_SSHD",
2508
+ "UPDATE_CA_CERTIFICATES",
2509
+ "ChownCmdError",
2510
+ "CpError",
2511
+ "MvFileError",
2512
+ "RsyncCmdError",
2513
+ "UvPipListError",
2514
+ "append_text",
2515
+ "apt_install",
2516
+ "apt_install_cmd",
2517
+ "apt_remove",
2518
+ "apt_remove_cmd",
2519
+ "apt_update",
2520
+ "cat",
2521
+ "cd_cmd",
2522
+ "chattr",
2523
+ "chattr_cmd",
2524
+ "chmod",
2525
+ "chmod_cmd",
2526
+ "chown",
2527
+ "chown_cmd",
2528
+ "chpasswd",
2529
+ "copy_text",
2530
+ "cp",
2531
+ "cp_cmd",
2532
+ "curl",
2533
+ "curl_cmd",
2534
+ "echo_cmd",
2535
+ "env_cmds",
2536
+ "expand_path",
2537
+ "git_branch_current",
2538
+ "git_checkout",
2539
+ "git_checkout_cmd",
2540
+ "git_clone",
2541
+ "git_clone_cmd",
2542
+ "install",
2543
+ "install_cmd",
2544
+ "maybe_parent",
2545
+ "maybe_sudo_cmd",
2546
+ "mkdir",
2547
+ "mkdir_cmd",
2548
+ "mv",
2549
+ "mv_cmd",
2550
+ "replace_text",
2551
+ "ripgrep",
2552
+ "ripgrep_cmd",
2553
+ "rm",
2554
+ "rm_cmd",
2555
+ "rsync",
2556
+ "rsync_cmd",
2557
+ "rsync_many",
2558
+ "run",
2559
+ "set_hostname_cmd",
2560
+ "ssh",
2561
+ "ssh_await",
2562
+ "ssh_cmd",
2563
+ "ssh_keygen_remove",
2564
+ "ssh_keygen_remove_cmd",
2565
+ "ssh_keyscan",
2566
+ "ssh_keyscan_cmd",
2567
+ "ssh_opts_cmd",
2568
+ "sudo_cmd",
2569
+ "sudo_nopasswd_cmd",
2570
+ "symlink",
2571
+ "symlink_cmd",
2572
+ "tee_cmd",
2573
+ "touch",
2574
+ "touch_cmd",
2575
+ "update_ca_certificates",
2576
+ "useradd",
2577
+ "useradd_cmd",
2578
+ "uv_native_tls_cmd",
2579
+ "uv_pip_list",
2580
+ "uv_pip_list_cmd",
2581
+ "uv_run",
2582
+ "uv_run_cmd",
2583
+ "uv_tool_install",
2584
+ "uv_tool_install_cmd",
2585
+ "uv_tool_run",
2586
+ "uv_tool_run_cmd",
2587
+ "uv_with_cmd",
2588
+ "yield_git_repo",
2589
+ "yield_ssh_temp_dir",
2590
+ ]