dycw-utilities 0.174.15__tar.gz → 0.174.17__tar.gz

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 (103) hide show
  1. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/PKG-INFO +1 -1
  2. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/pyproject.toml +5 -3
  3. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/__init__.py +1 -1
  4. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/click.py +1 -2
  5. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/docker.py +1 -1
  6. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/functions.py +1 -1
  7. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/hypothesis.py +1 -1
  8. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/pathlib.py +1 -1
  9. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/platform.py +1 -1
  10. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/subprocess.py +146 -14
  11. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/README.md +0 -0
  12. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/aeventkit.py +0 -0
  13. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/altair.py +0 -0
  14. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/asyncio.py +0 -0
  15. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/atomicwrites.py +0 -0
  16. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/atools.py +0 -0
  17. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/cachetools.py +0 -0
  18. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/concurrent.py +0 -0
  19. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/contextlib.py +0 -0
  20. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/contextvars.py +0 -0
  21. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/cryptography.py +0 -0
  22. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/cvxpy.py +0 -0
  23. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/dataclasses.py +0 -0
  24. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/enum.py +0 -0
  25. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/errors.py +0 -0
  26. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/fastapi.py +0 -0
  27. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/fpdf2.py +0 -0
  28. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/functools.py +0 -0
  29. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/getpass.py +0 -0
  30. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/git.py +0 -0
  31. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/grp.py +0 -0
  32. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/gzip.py +0 -0
  33. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/hashlib.py +0 -0
  34. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/http.py +0 -0
  35. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/importlib.py +0 -0
  36. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/inflect.py +0 -0
  37. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/ipython.py +0 -0
  38. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/iterables.py +0 -0
  39. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/jinja2.py +0 -0
  40. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/json.py +0 -0
  41. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/jupyter.py +0 -0
  42. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/libcst.py +0 -0
  43. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/lightweight_charts.py +0 -0
  44. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/logging.py +0 -0
  45. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/math.py +0 -0
  46. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/memory_profiler.py +0 -0
  47. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/modules.py +0 -0
  48. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/more_itertools.py +0 -0
  49. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/numpy.py +0 -0
  50. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/operator.py +0 -0
  51. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/optuna.py +0 -0
  52. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/orjson.py +0 -0
  53. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/os.py +0 -0
  54. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/parse.py +0 -0
  55. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/permissions.py +0 -0
  56. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/pickle.py +0 -0
  57. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/polars.py +0 -0
  58. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/polars_ols.py +0 -0
  59. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/postgres.py +0 -0
  60. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/pottery.py +0 -0
  61. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/pqdm.py +0 -0
  62. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/psutil.py +0 -0
  63. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/pwd.py +0 -0
  64. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/py.typed +0 -0
  65. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/pydantic.py +0 -0
  66. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/pydantic_settings.py +0 -0
  67. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/pydantic_settings_sops.py +0 -0
  68. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/pyinstrument.py +0 -0
  69. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/pytest.py +0 -0
  70. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/pytest_plugins/__init__.py +0 -0
  71. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/pytest_plugins/pytest_randomly.py +0 -0
  72. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/pytest_plugins/pytest_regressions.py +0 -0
  73. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/pytest_regressions.py +0 -0
  74. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/random.py +0 -0
  75. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/re.py +0 -0
  76. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/redis.py +0 -0
  77. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/reprlib.py +0 -0
  78. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/scipy.py +0 -0
  79. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/sentinel.py +0 -0
  80. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/shelve.py +0 -0
  81. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/shutil.py +0 -0
  82. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/slack_sdk.py +0 -0
  83. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/socket.py +0 -0
  84. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/sqlalchemy.py +0 -0
  85. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/sqlalchemy_polars.py +0 -0
  86. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/statsmodels.py +0 -0
  87. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/string.py +0 -0
  88. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/tempfile.py +0 -0
  89. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/testbook.py +0 -0
  90. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/text.py +0 -0
  91. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/threading.py +0 -0
  92. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/timer.py +0 -0
  93. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/traceback.py +0 -0
  94. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/types.py +0 -0
  95. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/typing.py +0 -0
  96. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/tzdata.py +0 -0
  97. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/tzlocal.py +0 -0
  98. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/uuid.py +0 -0
  99. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/version.py +0 -0
  100. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/warnings.py +0 -0
  101. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/whenever.py +0 -0
  102. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/zipfile.py +0 -0
  103. {dycw_utilities-0.174.15 → dycw_utilities-0.174.17}/src/utilities/zoneinfo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dycw-utilities
3
- Version: 0.174.15
3
+ Version: 0.174.17
4
4
  Author: Derek Wan
5
5
  Author-email: Derek Wan <d.wan@icloud.com>
6
6
  Requires-Dist: atomicwrites>=1.4.1,<1.5
@@ -28,7 +28,7 @@
28
28
  "pytest-cov >=7.0.0, <7.1",
29
29
  "pytest-timeout >=2.4.0, <2.5",
30
30
  ]
31
- fastapi = ["fastapi >=0.127.1, <0.128"]
31
+ fastapi = ["fastapi >=0.128.0, <0.129"]
32
32
  fastapi-test = ["httpx", "uvicorn"]
33
33
  fpdf2 = ["fpdf2 >=2.8.5, <2.9"]
34
34
  gitpython = ["gitpython >=3.1.45, <3.2"]
@@ -101,7 +101,7 @@
101
101
  name = "dycw-utilities"
102
102
  readme = "README.md"
103
103
  requires-python = ">= 3.12"
104
- version = "0.174.15"
104
+ version = "0.174.17"
105
105
 
106
106
  [project.entry-points.pytest11]
107
107
  pytest-randomly = "utilities.pytest_plugins.pytest_randomly"
@@ -135,7 +135,7 @@
135
135
  # bump-my-version
136
136
  [tool.bumpversion]
137
137
  allow_dirty = true
138
- current_version = "0.174.15"
138
+ current_version = "0.174.17"
139
139
 
140
140
  [[tool.bumpversion.files]]
141
141
  filename = "src/utilities/__init__.py"
@@ -154,6 +154,8 @@
154
154
  skipif-mac = 'sys_platform == "darwin"'
155
155
  skipif-not-linux = 'sys_platform != "linux"'
156
156
  skipif-not-macos = 'sys_platform != "darwin"'
157
+ skipif-not-windows = 'sys_platform != "windows"'
158
+ skipif-windows = 'sys_platform == "darwin"'
157
159
 
158
160
  [tool.coverage.html]
159
161
  directory = ".coverage/html"
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.174.15"
3
+ __version__ = "0.174.17"
@@ -210,8 +210,7 @@ class EnumPartial[E: enum.Enum](ParamType):
210
210
  self.fail(str(error), param, ctx)
211
211
  if enum in self._members:
212
212
  return enum
213
- self.fail(f"{enum.value!r} is not a selected member")
214
- return None
213
+ return self.fail(f"{enum.value!r} is not a selected member")
215
214
 
216
215
  @override
217
216
  def get_metavar(self, param: Parameter, ctx: Context) -> str | None:
@@ -47,7 +47,7 @@ def docker_cp(
47
47
  sudo: bool = False,
48
48
  logger: LoggerLike | None = None,
49
49
  ) -> None:
50
- match src, dest:
50
+ match src, dest: # skipif-ci
51
51
  case Path() | str(), (str() as cont, Path() | str() as dest_path):
52
52
  docker_exec(
53
53
  cont, *maybe_sudo_cmd(*mkdir_cmd(dest_path, parent=True), sudo=sudo)
@@ -693,7 +693,7 @@ def second[U](pair: tuple[Any, U], /) -> U:
693
693
 
694
694
  def skip_if_optimize[**P](func: Callable[P, None], /) -> Callable[P, None]:
695
695
  """Skip a function if we are in the optimized mode."""
696
- if __debug__:
696
+ if __debug__: # pragma: no cover
697
697
  return func
698
698
 
699
699
  @wraps(func)
@@ -1040,7 +1040,7 @@ def setup_hypothesis_profiles(
1040
1040
  assert_never(never)
1041
1041
 
1042
1042
  phases = {Phase.explicit, Phase.reuse, Phase.generate, Phase.target}
1043
- if "HYPOTHESIS_NO_SHRINK" not in environ:
1043
+ if "HYPOTHESIS_NO_SHRINK" not in environ: # pragma: no cover
1044
1044
  phases.add(Phase.shrink)
1045
1045
  for profile in Profile:
1046
1046
  try:
@@ -127,7 +127,7 @@ class GetRepoRootError(Exception): ...
127
127
  class _GetRepoRootGitNotFoundError(GetRepoRootError):
128
128
  @override
129
129
  def __str__(self) -> str:
130
- return "'git' not found"
130
+ return "'git' not found" # pragma: no cover
131
131
 
132
132
 
133
133
  @dataclass(kw_only=True, slots=True)
@@ -16,7 +16,7 @@ System = Literal["windows", "mac", "linux"]
16
16
  def get_system() -> System:
17
17
  """Get the system/OS name."""
18
18
  sys = system()
19
- if sys == "Windows":
19
+ if sys == "Windows": # skipif-ci
20
20
  return "windows"
21
21
  if sys == "Darwin": # skipif-not-macos
22
22
  return "mac"
@@ -17,7 +17,8 @@ from typing import IO, TYPE_CHECKING, Literal, assert_never, overload, override
17
17
  from utilities.errors import ImpossibleCaseError
18
18
  from utilities.iterables import always_iterable
19
19
  from utilities.logging import to_logger
20
- from utilities.permissions import ensure_perms
20
+ from utilities.permissions import Permissions, ensure_perms
21
+ from utilities.tempfile import TemporaryDirectory
21
22
  from utilities.text import strip_and_dedent
22
23
  from utilities.whenever import to_seconds
23
24
 
@@ -164,7 +165,7 @@ def cp(
164
165
  ) -> None:
165
166
  """Copy a file/directory."""
166
167
  mkdir(dest, sudo=sudo, parent=True)
167
- if sudo:
168
+ if sudo: # pragma: no cover
168
169
  run(*sudo_cmd(*cp_cmd(src, dest)))
169
170
  else:
170
171
  src, dest = map(Path, [src, dest])
@@ -277,7 +278,7 @@ def mv(
277
278
  ) -> None:
278
279
  """Move a file/directory."""
279
280
  mkdir(dest, sudo=sudo, parent=True)
280
- if sudo:
281
+ if sudo: # pragma: no cover
281
282
  run(*sudo_cmd(*cp_cmd(src, dest)))
282
283
  else:
283
284
  src, dest = map(Path, [src, dest])
@@ -346,7 +347,7 @@ def rsync(
346
347
  chown_user: str | None = None,
347
348
  chown_group: str | None = None,
348
349
  exclude: MaybeIterable[str] | None = None,
349
- chmod: str | None = None,
350
+ chmod: PermissionsLike | None = None,
350
351
  ) -> None:
351
352
  """Remote & local file copying."""
352
353
  mkdir_args = maybe_sudo_cmd(*mkdir_cmd(dest, parent=True), sudo=sudo) # skipif-ci
@@ -361,13 +362,13 @@ def rsync(
361
362
  retry=retry,
362
363
  logger=logger,
363
364
  )
364
- is_dir = any(Path(s).is_dir() for s in always_iterable(src_or_srcs)) # skipif-ci
365
+ srcs = list(always_iterable(src_or_srcs)) # skipif-ci
365
366
  rsync_args = rsync_cmd( # skipif-ci
366
- src_or_srcs,
367
+ srcs,
367
368
  user,
368
369
  hostname,
369
370
  dest,
370
- archive=is_dir,
371
+ archive=any(Path(s).is_dir() for s in srcs),
371
372
  chown_user=chown_user,
372
373
  chown_group=chown_group,
373
374
  exclude=exclude,
@@ -375,7 +376,6 @@ def rsync(
375
376
  host_key_algorithms=host_key_algorithms,
376
377
  strict_host_key_checking=strict_host_key_checking,
377
378
  sudo=sudo,
378
- parent=is_dir,
379
379
  )
380
380
  run(*rsync_args, print=print, retry=retry, logger=logger) # skipif-ci
381
381
  if chmod is not None: # skipif-ci
@@ -408,7 +408,6 @@ def rsync_cmd(
408
408
  host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
409
409
  strict_host_key_checking: bool = True,
410
410
  sudo: bool = False,
411
- parent: bool = False,
412
411
  ) -> list[str]:
413
412
  """Command to use 'rsync' to do remote & local file copying."""
414
413
  args: list[str] = ["rsync"]
@@ -438,12 +437,141 @@ def rsync_cmd(
438
437
  args.extend(["--rsh", join(rsh_args)])
439
438
  if sudo:
440
439
  args.extend(["--rsync-path", join(sudo_cmd("rsync"))])
441
- dest_use = maybe_parent(dest, parent=parent)
442
- return [
443
- *args,
444
- *map(str, always_iterable(src_or_srcs)),
445
- f"{user}@{hostname}:{dest_use}",
440
+ srcs = list(always_iterable(src_or_srcs)) # do not Path()
441
+ if len(srcs) == 0:
442
+ raise RsyncCmdNoSourcesError(user=user, hostname=hostname, dest=dest)
443
+ missing = [s for s in srcs if not Path(s).exists()]
444
+ if len(missing) >= 1:
445
+ raise RsyncCmdSourcesNotFoundError(
446
+ sources=missing, user=user, hostname=hostname, dest=dest
447
+ )
448
+ return [*args, *map(str, srcs), f"{user}@{hostname}:{dest}"]
449
+
450
+
451
+ @dataclass(kw_only=True, slots=True)
452
+ class RsyncCmdError(Exception):
453
+ user: str
454
+ hostname: str
455
+ dest: PathLike
456
+
457
+ @override
458
+ def __str__(self) -> str:
459
+ return f"No sources selected to send to {self.user}@{self.hostname}:{self.dest}"
460
+
461
+
462
+ @dataclass(kw_only=True, slots=True)
463
+ class RsyncCmdNoSourcesError(RsyncCmdError):
464
+ @override
465
+ def __str__(self) -> str:
466
+ return f"No sources selected to send to {self.user}@{self.hostname}:{self.dest}"
467
+
468
+
469
+ @dataclass(kw_only=True, slots=True)
470
+ class RsyncCmdSourcesNotFoundError(RsyncCmdError):
471
+ sources: list[PathLike]
472
+
473
+ @override
474
+ def __str__(self) -> str:
475
+ desc = ", ".join(map(repr, map(str, self.sources)))
476
+ return f"Sources selected to send to {self.user}@{self.hostname}:{self.dest} but not found: {desc}"
477
+
478
+
479
+ ##
480
+
481
+
482
+ def rsync_many(
483
+ user: str,
484
+ hostname: str,
485
+ /,
486
+ *items: tuple[PathLike, PathLike]
487
+ | tuple[Literal["sudo"], PathLike, PathLike]
488
+ | tuple[PathLike, PathLike, PermissionsLike],
489
+ retry: Retry | None = None,
490
+ logger: LoggerLike | None = None,
491
+ keep: bool = False,
492
+ batch_mode: bool = True,
493
+ host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
494
+ strict_host_key_checking: bool = True,
495
+ print: bool = False, # noqa: A002
496
+ ) -> None:
497
+ cmds: list[list[str]] = [] # skipif-ci
498
+ with ( # skipif-ci
499
+ TemporaryDirectory() as temp_src,
500
+ yield_ssh_temp_dir(
501
+ user, hostname, retry=retry, logger=logger, keep=keep
502
+ ) as temp_dest,
503
+ ):
504
+ for item in items:
505
+ match item:
506
+ case Path() | str() as src, Path() | str() as dest:
507
+ cmds.extend(_rsync_many_prepare(src, dest, temp_src, temp_dest))
508
+ case "sudo", Path() | str() as src, Path() | str() as dest:
509
+ cmds.extend(
510
+ _rsync_many_prepare(src, dest, temp_src, temp_dest, sudo=True)
511
+ )
512
+ case (
513
+ Path() | str() as src,
514
+ Path() | str() as dest,
515
+ Permissions() | int() | str() as perms,
516
+ ):
517
+ cmds.extend(
518
+ _rsync_many_prepare(src, dest, temp_src, temp_dest, perms=perms)
519
+ )
520
+ case never:
521
+ assert_never(never)
522
+ rsync(
523
+ f"{temp_src}/",
524
+ user,
525
+ hostname,
526
+ temp_dest,
527
+ batch_mode=batch_mode,
528
+ host_key_algorithms=host_key_algorithms,
529
+ strict_host_key_checking=strict_host_key_checking,
530
+ print=print,
531
+ retry=retry,
532
+ logger=logger,
533
+ )
534
+ ssh(
535
+ user,
536
+ hostname,
537
+ *BASH_LS,
538
+ input="\n".join(map(join, cmds)),
539
+ print=print,
540
+ retry=retry,
541
+ logger=logger,
542
+ )
543
+
544
+
545
+ def _rsync_many_prepare(
546
+ src: PathLike,
547
+ dest: PathLike,
548
+ temp_src: PathLike,
549
+ temp_dest: PathLike,
550
+ /,
551
+ *,
552
+ sudo: bool = False,
553
+ perms: PermissionsLike | None = None,
554
+ ) -> list[list[str]]:
555
+ dest, temp_src, temp_dest = map(Path, [dest, temp_src, temp_dest])
556
+ n = len(list(temp_src.iterdir()))
557
+ name = str(n)
558
+ match src:
559
+ case Path():
560
+ cp(src, temp_src / name)
561
+ case str():
562
+ if Path(src).exists():
563
+ cp(src, temp_src / name)
564
+ else:
565
+ tee(temp_src / name, src)
566
+ case never:
567
+ assert_never(never)
568
+ cmds: list[list[str]] = [
569
+ maybe_sudo_cmd(*mkdir_cmd(dest, parent=True), sudo=sudo),
570
+ maybe_sudo_cmd(*cp_cmd(temp_dest / name, dest), sudo=sudo),
446
571
  ]
572
+ if perms is not None:
573
+ cmds.append(maybe_sudo_cmd(*chmod_cmd(dest, perms), sudo=sudo))
574
+ return cmds
447
575
 
448
576
 
449
577
  ##
@@ -1019,6 +1147,9 @@ __all__ = [
1019
1147
  "ChownCmdError",
1020
1148
  "CpError",
1021
1149
  "MvFileError",
1150
+ "RsyncCmdError",
1151
+ "RsyncCmdNoSourcesError",
1152
+ "RsyncCmdSourcesNotFoundError",
1022
1153
  "apt_install_cmd",
1023
1154
  "cd_cmd",
1024
1155
  "chmod",
@@ -1041,6 +1172,7 @@ __all__ = [
1041
1172
  "rm_cmd",
1042
1173
  "rsync",
1043
1174
  "rsync_cmd",
1175
+ "rsync_many",
1044
1176
  "run",
1045
1177
  "set_hostname_cmd",
1046
1178
  "ssh",