dycw-utilities 0.166.30__py3-none-any.whl → 0.175.17__py3-none-any.whl

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