dycw-utilities 0.175.17__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 (94) 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.175.17.dist-info → dycw_utilities-0.185.8.dist-info}/WHEEL +2 -2
  4. utilities/__init__.py +1 -1
  5. utilities/altair.py +8 -6
  6. utilities/asyncio.py +40 -56
  7. utilities/atools.py +9 -11
  8. utilities/cachetools.py +8 -6
  9. utilities/click.py +4 -3
  10. utilities/concurrent.py +1 -1
  11. utilities/constants.py +492 -0
  12. utilities/contextlib.py +23 -30
  13. utilities/contextvars.py +1 -23
  14. utilities/core.py +2581 -0
  15. utilities/dataclasses.py +16 -119
  16. utilities/docker.py +139 -45
  17. utilities/enum.py +1 -1
  18. utilities/errors.py +2 -16
  19. utilities/fastapi.py +5 -5
  20. utilities/fpdf2.py +2 -1
  21. utilities/functions.py +33 -264
  22. utilities/http.py +2 -3
  23. utilities/hypothesis.py +48 -25
  24. utilities/iterables.py +39 -575
  25. utilities/jinja2.py +3 -6
  26. utilities/jupyter.py +5 -3
  27. utilities/libcst.py +1 -1
  28. utilities/lightweight_charts.py +4 -6
  29. utilities/logging.py +17 -15
  30. utilities/math.py +1 -36
  31. utilities/more_itertools.py +4 -6
  32. utilities/numpy.py +2 -1
  33. utilities/operator.py +2 -2
  34. utilities/orjson.py +24 -25
  35. utilities/os.py +4 -185
  36. utilities/packaging.py +129 -0
  37. utilities/parse.py +33 -13
  38. utilities/pathlib.py +2 -136
  39. utilities/platform.py +8 -90
  40. utilities/polars.py +34 -31
  41. utilities/postgres.py +9 -4
  42. utilities/pottery.py +20 -18
  43. utilities/pqdm.py +3 -4
  44. utilities/psutil.py +2 -3
  45. utilities/pydantic.py +18 -4
  46. utilities/pydantic_settings.py +7 -9
  47. utilities/pydantic_settings_sops.py +3 -3
  48. utilities/pyinstrument.py +4 -4
  49. utilities/pytest.py +49 -108
  50. utilities/pytest_plugins/pytest_regressions.py +2 -2
  51. utilities/pytest_regressions.py +8 -6
  52. utilities/random.py +2 -8
  53. utilities/redis.py +98 -94
  54. utilities/reprlib.py +11 -118
  55. utilities/shellingham.py +66 -0
  56. utilities/slack_sdk.py +13 -12
  57. utilities/sqlalchemy.py +42 -30
  58. utilities/sqlalchemy_polars.py +16 -25
  59. utilities/subprocess.py +1166 -148
  60. utilities/tabulate.py +32 -0
  61. utilities/testbook.py +8 -8
  62. utilities/text.py +24 -115
  63. utilities/throttle.py +159 -0
  64. utilities/time.py +18 -0
  65. utilities/timer.py +29 -12
  66. utilities/traceback.py +15 -22
  67. utilities/types.py +38 -3
  68. utilities/typing.py +18 -12
  69. utilities/uuid.py +1 -1
  70. utilities/version.py +202 -45
  71. utilities/whenever.py +22 -150
  72. dycw_utilities-0.175.17.dist-info/METADATA +0 -34
  73. dycw_utilities-0.175.17.dist-info/RECORD +0 -103
  74. utilities/atomicwrites.py +0 -182
  75. utilities/cryptography.py +0 -41
  76. utilities/getpass.py +0 -8
  77. utilities/git.py +0 -19
  78. utilities/grp.py +0 -28
  79. utilities/gzip.py +0 -31
  80. utilities/json.py +0 -70
  81. utilities/permissions.py +0 -298
  82. utilities/pickle.py +0 -25
  83. utilities/pwd.py +0 -28
  84. utilities/re.py +0 -156
  85. utilities/sentinel.py +0 -73
  86. utilities/socket.py +0 -8
  87. utilities/string.py +0 -20
  88. utilities/tempfile.py +0 -136
  89. utilities/tzdata.py +0 -11
  90. utilities/tzlocal.py +0 -28
  91. utilities/warnings.py +0 -65
  92. utilities/zipfile.py +0 -25
  93. utilities/zoneinfo.py +0 -133
  94. {dycw_utilities-0.175.17.dist-info → dycw_utilities-0.185.8.dist-info}/entry_points.txt +0 -0
utilities/subprocess.py CHANGED
@@ -1,37 +1,56 @@
1
1
  from __future__ import annotations
2
2
 
3
- import shutil
3
+ import json
4
4
  import sys
5
- from contextlib import contextmanager
6
5
  from dataclasses import dataclass
7
6
  from io import StringIO
7
+ from itertools import chain, repeat
8
+ from json import JSONDecodeError
8
9
  from pathlib import Path
9
- from re import search
10
+ from re import MULTILINE, escape, search
10
11
  from shlex import join
11
- from shutil import copyfile, copytree, move, rmtree
12
+ from shutil import rmtree
12
13
  from string import Template
13
14
  from subprocess import PIPE, CalledProcessError, Popen
14
15
  from threading import Thread
15
- from time import sleep
16
16
  from typing import IO, TYPE_CHECKING, Literal, assert_never, overload, override
17
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
+ )
18
34
  from utilities.errors import ImpossibleCaseError
19
- from utilities.iterables import always_iterable
35
+ from utilities.functions import in_timedelta
20
36
  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
37
+ from utilities.time import sleep
38
+ from utilities.version import (
39
+ ParseVersion2Or3Error,
40
+ Version2,
41
+ Version3,
42
+ parse_version_2_or_3,
43
+ )
26
44
 
27
45
  if TYPE_CHECKING:
28
- from collections.abc import Callable, Iterator
46
+ from collections.abc import Callable, Iterable, Iterator
29
47
 
30
- from utilities.permissions import PermissionsLike
48
+ from utilities.core import PermissionsLike
31
49
  from utilities.types import (
32
- Delta,
50
+ Duration,
33
51
  LoggerLike,
34
52
  MaybeIterable,
53
+ MaybeSequenceStr,
35
54
  PathLike,
36
55
  Retry,
37
56
  StrMapping,
@@ -45,8 +64,12 @@ BASH_LC = ["bash", "-lc"]
45
64
  BASH_LS = ["bash", "-ls"]
46
65
  CHPASSWD = "chpasswd"
47
66
  GIT_BRANCH_SHOW_CURRENT = ["git", "branch", "--show-current"]
48
- KNOWN_HOSTS = Path.home() / ".ssh/known_hosts"
67
+ ISOLATED = "--isolated"
68
+ KNOWN_HOSTS = HOME / ".ssh/known_hosts"
69
+ MANAGED_PYTHON = "--managed-python"
49
70
  MKTEMP_DIR_CMD = ["mktemp", "-d"]
71
+ PRERELEASE_DISALLOW = ["--prerelease", "disallow"]
72
+ RESOLUTION_HIGHEST = ["--resolution", "highest"]
50
73
  RESTART_SSHD = ["systemctl", "restart", "sshd"]
51
74
  UPDATE_CA_CERTIFICATES: str = "update-ca-certificates"
52
75
 
@@ -54,24 +77,89 @@ UPDATE_CA_CERTIFICATES: str = "update-ca-certificates"
54
77
  ##
55
78
 
56
79
 
57
- def apt_install(package: str, /, *, update: bool = False, sudo: bool = False) -> None:
58
- """Install a package."""
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."""
59
112
  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))
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
+
62
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]
63
123
 
64
- def apt_install_cmd(package: str, /) -> list[str]:
65
- """Command to use 'apt' to install a package."""
66
- return ["apt", "install", "-y", package]
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
67
147
 
68
148
 
69
149
  ##
70
150
 
71
151
 
72
- def cat_cmd(path: PathLike, /) -> list[str]:
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]:
73
161
  """Command to use 'cat' to concatenate and print files."""
74
- return ["cat", str(path)]
162
+ return ["cat", str(path), *map(str, paths)]
75
163
 
76
164
 
77
165
  ##
@@ -85,17 +173,40 @@ def cd_cmd(path: PathLike, /) -> list[str]:
85
173
  ##
86
174
 
87
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
+
88
199
  def chmod(path: PathLike, perms: PermissionsLike, /, *, sudo: bool = False) -> None:
89
200
  """Change file mode."""
90
201
  if sudo: # pragma: no cover
91
202
  run(*sudo_cmd(*chmod_cmd(path, perms)))
92
- else:
93
- Path(path).chmod(int(ensure_perms(perms)))
203
+ else: # pragma: no cover
204
+ utilities.core.chmod(path, perms)
94
205
 
95
206
 
96
207
  def chmod_cmd(path: PathLike, perms: PermissionsLike, /) -> list[str]:
97
208
  """Command to use 'chmod' to change file mode."""
98
- return ["chmod", str(ensure_perms(perms)), str(path)]
209
+ return ["chmod", str(Permissions.new(perms)), str(path)]
99
210
 
100
211
 
101
212
  ##
@@ -106,36 +217,33 @@ def chown(
106
217
  /,
107
218
  *,
108
219
  sudo: bool = False,
220
+ recursive: bool = False,
109
221
  user: str | int | None = None,
110
222
  group: str | int | None = None,
111
223
  ) -> None:
112
224
  """Change file owner and/or group."""
113
225
  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)
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)
133
233
 
134
234
 
135
235
  def chown_cmd(
136
- path: PathLike, /, *, user: str | int | None = None, group: str | int | None = None
236
+ path: PathLike,
237
+ /,
238
+ *,
239
+ recursive: bool = False,
240
+ user: str | int | None = None,
241
+ group: str | int | None = None,
137
242
  ) -> list[str]:
138
243
  """Command to use 'chown' to change file owner and/or group."""
244
+ args: list[str] = ["chown"]
245
+ if recursive:
246
+ args.append("-R")
139
247
  match user, group:
140
248
  case None, None:
141
249
  raise ChownCmdError
@@ -147,7 +255,7 @@ def chown_cmd(
147
255
  ownership = f"{user}:{group}"
148
256
  case never:
149
257
  assert_never(never)
150
- return ["chown", ownership, str(path)]
258
+ return [*args, ownership, str(path)]
151
259
 
152
260
 
153
261
  @dataclass(kw_only=True, slots=True)
@@ -162,9 +270,26 @@ class ChownCmdError(Exception):
162
270
 
163
271
  def chpasswd(user_name: str, password: str, /, *, sudo: bool = False) -> None:
164
272
  """Update passwords."""
165
- run( # pragma: no cover
166
- *maybe_sudo_cmd(CHPASSWD, sudo=sudo), input=f"{user_name}:{password}"
167
- )
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)
168
293
 
169
294
 
170
295
  ##
@@ -184,28 +309,24 @@ def cp(
184
309
  mkdir(dest, sudo=sudo, parent=True)
185
310
  if sudo: # pragma: no cover
186
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)
187
316
  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)
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
199
321
 
200
322
 
201
323
  @dataclass(kw_only=True, slots=True)
202
324
  class CpError(Exception):
203
325
  src: Path
204
- dest: Path
205
326
 
206
327
  @override
207
328
  def __str__(self) -> str:
208
- return f"Unable to copy {str(self.src)!r} to {str(self.dest)!r}; source does not exist"
329
+ return f"Source {repr_str(self.src)} does not exist"
209
330
 
210
331
 
211
332
  def cp_cmd(src: PathLike, dest: PathLike, /) -> list[str]:
@@ -216,6 +337,185 @@ def cp_cmd(src: PathLike, dest: PathLike, /) -> list[str]:
216
337
  ##
217
338
 
218
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
+
219
519
  def echo_cmd(text: str, /) -> list[str]:
220
520
  """Command to use 'echo' to write arguments to the standard output."""
221
521
  return ["echo", text]
@@ -284,6 +584,53 @@ def git_clone_cmd(url: str, path: PathLike, /) -> list[str]:
284
584
  ##
285
585
 
286
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
+
287
634
  def maybe_parent(path: PathLike, /, *, parent: bool = False) -> Path:
288
635
  """Get the parent of a path, if required."""
289
636
  path = Path(path)
@@ -326,26 +673,24 @@ def mv(
326
673
  mkdir(dest, sudo=sudo, parent=True)
327
674
  if sudo: # pragma: no cover
328
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)
329
680
  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)
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
339
685
 
340
686
 
341
687
  @dataclass(kw_only=True, slots=True)
342
688
  class MvFileError(Exception):
343
689
  src: Path
344
- dest: Path
345
690
 
346
691
  @override
347
692
  def __str__(self) -> str:
348
- return f"Unable to move {str(self.src)!r} to {str(self.dest)!r}; source does not exist"
693
+ return f"Source {repr_str(self.src)} does not exist"
349
694
 
350
695
 
351
696
  def mv_cmd(src: PathLike, dest: PathLike, /) -> list[str]:
@@ -356,6 +701,20 @@ def mv_cmd(src: PathLike, dest: PathLike, /) -> list[str]:
356
701
  ##
357
702
 
358
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
+
359
718
  def ripgrep(*args: str, path: PathLike = PWD) -> str | None:
360
719
  """Search for lines."""
361
720
  try: # skipif-ci
@@ -379,11 +738,17 @@ def rm(path: PathLike, /, *paths: PathLike, sudo: bool = False) -> None:
379
738
  if sudo: # pragma: no cover
380
739
  run(*sudo_cmd(*rm_cmd(path, *paths)))
381
740
  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)
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)
387
752
 
388
753
 
389
754
  def rm_cmd(path: PathLike, /, *paths: PathLike) -> list[str]:
@@ -503,10 +868,10 @@ def rsync_cmd(
503
868
  args.extend(["--rsync-path", join(sudo_cmd("rsync"))])
504
869
  srcs = list(always_iterable(src_or_srcs)) # do not Path()
505
870
  if len(srcs) == 0:
506
- raise RsyncCmdNoSourcesError(user=user, hostname=hostname, dest=dest)
871
+ raise _RsyncCmdNoSourcesError(user=user, hostname=hostname, dest=dest)
507
872
  missing = [s for s in srcs if not Path(s).exists()]
508
873
  if len(missing) >= 1:
509
- raise RsyncCmdSourcesNotFoundError(
874
+ raise _RsyncCmdSourcesNotFoundError(
510
875
  sources=missing, user=user, hostname=hostname, dest=dest
511
876
  )
512
877
  return [*args, *map(str, srcs), f"{user}@{hostname}:{dest}"]
@@ -518,20 +883,16 @@ class RsyncCmdError(Exception):
518
883
  hostname: str
519
884
  dest: PathLike
520
885
 
521
- @override
522
- def __str__(self) -> str:
523
- return f"No sources selected to send to {self.user}@{self.hostname}:{self.dest}"
524
-
525
886
 
526
887
  @dataclass(kw_only=True, slots=True)
527
- class RsyncCmdNoSourcesError(RsyncCmdError):
888
+ class _RsyncCmdNoSourcesError(RsyncCmdError):
528
889
  @override
529
890
  def __str__(self) -> str:
530
891
  return f"No sources selected to send to {self.user}@{self.hostname}:{self.dest}"
531
892
 
532
893
 
533
894
  @dataclass(kw_only=True, slots=True)
534
- class RsyncCmdSourcesNotFoundError(RsyncCmdError):
895
+ class _RsyncCmdSourcesNotFoundError(RsyncCmdError):
535
896
  sources: list[PathLike]
536
897
 
537
898
  @override
@@ -549,7 +910,8 @@ def rsync_many(
549
910
  /,
550
911
  *items: tuple[PathLike, PathLike]
551
912
  | tuple[Literal["sudo"], PathLike, PathLike]
552
- | tuple[PathLike, PathLike, PermissionsLike],
913
+ | tuple[PathLike, PathLike, PermissionsLike]
914
+ | tuple[Literal["sudo"], PathLike, PathLike, PermissionsLike],
553
915
  retry: Retry | None = None,
554
916
  logger: LoggerLike | None = None,
555
917
  keep: bool = False,
@@ -570,7 +932,11 @@ def rsync_many(
570
932
  match item:
571
933
  case Path() | str() as src, Path() | str() as dest:
572
934
  cmds.extend(_rsync_many_prepare(src, dest, temp_src, temp_dest))
573
- case "sudo", Path() | str() as src, Path() | str() as dest:
935
+ case ( # pragma: no cover
936
+ "sudo",
937
+ Path() | str() as src,
938
+ Path() | str() as dest,
939
+ ):
574
940
  cmds.extend(
575
941
  _rsync_many_prepare(src, dest, temp_src, temp_dest, sudo=True)
576
942
  )
@@ -582,6 +948,17 @@ def rsync_many(
582
948
  cmds.extend(
583
949
  _rsync_many_prepare(src, dest, temp_src, temp_dest, perms=perms)
584
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
+ )
585
962
  case never:
586
963
  assert_never(never)
587
964
  rsync(
@@ -625,13 +1002,19 @@ def _rsync_many_prepare(
625
1002
  case Path():
626
1003
  cp(src, temp_src / name)
627
1004
  case str():
628
- if Path(src).exists():
629
- cp(src, temp_src / name)
630
- else:
1005
+ try:
1006
+ exists = Path(src).exists()
1007
+ except OSError:
631
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)
632
1014
  case never:
633
1015
  assert_never(never)
634
1016
  cmds: list[list[str]] = [
1017
+ maybe_sudo_cmd(*rm_cmd(dest), sudo=sudo),
635
1018
  maybe_sudo_cmd(*mkdir_cmd(dest, parent=True), sudo=sudo),
636
1019
  maybe_sudo_cmd(*cp_cmd(temp_dest / name, dest), sudo=sudo),
637
1020
  ]
@@ -832,11 +1215,11 @@ def run(
832
1215
  and (retry_skip is not None)
833
1216
  and retry_skip(return_code, stdout_text, stderr_text)
834
1217
  ):
835
- attempts = delta = None
1218
+ attempts = duration = None
836
1219
  else:
837
- attempts, delta = retry
1220
+ attempts, duration = retry
838
1221
  if logger is not None:
839
- msg = strip_and_dedent(f"""
1222
+ msg = normalize_multi_line_str(f"""
840
1223
  'run' failed with:
841
1224
  - cmd = {cmd}
842
1225
  - cmds_or_args = {cmds_or_args}
@@ -854,18 +1237,18 @@ def run(
854
1237
  {stderr_text}-------------------------------------------------------------------------------
855
1238
  """)
856
1239
  if (attempts is not None) and (attempts >= 1):
857
- if delta is None:
1240
+ if duration is None:
858
1241
  msg = f"{msg}\n\nRetrying {attempts} more time(s)..."
859
1242
  else:
860
- msg = f"{msg}\n\nRetrying {attempts} more time(s) after {delta}..."
1243
+ msg = f"{msg}\n\nRetrying {attempts} more time(s) after {in_timedelta(duration)}..."
861
1244
  to_logger(logger).error(msg)
862
1245
  error = CalledProcessError(
863
1246
  return_code, args, output=stdout_text, stderr=stderr_text
864
1247
  )
865
1248
  if (attempts is None) or (attempts <= 0):
866
1249
  raise error
867
- if delta is not None:
868
- sleep(to_seconds(delta))
1250
+ if duration is not None:
1251
+ sleep(duration)
869
1252
  return run(
870
1253
  cmd,
871
1254
  *cmds_or_args,
@@ -881,14 +1264,14 @@ def run(
881
1264
  return_=return_,
882
1265
  return_stdout=return_stdout,
883
1266
  return_stderr=return_stderr,
884
- retry=(attempts - 1, delta),
1267
+ retry=(attempts - 1, duration),
885
1268
  logger=logger,
886
1269
  )
887
1270
  case never:
888
1271
  assert_never(never)
889
1272
 
890
1273
 
891
- @contextmanager
1274
+ @enhanced_context_manager
892
1275
  def _run_yield_write(input_: IO[str], /, *outputs: IO[str]) -> Iterator[None]:
893
1276
  thread = Thread(target=_run_daemon_target, args=(input_, *outputs), daemon=True)
894
1277
  thread.start()
@@ -1070,7 +1453,7 @@ def ssh(
1070
1453
  retry_skip=_ssh_retry_skip,
1071
1454
  logger=logger,
1072
1455
  )
1073
- except CalledProcessError as error: # skipif-ci
1456
+ except CalledProcessError as error: # pragma: no cover
1074
1457
  if not _ssh_is_strict_checking_error(error.stderr):
1075
1458
  raise
1076
1459
  ssh_keyscan(hostname, port=port)
@@ -1101,8 +1484,9 @@ def _ssh_retry_skip(return_code: int, stdout: str, stderr: str, /) -> bool:
1101
1484
 
1102
1485
  def _ssh_is_strict_checking_error(text: str, /) -> bool:
1103
1486
  match = search(
1104
- "No ED25519 host key is known for .* and you have requested strict checking",
1487
+ "(Host key for .* has changed|No ED25519 host key is known for .*) and you have requested strict checking",
1105
1488
  text,
1489
+ flags=MULTILINE,
1106
1490
  )
1107
1491
  return match is not None
1108
1492
 
@@ -1159,15 +1543,15 @@ def ssh_await(
1159
1543
  /,
1160
1544
  *,
1161
1545
  logger: LoggerLike | None = None,
1162
- delta: Delta = SECOND,
1546
+ duration: Duration = SECOND,
1163
1547
  ) -> None:
1164
1548
  while True: # skipif-ci
1165
1549
  if logger is not None:
1166
1550
  to_logger(logger).info("Waiting for '%s'...", hostname)
1167
1551
  try:
1168
1552
  ssh(user, hostname, "true")
1169
- except CalledProcessError:
1170
- sleep(to_seconds(delta))
1553
+ except CalledProcessError: # pragma: no cover
1554
+ sleep(duration)
1171
1555
  else:
1172
1556
  if logger is not None:
1173
1557
  to_logger(logger).info("'%s' is up", hostname)
@@ -1178,13 +1562,19 @@ def ssh_await(
1178
1562
 
1179
1563
 
1180
1564
  def ssh_keyscan(
1181
- hostname: str, /, *, path: PathLike = KNOWN_HOSTS, port: int | None = None
1565
+ hostname: str,
1566
+ /,
1567
+ *,
1568
+ path: PathLike = KNOWN_HOSTS,
1569
+ retry: Retry | None = None,
1570
+ port: int | None = None,
1182
1571
  ) -> None:
1183
1572
  """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))
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
1188
1578
 
1189
1579
 
1190
1580
  def ssh_keyscan_cmd(hostname: str, /, *, port: int | None = None) -> list[str]:
@@ -1198,11 +1588,13 @@ def ssh_keyscan_cmd(hostname: str, /, *, port: int | None = None) -> list[str]:
1198
1588
  ##
1199
1589
 
1200
1590
 
1201
- def ssh_keygen_remove(hostname: str, /, *, path: PathLike = KNOWN_HOSTS) -> None:
1591
+ def ssh_keygen_remove(
1592
+ hostname: str, /, *, path: PathLike = KNOWN_HOSTS, retry: Retry | None = None
1593
+ ) -> None:
1202
1594
  """Remove a known host."""
1203
1595
  path = Path(path)
1204
1596
  if path.exists():
1205
- run(*ssh_keygen_remove_cmd(hostname, path=path))
1597
+ run(*ssh_keygen_remove_cmd(hostname, path=path), retry=retry)
1206
1598
 
1207
1599
 
1208
1600
  def ssh_keygen_remove_cmd(
@@ -1259,7 +1651,8 @@ def symlink_cmd(target: PathLike, link: PathLike, /) -> list[str]:
1259
1651
  def tee(
1260
1652
  path: PathLike, text: str, /, *, sudo: bool = False, append: bool = False
1261
1653
  ) -> None:
1262
- """Use 'tee' to duplicate standard input."""
1654
+ """Duplicate standard input."""
1655
+ mkdir(path, sudo=sudo, parent=True)
1263
1656
  if sudo: # pragma: no cover
1264
1657
  run(*sudo_cmd(*tee_cmd(path, append=append)), input=text)
1265
1658
  else:
@@ -1341,27 +1734,200 @@ def useradd_cmd(
1341
1734
  ##
1342
1735
 
1343
1736
 
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,
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,
1365
1931
  print: bool = False,
1366
1932
  print_stdout: bool = False,
1367
1933
  print_stderr: bool = False,
@@ -1376,6 +1942,15 @@ def uv_run(
1376
1942
  module: str,
1377
1943
  /,
1378
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,
1379
1954
  cwd: PathLike | None = None,
1380
1955
  print: bool = False,
1381
1956
  print_stdout: bool = False,
@@ -1391,6 +1966,15 @@ def uv_run(
1391
1966
  module: str,
1392
1967
  /,
1393
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,
1394
1978
  cwd: PathLike | None = None,
1395
1979
  print: bool = False,
1396
1980
  print_stdout: bool = False,
@@ -1406,6 +1990,15 @@ def uv_run(
1406
1990
  module: str,
1407
1991
  /,
1408
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,
1409
2002
  cwd: PathLike | None = None,
1410
2003
  print: bool = False,
1411
2004
  print_stdout: bool = False,
@@ -1420,7 +2013,16 @@ def uv_run(
1420
2013
  module: str,
1421
2014
  /,
1422
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,
1423
2024
  cwd: PathLike | None = None,
2025
+ env: StrStrMapping | None = None,
1424
2026
  print: bool = False, # noqa: A002
1425
2027
  print_stdout: bool = False,
1426
2028
  print_stderr: bool = False,
@@ -1432,8 +2034,20 @@ def uv_run(
1432
2034
  ) -> str | None:
1433
2035
  """Run a command or script."""
1434
2036
  return run( # pragma: no cover
1435
- *uv_run_cmd(module, *args),
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
+ ),
1436
2049
  cwd=cwd,
2050
+ env=env,
1437
2051
  print=print,
1438
2052
  print_stdout=print_stdout,
1439
2053
  print_stderr=print_stderr,
@@ -1445,15 +2059,46 @@ def uv_run(
1445
2059
  )
1446
2060
 
1447
2061
 
1448
- def uv_run_cmd(module: str, /, *args: str) -> list[str]:
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]:
1449
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")
1450
2091
  return [
1451
- "uv",
1452
- "run",
1453
- "--no-dev",
1454
- "--active",
1455
- "--prerelease=disallow",
1456
- "--managed-python",
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,
1457
2102
  "python",
1458
2103
  "-m",
1459
2104
  module,
@@ -1464,7 +2109,356 @@ def uv_run_cmd(module: str, /, *args: str) -> list[str]:
1464
2109
  ##
1465
2110
 
1466
2111
 
1467
- @contextmanager
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
1468
2462
  def yield_git_repo(url: str, /, *, branch: str | None = None) -> Iterator[Path]:
1469
2463
  """Yield a temporary git repository."""
1470
2464
  with TemporaryDirectory() as temp_dir:
@@ -1475,7 +2469,7 @@ def yield_git_repo(url: str, /, *, branch: str | None = None) -> Iterator[Path]:
1475
2469
  ##
1476
2470
 
1477
2471
 
1478
- @contextmanager
2472
+ @enhanced_context_manager
1479
2473
  def yield_ssh_temp_dir(
1480
2474
  user: str,
1481
2475
  hostname: str,
@@ -1505,25 +2499,38 @@ __all__ = [
1505
2499
  "BASH_LS",
1506
2500
  "CHPASSWD",
1507
2501
  "GIT_BRANCH_SHOW_CURRENT",
2502
+ "ISOLATED",
2503
+ "MANAGED_PYTHON",
1508
2504
  "MKTEMP_DIR_CMD",
2505
+ "PRERELEASE_DISALLOW",
2506
+ "RESOLUTION_HIGHEST",
1509
2507
  "RESTART_SSHD",
1510
2508
  "UPDATE_CA_CERTIFICATES",
1511
2509
  "ChownCmdError",
1512
2510
  "CpError",
1513
2511
  "MvFileError",
1514
2512
  "RsyncCmdError",
1515
- "RsyncCmdNoSourcesError",
1516
- "RsyncCmdSourcesNotFoundError",
2513
+ "UvPipListError",
2514
+ "append_text",
1517
2515
  "apt_install",
1518
2516
  "apt_install_cmd",
2517
+ "apt_remove",
2518
+ "apt_remove_cmd",
2519
+ "apt_update",
2520
+ "cat",
1519
2521
  "cd_cmd",
2522
+ "chattr",
2523
+ "chattr_cmd",
1520
2524
  "chmod",
1521
2525
  "chmod_cmd",
1522
2526
  "chown",
1523
2527
  "chown_cmd",
1524
2528
  "chpasswd",
2529
+ "copy_text",
1525
2530
  "cp",
1526
2531
  "cp_cmd",
2532
+ "curl",
2533
+ "curl_cmd",
1527
2534
  "echo_cmd",
1528
2535
  "env_cmds",
1529
2536
  "expand_path",
@@ -1532,12 +2539,15 @@ __all__ = [
1532
2539
  "git_checkout_cmd",
1533
2540
  "git_clone",
1534
2541
  "git_clone_cmd",
2542
+ "install",
2543
+ "install_cmd",
1535
2544
  "maybe_parent",
1536
2545
  "maybe_sudo_cmd",
1537
2546
  "mkdir",
1538
2547
  "mkdir_cmd",
1539
2548
  "mv",
1540
2549
  "mv_cmd",
2550
+ "replace_text",
1541
2551
  "ripgrep",
1542
2552
  "ripgrep_cmd",
1543
2553
  "rm",
@@ -1565,8 +2575,16 @@ __all__ = [
1565
2575
  "update_ca_certificates",
1566
2576
  "useradd",
1567
2577
  "useradd_cmd",
2578
+ "uv_native_tls_cmd",
2579
+ "uv_pip_list",
2580
+ "uv_pip_list_cmd",
1568
2581
  "uv_run",
1569
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",
1570
2588
  "yield_git_repo",
1571
2589
  "yield_ssh_temp_dir",
1572
2590
  ]