dycw-utilities 0.174.16__py3-none-any.whl → 0.174.18__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dycw-utilities
3
- Version: 0.174.16
3
+ Version: 0.174.18
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
@@ -1,4 +1,4 @@
1
- utilities/__init__.py,sha256=vtFT93z2VkUoI-200aWbVdTniPgIRP-XjQ6slYwfpHo,61
1
+ utilities/__init__.py,sha256=2xupJL_-0RN3mhQDSD7NIAJegYc_Pr3NKdbT8LPHrvI,61
2
2
  utilities/aeventkit.py,sha256=OmDBhYGgbsKrB7cdC5FFpJHUatX9O76eTeKVVTksp2Y,12673
3
3
  utilities/altair.py,sha256=rUK99g9x6CYDDfiZrf-aTx5fSRbL1Q8ctgKORowzXHg,9060
4
4
  utilities/asyncio.py,sha256=aJySVxBY0gqsIYnoNmH7-1r8djKuf4vSsU69VCD08t8,16772
@@ -12,7 +12,7 @@ utilities/contextvars.py,sha256=J8OhC7jqozAGYOCe2KUWysbPXNGe5JYz3HfaY_mIs08,883
12
12
  utilities/cryptography.py,sha256=5PFrzsNUGHay91dFgYnDKwYprXxahrBqztmUqViRzBk,956
13
13
  utilities/cvxpy.py,sha256=Rv1-fD-XYerosCavRF8Pohop2DBkU3AlFaGTfD8AEAA,13776
14
14
  utilities/dataclasses.py,sha256=xbU3QN1GFy7RC6hIJRZIeUZm7YRlodrgEWmahWG6k2g,32465
15
- utilities/docker.py,sha256=l_R9Eyhc-_JbkkaYXYKGZ0PZ_oukrSj8aKtD1NyAhMM,7879
15
+ utilities/docker.py,sha256=N__PKd3cnSRsXNEMHMLdLneLdyzfbr2ESkElcwrovvQ,7940
16
16
  utilities/enum.py,sha256=5l6pwZD1cjSlVW4ss-zBPspWvrbrYrdtJWcg6f5_J5w,5781
17
17
  utilities/errors.py,sha256=mFlDGSM0LI1jZ1pbqwLAH3ttLZ2JVIxyZLojw8tGVZU,1479
18
18
  utilities/fastapi.py,sha256=TqyKvBjiMS594sXPjrz-KRTLMb3l3D3rZ1zAYV7GfOk,1454
@@ -81,7 +81,7 @@ utilities/sqlalchemy.py,sha256=HQYpd7LFxdTF5WYVWYtCJeEBI71EJm7ytvCGyAH9B-U,37163
81
81
  utilities/sqlalchemy_polars.py,sha256=JCGhB37raSR7fqeWV5dTsciRTMVzIdVT9YSqKT0piT0,13370
82
82
  utilities/statsmodels.py,sha256=koyiBHvpMcSiBfh99wFUfSggLNx7cuAw3rwyfAhoKpQ,3410
83
83
  utilities/string.py,sha256=shmBK87zZwzGyixuNuXCiUbqzfeZ9xlrFwz6JTaRvDk,582
84
- utilities/subprocess.py,sha256=c9f3-HCXVOAMBkfcs4ytmtGUKRH24UlV1D_NdvPqfVA,28937
84
+ utilities/subprocess.py,sha256=q0k-Fl8iRKX9UvqtNgshUcrv-tAEzJbeyoQWiWEdql8,34298
85
85
  utilities/tempfile.py,sha256=Lx6qa16lL1XVH6WdmD_G9vlN6gLI8nrIurxmsFkPKvg,3022
86
86
  utilities/testbook.py,sha256=j1KmaVbrX9VrbeMgtPh5gk55myAsn3dyRUn7jGbPbRk,1294
87
87
  utilities/text.py,sha256=7SvwcSR2l_5cOrm1samGnR4C-ZI6qyFLHLzSpO1zeHQ,13958
@@ -98,7 +98,7 @@ utilities/warnings.py,sha256=un1LvHv70PU-LLv8RxPVmugTzDJkkGXRMZTE2-fTQHw,1771
98
98
  utilities/whenever.py,sha256=F4ek0-OBWxHYrZdmoZt76N2RnNyKY5KrEHt7rqO4AQE,60183
99
99
  utilities/zipfile.py,sha256=24lQc9ATcJxHXBPc_tBDiJk48pWyRrlxO2fIsFxU0A8,699
100
100
  utilities/zoneinfo.py,sha256=tdIScrTB2-B-LH0ukb1HUXKooLknOfJNwHk10MuMYvA,3619
101
- dycw_utilities-0.174.16.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
102
- dycw_utilities-0.174.16.dist-info/entry_points.txt,sha256=ykGI1ArwOPHqm2g5Cqh3ENdMxEej_a_FcOUov5EM5Oc,155
103
- dycw_utilities-0.174.16.dist-info/METADATA,sha256=aNMnbDY3Lh5LGRBgi6ySNdZ3PR2zd3cxGHv28VNPX44,1710
104
- dycw_utilities-0.174.16.dist-info/RECORD,,
101
+ dycw_utilities-0.174.18.dist-info/WHEEL,sha256=RRVLqVugUmFOqBedBFAmA4bsgFcROUBiSUKlERi0Hcg,79
102
+ dycw_utilities-0.174.18.dist-info/entry_points.txt,sha256=ykGI1ArwOPHqm2g5Cqh3ENdMxEej_a_FcOUov5EM5Oc,155
103
+ dycw_utilities-0.174.18.dist-info/METADATA,sha256=lsjOEHDTftfTew-cUGjw81uggqGrWLEXHVN3snWSV80,1710
104
+ dycw_utilities-0.174.18.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.9.18
2
+ Generator: uv 0.9.21
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
utilities/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.174.16"
3
+ __version__ = "0.174.18"
utilities/docker.py CHANGED
@@ -269,6 +269,7 @@ def yield_docker_temp_dir(
269
269
  logger: LoggerLike | None = None,
270
270
  keep: bool = False,
271
271
  ) -> Iterator[Path]:
272
+ """Yield a temporary directory in a Docker container."""
272
273
  path = Path( # skipif-ci
273
274
  docker_exec(
274
275
  container,
utilities/subprocess.py CHANGED
@@ -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
 
@@ -220,6 +221,16 @@ def expand_path(
220
221
  ##
221
222
 
222
223
 
224
+ def git_clone(
225
+ url: str, path: PathLike, /, *, sudo: bool = False, branch: str | None = None
226
+ ) -> None:
227
+ """Clone a repository."""
228
+ rm(path, sudo=sudo)
229
+ run(*maybe_sudo_cmd(*git_clone_cmd(url, path), sudo=sudo))
230
+ if branch is not None:
231
+ run(*maybe_sudo_cmd(*git_hard_reset_cmd(branch=branch), sudo=sudo), cwd=path)
232
+
233
+
223
234
  def git_clone_cmd(url: str, path: PathLike, /) -> list[str]:
224
235
  """Command to use 'git clone' to clone a repository."""
225
236
  return ["git", "clone", "--recurse-submodules", url, str(path)]
@@ -346,7 +357,7 @@ def rsync(
346
357
  chown_user: str | None = None,
347
358
  chown_group: str | None = None,
348
359
  exclude: MaybeIterable[str] | None = None,
349
- chmod: str | None = None,
360
+ chmod: PermissionsLike | None = None,
350
361
  ) -> None:
351
362
  """Remote & local file copying."""
352
363
  mkdir_args = maybe_sudo_cmd(*mkdir_cmd(dest, parent=True), sudo=sudo) # skipif-ci
@@ -361,13 +372,13 @@ def rsync(
361
372
  retry=retry,
362
373
  logger=logger,
363
374
  )
364
- is_dir = any(Path(s).is_dir() for s in always_iterable(src_or_srcs)) # skipif-ci
375
+ srcs = list(always_iterable(src_or_srcs)) # skipif-ci
365
376
  rsync_args = rsync_cmd( # skipif-ci
366
- src_or_srcs,
377
+ srcs,
367
378
  user,
368
379
  hostname,
369
380
  dest,
370
- archive=is_dir,
381
+ archive=any(Path(s).is_dir() for s in srcs),
371
382
  chown_user=chown_user,
372
383
  chown_group=chown_group,
373
384
  exclude=exclude,
@@ -375,7 +386,6 @@ def rsync(
375
386
  host_key_algorithms=host_key_algorithms,
376
387
  strict_host_key_checking=strict_host_key_checking,
377
388
  sudo=sudo,
378
- parent=is_dir,
379
389
  )
380
390
  run(*rsync_args, print=print, retry=retry, logger=logger) # skipif-ci
381
391
  if chmod is not None: # skipif-ci
@@ -408,7 +418,6 @@ def rsync_cmd(
408
418
  host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
409
419
  strict_host_key_checking: bool = True,
410
420
  sudo: bool = False,
411
- parent: bool = False,
412
421
  ) -> list[str]:
413
422
  """Command to use 'rsync' to do remote & local file copying."""
414
423
  args: list[str] = ["rsync"]
@@ -438,12 +447,141 @@ def rsync_cmd(
438
447
  args.extend(["--rsh", join(rsh_args)])
439
448
  if sudo:
440
449
  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}",
450
+ srcs = list(always_iterable(src_or_srcs)) # do not Path()
451
+ if len(srcs) == 0:
452
+ raise RsyncCmdNoSourcesError(user=user, hostname=hostname, dest=dest)
453
+ missing = [s for s in srcs if not Path(s).exists()]
454
+ if len(missing) >= 1:
455
+ raise RsyncCmdSourcesNotFoundError(
456
+ sources=missing, user=user, hostname=hostname, dest=dest
457
+ )
458
+ return [*args, *map(str, srcs), f"{user}@{hostname}:{dest}"]
459
+
460
+
461
+ @dataclass(kw_only=True, slots=True)
462
+ class RsyncCmdError(Exception):
463
+ user: str
464
+ hostname: str
465
+ dest: PathLike
466
+
467
+ @override
468
+ def __str__(self) -> str:
469
+ return f"No sources selected to send to {self.user}@{self.hostname}:{self.dest}"
470
+
471
+
472
+ @dataclass(kw_only=True, slots=True)
473
+ class RsyncCmdNoSourcesError(RsyncCmdError):
474
+ @override
475
+ def __str__(self) -> str:
476
+ return f"No sources selected to send to {self.user}@{self.hostname}:{self.dest}"
477
+
478
+
479
+ @dataclass(kw_only=True, slots=True)
480
+ class RsyncCmdSourcesNotFoundError(RsyncCmdError):
481
+ sources: list[PathLike]
482
+
483
+ @override
484
+ def __str__(self) -> str:
485
+ desc = ", ".join(map(repr, map(str, self.sources)))
486
+ return f"Sources selected to send to {self.user}@{self.hostname}:{self.dest} but not found: {desc}"
487
+
488
+
489
+ ##
490
+
491
+
492
+ def rsync_many(
493
+ user: str,
494
+ hostname: str,
495
+ /,
496
+ *items: tuple[PathLike, PathLike]
497
+ | tuple[Literal["sudo"], PathLike, PathLike]
498
+ | tuple[PathLike, PathLike, PermissionsLike],
499
+ retry: Retry | None = None,
500
+ logger: LoggerLike | None = None,
501
+ keep: bool = False,
502
+ batch_mode: bool = True,
503
+ host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
504
+ strict_host_key_checking: bool = True,
505
+ print: bool = False, # noqa: A002
506
+ ) -> None:
507
+ cmds: list[list[str]] = [] # skipif-ci
508
+ with ( # skipif-ci
509
+ TemporaryDirectory() as temp_src,
510
+ yield_ssh_temp_dir(
511
+ user, hostname, retry=retry, logger=logger, keep=keep
512
+ ) as temp_dest,
513
+ ):
514
+ for item in items:
515
+ match item:
516
+ case Path() | str() as src, Path() | str() as dest:
517
+ cmds.extend(_rsync_many_prepare(src, dest, temp_src, temp_dest))
518
+ case "sudo", Path() | str() as src, Path() | str() as dest:
519
+ cmds.extend(
520
+ _rsync_many_prepare(src, dest, temp_src, temp_dest, sudo=True)
521
+ )
522
+ case (
523
+ Path() | str() as src,
524
+ Path() | str() as dest,
525
+ Permissions() | int() | str() as perms,
526
+ ):
527
+ cmds.extend(
528
+ _rsync_many_prepare(src, dest, temp_src, temp_dest, perms=perms)
529
+ )
530
+ case never:
531
+ assert_never(never)
532
+ rsync(
533
+ f"{temp_src}/",
534
+ user,
535
+ hostname,
536
+ temp_dest,
537
+ batch_mode=batch_mode,
538
+ host_key_algorithms=host_key_algorithms,
539
+ strict_host_key_checking=strict_host_key_checking,
540
+ print=print,
541
+ retry=retry,
542
+ logger=logger,
543
+ )
544
+ ssh(
545
+ user,
546
+ hostname,
547
+ *BASH_LS,
548
+ input="\n".join(map(join, cmds)),
549
+ print=print,
550
+ retry=retry,
551
+ logger=logger,
552
+ )
553
+
554
+
555
+ def _rsync_many_prepare(
556
+ src: PathLike,
557
+ dest: PathLike,
558
+ temp_src: PathLike,
559
+ temp_dest: PathLike,
560
+ /,
561
+ *,
562
+ sudo: bool = False,
563
+ perms: PermissionsLike | None = None,
564
+ ) -> list[list[str]]:
565
+ dest, temp_src, temp_dest = map(Path, [dest, temp_src, temp_dest])
566
+ n = len(list(temp_src.iterdir()))
567
+ name = str(n)
568
+ match src:
569
+ case Path():
570
+ cp(src, temp_src / name)
571
+ case str():
572
+ if Path(src).exists():
573
+ cp(src, temp_src / name)
574
+ else:
575
+ tee(temp_src / name, src)
576
+ case never:
577
+ assert_never(never)
578
+ cmds: list[list[str]] = [
579
+ maybe_sudo_cmd(*mkdir_cmd(dest, parent=True), sudo=sudo),
580
+ maybe_sudo_cmd(*cp_cmd(temp_dest / name, dest), sudo=sudo),
446
581
  ]
582
+ if perms is not None:
583
+ cmds.append(maybe_sudo_cmd(*chmod_cmd(dest, perms), sudo=sudo))
584
+ return cmds
447
585
 
448
586
 
449
587
  ##
@@ -961,14 +1099,26 @@ def tee_cmd(path: PathLike, /, *, append: bool = False) -> list[str]:
961
1099
  ##
962
1100
 
963
1101
 
1102
+ def touch(path: PathLike, /, *, sudo: bool = False) -> None:
1103
+ """Change file access and modification times."""
1104
+ run(*maybe_sudo_cmd(*touch_cmd(path), sudo=sudo))
1105
+
1106
+
964
1107
  def touch_cmd(path: PathLike, /) -> list[str]:
1108
+ """Command to use 'touch' to change file access and modification times."""
965
1109
  return ["touch", str(path)]
966
1110
 
967
1111
 
968
1112
  ##
969
1113
 
970
1114
 
1115
+ def uv_run(module: str, /, *args: str) -> None:
1116
+ """Run a command or script."""
1117
+ run(*uv_run_cmd(module, *args)) # pragma: no cover
1118
+
1119
+
971
1120
  def uv_run_cmd(module: str, /, *args: str) -> list[str]:
1121
+ """Command to use 'uv' to run a command or script."""
972
1122
  return [
973
1123
  "uv",
974
1124
  "run",
@@ -986,6 +1136,17 @@ def uv_run_cmd(module: str, /, *args: str) -> list[str]:
986
1136
  ##
987
1137
 
988
1138
 
1139
+ @contextmanager
1140
+ def yield_git_repo(url: str, /, *, branch: str | None = None) -> Iterator[Path]:
1141
+ """Yield a temporary git repository."""
1142
+ with TemporaryDirectory() as temp_dir:
1143
+ git_clone(url, temp_dir, branch=branch)
1144
+ yield temp_dir
1145
+
1146
+
1147
+ ##
1148
+
1149
+
989
1150
  @contextmanager
990
1151
  def yield_ssh_temp_dir(
991
1152
  user: str,
@@ -996,6 +1157,7 @@ def yield_ssh_temp_dir(
996
1157
  logger: LoggerLike | None = None,
997
1158
  keep: bool = False,
998
1159
  ) -> Iterator[Path]:
1160
+ """Yield a temporary directory on a remote machine."""
999
1161
  path = Path( # skipif-ci
1000
1162
  ssh(user, hostname, *MKTEMP_DIR_CMD, return_=True, retry=retry, logger=logger)
1001
1163
  )
@@ -1019,6 +1181,9 @@ __all__ = [
1019
1181
  "ChownCmdError",
1020
1182
  "CpError",
1021
1183
  "MvFileError",
1184
+ "RsyncCmdError",
1185
+ "RsyncCmdNoSourcesError",
1186
+ "RsyncCmdSourcesNotFoundError",
1022
1187
  "apt_install_cmd",
1023
1188
  "cd_cmd",
1024
1189
  "chmod",
@@ -1029,6 +1194,7 @@ __all__ = [
1029
1194
  "cp_cmd",
1030
1195
  "echo_cmd",
1031
1196
  "expand_path",
1197
+ "git_clone",
1032
1198
  "git_clone_cmd",
1033
1199
  "git_hard_reset_cmd",
1034
1200
  "maybe_parent",
@@ -1041,6 +1207,7 @@ __all__ = [
1041
1207
  "rm_cmd",
1042
1208
  "rsync",
1043
1209
  "rsync_cmd",
1210
+ "rsync_many",
1044
1211
  "run",
1045
1212
  "set_hostname_cmd",
1046
1213
  "ssh",
@@ -1051,7 +1218,10 @@ __all__ = [
1051
1218
  "symlink",
1052
1219
  "symlink_cmd",
1053
1220
  "tee_cmd",
1221
+ "touch",
1054
1222
  "touch_cmd",
1223
+ "uv_run",
1055
1224
  "uv_run_cmd",
1225
+ "yield_git_repo",
1056
1226
  "yield_ssh_temp_dir",
1057
1227
  ]