dycw-utilities 0.174.19__py3-none-any.whl → 0.175.6__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,7 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dycw-utilities
3
- Version: 0.174.19
3
+ Version: 0.175.6
4
+ Summary: Miscellaneous Python utilities
4
5
  Author: Derek Wan
5
6
  Author-email: Derek Wan <d.wan@icloud.com>
6
7
  Requires-Dist: atomicwrites>=1.4.1,<1.5
@@ -28,14 +29,6 @@ Provides-Extra: logging
28
29
  Provides-Extra: test
29
30
  Description-Content-Type: text/markdown
30
31
 
31
- [![PyPI version](https://badge.fury.io/py/dycw-utilities.svg)](https://badge.fury.io/py/dycw-utilities)
32
+ # `python-utilities`
32
33
 
33
- # `dycw-utilities`
34
-
35
- [All the Python functions I don't want to write twice.](https://github.com/nvim-lua/plenary.nvim)
36
-
37
- ## Installation
38
-
39
- - `pip install dycw-utilities`
40
-
41
- or with [extras](https://github.com/dycw/python-utilities/blob/master/pyproject.toml).
34
+ Miscellaneous Python utilities
@@ -1,6 +1,5 @@
1
- utilities/__init__.py,sha256=nyJKpGIkfnetD4-kNA4ZJixn2uq8SdQrflZtDYf_k2Q,61
2
- utilities/aeventkit.py,sha256=OmDBhYGgbsKrB7cdC5FFpJHUatX9O76eTeKVVTksp2Y,12673
3
- utilities/altair.py,sha256=rUK99g9x6CYDDfiZrf-aTx5fSRbL1Q8ctgKORowzXHg,9060
1
+ utilities/__init__.py,sha256=7y6XPRHYRyZjE6Vfy185IzZfDUJT8hoQcrfKUT3_4RU,60
2
+ utilities/altair.py,sha256=TLfRFbG9HwG7SLXoJ-v0r-t49ZaGgTQZD82cpjVi4vs,9085
4
3
  utilities/asyncio.py,sha256=aJySVxBY0gqsIYnoNmH7-1r8djKuf4vSsU69VCD08t8,16772
5
4
  utilities/atomicwrites.py,sha256=tPo6r-Rypd9u99u66B9z86YBPpnLrlHtwox_8Z7T34Y,5790
6
5
  utilities/atools.py,sha256=6neeCcgXxK2dlsc0xp15Za7nSucbCgFtAJepGI_-WXU,2549
@@ -66,7 +65,7 @@ utilities/pytest.py,sha256=9HHwYgZQe6CRF0ekHQEFH05gmoP4Ne0V54RrtUNDfi4,10524
66
65
  utilities/pytest_plugins/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
67
66
  utilities/pytest_plugins/pytest_randomly.py,sha256=B1qYVlExGOxTywq2r1SMi5o7btHLk2PNdY_b1p98dkE,409
68
67
  utilities/pytest_plugins/pytest_regressions.py,sha256=mnHYBfdprz50UGVkVzV1bZERZN5CFfoF8YbokGxdFwU,1639
69
- utilities/pytest_regressions.py,sha256=nKdVZAa88_aMo1a8V-EIHg_smZYdk1iDQBm50R3FlO4,4044
68
+ utilities/pytest_regressions.py,sha256=tJxW38u-zpoyjW1N4zogBx4V_07r-ibDInddcEUyXmc,4763
70
69
  utilities/random.py,sha256=hZlH4gnAtoaofWswuJYjcygejrY8db4CzP-z_adO2Mo,4165
71
70
  utilities/re.py,sha256=S4h-DLL6ScMPqjboZ_uQ1BVTJajrqV06r_81D--_HCE,4573
72
71
  utilities/redis.py,sha256=gybjqKea33Jy50n4dHTS14JdquqHaJqHF2dixQljYWQ,30172
@@ -81,7 +80,7 @@ utilities/sqlalchemy.py,sha256=HQYpd7LFxdTF5WYVWYtCJeEBI71EJm7ytvCGyAH9B-U,37163
81
80
  utilities/sqlalchemy_polars.py,sha256=JCGhB37raSR7fqeWV5dTsciRTMVzIdVT9YSqKT0piT0,13370
82
81
  utilities/statsmodels.py,sha256=koyiBHvpMcSiBfh99wFUfSggLNx7cuAw3rwyfAhoKpQ,3410
83
82
  utilities/string.py,sha256=shmBK87zZwzGyixuNuXCiUbqzfeZ9xlrFwz6JTaRvDk,582
84
- utilities/subprocess.py,sha256=7GEv6T9F7z9nacPymzOUUAF6r6ChPjGgKlL64Zrh-AU,34909
83
+ utilities/subprocess.py,sha256=C9H8GiV4gsNRRJY5mo8_2DcNqfeonxcfT5o6icQeSkg,40599
85
84
  utilities/tempfile.py,sha256=Lx6qa16lL1XVH6WdmD_G9vlN6gLI8nrIurxmsFkPKvg,3022
86
85
  utilities/testbook.py,sha256=j1KmaVbrX9VrbeMgtPh5gk55myAsn3dyRUn7jGbPbRk,1294
87
86
  utilities/text.py,sha256=7SvwcSR2l_5cOrm1samGnR4C-ZI6qyFLHLzSpO1zeHQ,13958
@@ -98,7 +97,7 @@ utilities/warnings.py,sha256=un1LvHv70PU-LLv8RxPVmugTzDJkkGXRMZTE2-fTQHw,1771
98
97
  utilities/whenever.py,sha256=F4ek0-OBWxHYrZdmoZt76N2RnNyKY5KrEHt7rqO4AQE,60183
99
98
  utilities/zipfile.py,sha256=24lQc9ATcJxHXBPc_tBDiJk48pWyRrlxO2fIsFxU0A8,699
100
99
  utilities/zoneinfo.py,sha256=tdIScrTB2-B-LH0ukb1HUXKooLknOfJNwHk10MuMYvA,3619
101
- dycw_utilities-0.174.19.dist-info/WHEEL,sha256=RRVLqVugUmFOqBedBFAmA4bsgFcROUBiSUKlERi0Hcg,79
102
- dycw_utilities-0.174.19.dist-info/entry_points.txt,sha256=ykGI1ArwOPHqm2g5Cqh3ENdMxEej_a_FcOUov5EM5Oc,155
103
- dycw_utilities-0.174.19.dist-info/METADATA,sha256=g_N5AxOBCGnPuLyOGvWEVdKj0mdu7pSyW2R6FBaPCmE,1710
104
- dycw_utilities-0.174.19.dist-info/RECORD,,
100
+ dycw_utilities-0.175.6.dist-info/WHEEL,sha256=RRVLqVugUmFOqBedBFAmA4bsgFcROUBiSUKlERi0Hcg,79
101
+ dycw_utilities-0.175.6.dist-info/entry_points.txt,sha256=cOGtKeJI0KXLSV7MJ8Dhc2G8jPgDcBDm53MVNJU4ycI,136
102
+ dycw_utilities-0.175.6.dist-info/METADATA,sha256=761YNYgBWP5Xs0xN_xnVhdet11rc3wj-eIQfF_XoqNk,1442
103
+ dycw_utilities-0.175.6.dist-info/RECORD,,
@@ -1,5 +1,3 @@
1
- [console_scripts]
2
-
3
1
  [pytest11]
4
2
  pytest-randomly = utilities.pytest_plugins.pytest_randomly
5
3
  pytest-regressions = utilities.pytest_plugins.pytest_regressions
utilities/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.174.19"
3
+ __version__ = "0.175.6"
utilities/altair.py CHANGED
@@ -145,7 +145,9 @@ def plot_dataframes(
145
145
  ]
146
146
  zoom = selection_interval(bind="scales", encodings=["x"])
147
147
  chart = (
148
- vconcat(*layers).add_params(zoom).resolve_scale(color="independent", x="shared")
148
+ vconcat_charts(*layers)
149
+ .add_params(zoom)
150
+ .resolve_scale(color="independent", x="shared")
149
151
  )
150
152
  if title is not None:
151
153
  chart = chart.properties(title=title)
@@ -1,15 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from contextlib import suppress
4
+ from dataclasses import dataclass
4
5
  from json import loads
5
6
  from pathlib import Path
6
7
  from shutil import copytree
7
- from typing import TYPE_CHECKING, Any, assert_never
8
+ from typing import TYPE_CHECKING, Any, assert_never, override
8
9
 
9
10
  from pytest_regressions.file_regression import FileRegressionFixture
10
11
 
11
12
  from utilities.functions import ensure_str
12
13
  from utilities.operator import is_equal
14
+ from utilities.reprlib import get_repr
13
15
 
14
16
  if TYPE_CHECKING:
15
17
  from polars import DataFrame, Series
@@ -70,10 +72,28 @@ class OrjsonRegressionFixture:
70
72
  check_fn=self._check_fn,
71
73
  )
72
74
 
73
- def _check_fn(self, path1: Path, path2: Path, /) -> None:
74
- left = loads(path1.read_text())
75
- right = loads(path2.read_text())
76
- assert is_equal(left, right), f"{left=}, {right=}"
75
+ def _check_fn(self, path_obtained: Path, path_existing: Path, /) -> None:
76
+ obtained = loads(path_obtained.read_text())
77
+ existing = loads(path_existing.read_text())
78
+ if not is_equal(obtained, existing):
79
+ raise OrjsonRegressionError(
80
+ path_obtained=path_obtained,
81
+ path_existing=path_existing,
82
+ obtained=obtained,
83
+ existing=existing,
84
+ )
85
+
86
+
87
+ @dataclass(kw_only=True, slots=True)
88
+ class OrjsonRegressionError(Exception):
89
+ path_obtained: Path
90
+ path_existing: Path
91
+ obtained: Any
92
+ existing: Any
93
+
94
+ @override
95
+ def __str__(self) -> str:
96
+ return f"Obtained object (at {str(self.path_obtained)!r}) and existing object (at {str(self.path_existing)!r}) differ; got {get_repr(self.obtained)} and {get_repr(self.existing)}"
77
97
 
78
98
 
79
99
  ##
utilities/subprocess.py CHANGED
@@ -6,6 +6,7 @@ from contextlib import contextmanager
6
6
  from dataclasses import dataclass
7
7
  from io import StringIO
8
8
  from pathlib import Path
9
+ from re import search
9
10
  from shlex import join
10
11
  from shutil import copyfile, copytree, move, rmtree
11
12
  from string import Template
@@ -23,7 +24,7 @@ from utilities.text import strip_and_dedent
23
24
  from utilities.whenever import to_seconds
24
25
 
25
26
  if TYPE_CHECKING:
26
- from collections.abc import Iterator
27
+ from collections.abc import Callable, Iterator
27
28
 
28
29
  from utilities.permissions import PermissionsLike
29
30
  from utilities.types import (
@@ -41,6 +42,7 @@ APT_UPDATE = ["apt", "update", "-y"]
41
42
  BASH_LC = ["bash", "-lc"]
42
43
  BASH_LS = ["bash", "-ls"]
43
44
  GIT_BRANCH_SHOW_CURRENT = ["git", "branch", "--show-current"]
45
+ KNOWN_HOSTS = Path.home() / ".ssh/known_hosts"
44
46
  MKTEMP_DIR_CMD = ["mktemp", "-d"]
45
47
  RESTART_SSHD = ["systemctl", "restart", "sshd"]
46
48
  UPDATE_CA_CERTIFICATES: str = "update-ca-certificates"
@@ -49,6 +51,13 @@ UPDATE_CA_CERTIFICATES: str = "update-ca-certificates"
49
51
  ##
50
52
 
51
53
 
54
+ def apt_install(package: str, /, *, update: bool = False, sudo: bool = False) -> None:
55
+ """Install a package."""
56
+ if update: # pragma: no cover
57
+ run(*maybe_sudo_cmd(*APT_UPDATE, sudo=sudo))
58
+ run(*maybe_sudo_cmd(*apt_install_cmd(package), sudo=sudo))
59
+
60
+
52
61
  def apt_install_cmd(package: str, /) -> list[str]:
53
62
  """Command to use 'apt' to install a package."""
54
63
  return ["apt", "install", "-y", package]
@@ -250,7 +259,7 @@ def git_clone(
250
259
  rm(path, sudo=sudo)
251
260
  run(*maybe_sudo_cmd(*git_clone_cmd(url, path), sudo=sudo))
252
261
  if branch is not None:
253
- run(*maybe_sudo_cmd(*git_hard_reset_cmd(branch=branch), sudo=sudo), cwd=path)
262
+ git_checkout(branch, path)
254
263
 
255
264
 
256
265
  def git_clone_cmd(url: str, path: PathLike, /) -> list[str]:
@@ -261,15 +270,6 @@ def git_clone_cmd(url: str, path: PathLike, /) -> list[str]:
261
270
  ##
262
271
 
263
272
 
264
- def git_hard_reset_cmd(*, branch: str | None = None) -> list[str]:
265
- """Command to use 'git hard-reset' to hard reset a repository."""
266
- branch_use = "master" if branch is None else branch
267
- return ["git", "hard-reset", branch_use]
268
-
269
-
270
- ##
271
-
272
-
273
273
  def maybe_parent(path: PathLike, /, *, parent: bool = False) -> Path:
274
274
  """Get the parent of a path, if required."""
275
275
  path = Path(path)
@@ -525,6 +525,7 @@ def rsync_many(
525
525
  host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
526
526
  strict_host_key_checking: bool = True,
527
527
  print: bool = False, # noqa: A002
528
+ exclude: MaybeIterable[str] | None = None,
528
529
  ) -> None:
529
530
  cmds: list[list[str]] = [] # skipif-ci
530
531
  with ( # skipif-ci
@@ -562,6 +563,7 @@ def rsync_many(
562
563
  print=print,
563
564
  retry=retry,
564
565
  logger=logger,
566
+ exclude=exclude,
565
567
  )
566
568
  ssh(
567
569
  user,
@@ -627,6 +629,7 @@ def run(
627
629
  return_stdout: bool = False,
628
630
  return_stderr: bool = False,
629
631
  retry: Retry | None = None,
632
+ retry_skip: Callable[[int, str, str], bool] | None = None,
630
633
  logger: LoggerLike | None = None,
631
634
  ) -> str: ...
632
635
  @overload
@@ -647,6 +650,7 @@ def run(
647
650
  return_stdout: Literal[True],
648
651
  return_stderr: bool = False,
649
652
  retry: Retry | None = None,
653
+ retry_skip: Callable[[int, str, str], bool] | None = None,
650
654
  logger: LoggerLike | None = None,
651
655
  ) -> str: ...
652
656
  @overload
@@ -667,6 +671,7 @@ def run(
667
671
  return_stdout: bool = False,
668
672
  return_stderr: Literal[True],
669
673
  retry: Retry | None = None,
674
+ retry_skip: Callable[[int, str, str], bool] | None = None,
670
675
  logger: LoggerLike | None = None,
671
676
  ) -> str: ...
672
677
  @overload
@@ -687,6 +692,7 @@ def run(
687
692
  return_stdout: Literal[False] = False,
688
693
  return_stderr: Literal[False] = False,
689
694
  retry: Retry | None = None,
695
+ retry_skip: Callable[[int, str, str], bool] | None = None,
690
696
  logger: LoggerLike | None = None,
691
697
  ) -> None: ...
692
698
  @overload
@@ -707,6 +713,7 @@ def run(
707
713
  return_stdout: bool = False,
708
714
  return_stderr: bool = False,
709
715
  retry: Retry | None = None,
716
+ retry_skip: Callable[[int, str, str], bool] | None = None,
710
717
  logger: LoggerLike | None = None,
711
718
  ) -> str | None: ...
712
719
  def run(
@@ -726,6 +733,7 @@ def run(
726
733
  return_stdout: bool = False,
727
734
  return_stderr: bool = False,
728
735
  retry: Retry | None = None,
736
+ retry_skip: Callable[[int, str, str], bool] | None = None,
729
737
  logger: LoggerLike | None = None,
730
738
  ) -> str | None:
731
739
  """Run a command in a subprocess."""
@@ -783,14 +791,18 @@ def run(
783
791
  case 0, False, False:
784
792
  return None
785
793
  case _, _, _:
786
- if retry is None:
787
- attempts = delta = None
788
- else:
789
- attempts, delta = retry
790
794
  _ = stdout.seek(0)
791
795
  stdout_text = stdout.read()
792
796
  _ = stderr.seek(0)
793
797
  stderr_text = stderr.read()
798
+ if (retry is None) or (
799
+ (retry is not None)
800
+ and (retry_skip is not None)
801
+ and retry_skip(return_code, stdout_text, stderr_text)
802
+ ):
803
+ attempts = delta = None
804
+ else:
805
+ attempts, delta = retry
794
806
  if logger is not None:
795
807
  msg = strip_and_dedent(f"""
796
808
  'run' failed with:
@@ -885,6 +897,7 @@ def ssh(
885
897
  batch_mode: bool = True,
886
898
  host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
887
899
  strict_host_key_checking: bool = True,
900
+ port: int | None = None,
888
901
  input: str | None = None,
889
902
  print: bool = False,
890
903
  print_stdout: bool = False,
@@ -904,6 +917,7 @@ def ssh(
904
917
  batch_mode: bool = True,
905
918
  host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
906
919
  strict_host_key_checking: bool = True,
920
+ port: int | None = None,
907
921
  input: str | None = None,
908
922
  print: bool = False,
909
923
  print_stdout: bool = False,
@@ -923,6 +937,7 @@ def ssh(
923
937
  batch_mode: bool = True,
924
938
  host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
925
939
  strict_host_key_checking: bool = True,
940
+ port: int | None = None,
926
941
  input: str | None = None,
927
942
  print: bool = False,
928
943
  print_stdout: bool = False,
@@ -942,6 +957,7 @@ def ssh(
942
957
  batch_mode: bool = True,
943
958
  host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
944
959
  strict_host_key_checking: bool = True,
960
+ port: int | None = None,
945
961
  input: str | None = None,
946
962
  print: bool = False,
947
963
  print_stdout: bool = False,
@@ -961,6 +977,7 @@ def ssh(
961
977
  batch_mode: bool = True,
962
978
  host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
963
979
  strict_host_key_checking: bool = True,
980
+ port: int | None = None,
964
981
  input: str | None = None,
965
982
  print: bool = False,
966
983
  print_stdout: bool = False,
@@ -979,6 +996,7 @@ def ssh(
979
996
  batch_mode: bool = True,
980
997
  host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
981
998
  strict_host_key_checking: bool = True,
999
+ port: int | None = None,
982
1000
  input: str | None = None, # noqa: A002
983
1001
  print: bool = False, # noqa: A002
984
1002
  print_stdout: bool = False,
@@ -997,19 +1015,57 @@ def ssh(
997
1015
  batch_mode=batch_mode,
998
1016
  host_key_algorithms=host_key_algorithms,
999
1017
  strict_host_key_checking=strict_host_key_checking,
1018
+ port=port,
1000
1019
  )
1001
- return run( # skipif-ci
1002
- *cmd_and_args,
1003
- input=input,
1004
- print=print,
1005
- print_stdout=print_stdout,
1006
- print_stderr=print_stderr,
1007
- return_=return_,
1008
- return_stdout=return_stdout,
1009
- return_stderr=return_stderr,
1010
- retry=retry,
1011
- logger=logger,
1020
+ try: # skipif-ci
1021
+ return run(
1022
+ *cmd_and_args,
1023
+ input=input,
1024
+ print=print,
1025
+ print_stdout=print_stdout,
1026
+ print_stderr=print_stderr,
1027
+ return_=return_,
1028
+ return_stdout=return_stdout,
1029
+ return_stderr=return_stderr,
1030
+ retry=retry,
1031
+ retry_skip=_ssh_retry_skip,
1032
+ logger=logger,
1033
+ )
1034
+ except CalledProcessError as error: # skipif-ci
1035
+ if not _ssh_is_strict_checking_error(error.stderr):
1036
+ raise
1037
+ ssh_keyscan(hostname, port=port)
1038
+ return ssh(
1039
+ user,
1040
+ hostname,
1041
+ *cmd_and_cmds_or_args,
1042
+ batch_mode=batch_mode,
1043
+ host_key_algorithms=host_key_algorithms,
1044
+ strict_host_key_checking=strict_host_key_checking,
1045
+ port=port,
1046
+ input=input,
1047
+ print=print,
1048
+ print_stdout=print_stdout,
1049
+ print_stderr=print_stderr,
1050
+ return_=return_,
1051
+ return_stdout=return_stdout,
1052
+ return_stderr=return_stderr,
1053
+ retry=retry,
1054
+ logger=logger,
1055
+ )
1056
+
1057
+
1058
+ def _ssh_retry_skip(return_code: int, stdout: str, stderr: str, /) -> bool:
1059
+ _ = (return_code, stdout)
1060
+ return _ssh_is_strict_checking_error(stderr)
1061
+
1062
+
1063
+ def _ssh_is_strict_checking_error(text: str, /) -> bool:
1064
+ match = search(
1065
+ "No ED25519 host key is known for .* and you have requested strict checking",
1066
+ text,
1012
1067
  )
1068
+ return match is not None
1013
1069
 
1014
1070
 
1015
1071
  def ssh_cmd(
@@ -1020,12 +1076,14 @@ def ssh_cmd(
1020
1076
  batch_mode: bool = True,
1021
1077
  host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
1022
1078
  strict_host_key_checking: bool = True,
1079
+ port: int | None = None,
1023
1080
  ) -> list[str]:
1024
1081
  """Command to use 'ssh' to execute a command on a remote machine."""
1025
1082
  args: list[str] = ssh_opts_cmd(
1026
1083
  batch_mode=batch_mode,
1027
1084
  host_key_algorithms=host_key_algorithms,
1028
1085
  strict_host_key_checking=strict_host_key_checking,
1086
+ port=port,
1029
1087
  )
1030
1088
  return [*args, f"{user}@{hostname}", *cmd_and_cmds_or_args]
1031
1089
 
@@ -1035,6 +1093,7 @@ def ssh_opts_cmd(
1035
1093
  batch_mode: bool = True,
1036
1094
  host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
1037
1095
  strict_host_key_checking: bool = True,
1096
+ port: int | None = None,
1038
1097
  ) -> list[str]:
1039
1098
  """Command to use prepare 'ssh' to execute a command on a remote machine."""
1040
1099
  args: list[str] = ["ssh"]
@@ -1043,15 +1102,47 @@ def ssh_opts_cmd(
1043
1102
  args.extend(["-o", f"HostKeyAlgorithms={','.join(host_key_algorithms)}"])
1044
1103
  if strict_host_key_checking:
1045
1104
  args.extend(["-o", "StrictHostKeyChecking=yes"])
1105
+ if port is not None:
1106
+ args.extend(["-p", str(port)])
1046
1107
  return [*args, "-T"]
1047
1108
 
1048
1109
 
1049
1110
  ##
1050
1111
 
1051
1112
 
1052
- def ssh_keygen_cmd(hostname: str, /) -> list[str]:
1053
- """Command to use 'ssh-keygen' to add a known host."""
1054
- return ["ssh-keygen", "-f", "~/.ssh/known_hosts", "-R", hostname]
1113
+ def ssh_keyscan(
1114
+ hostname: str, /, *, path: PathLike = KNOWN_HOSTS, port: int | None = None
1115
+ ) -> None:
1116
+ """Add a known host."""
1117
+ ssh_keygen_remove(hostname, path=path) # skipif-ci
1118
+ mkdir(path, parent=True) # skipif-ci
1119
+ with Path(path).open(mode="a") as fh: # skipif-ci
1120
+ _ = fh.write(run(*ssh_keyscan_cmd(hostname, port=port), return_=True))
1121
+
1122
+
1123
+ def ssh_keyscan_cmd(hostname: str, /, *, port: int | None = None) -> list[str]:
1124
+ """Command to use 'ssh-keyscan' to add a known host."""
1125
+ args: list[str] = ["ssh-keyscan"]
1126
+ if port is not None:
1127
+ args.extend(["-p", str(port)])
1128
+ return [*args, "-q", "-t", "ed25519", hostname]
1129
+
1130
+
1131
+ ##
1132
+
1133
+
1134
+ def ssh_keygen_remove(hostname: str, /, *, path: PathLike = KNOWN_HOSTS) -> None:
1135
+ """Remove a known host."""
1136
+ path = Path(path)
1137
+ if path.exists():
1138
+ run(*ssh_keygen_remove_cmd(hostname, path=path))
1139
+
1140
+
1141
+ def ssh_keygen_remove_cmd(
1142
+ hostname: str, /, *, path: PathLike = KNOWN_HOSTS
1143
+ ) -> list[str]:
1144
+ """Command to use 'ssh-keygen' to remove a known host."""
1145
+ return ["ssh-keygen", "-f", str(path), "-R", hostname]
1055
1146
 
1056
1147
 
1057
1148
  ##
@@ -1134,9 +1225,108 @@ def touch_cmd(path: PathLike, /) -> list[str]:
1134
1225
  ##
1135
1226
 
1136
1227
 
1137
- def uv_run(module: str, /, *args: str) -> None:
1228
+ @overload
1229
+ def uv_run(
1230
+ module: str,
1231
+ /,
1232
+ *args: str,
1233
+ cwd: PathLike | None = None,
1234
+ print: bool = False,
1235
+ print_stdout: bool = False,
1236
+ print_stderr: bool = False,
1237
+ return_: Literal[True],
1238
+ return_stdout: bool = False,
1239
+ return_stderr: bool = False,
1240
+ retry: Retry | None = None,
1241
+ logger: LoggerLike | None = None,
1242
+ ) -> str: ...
1243
+ @overload
1244
+ def uv_run(
1245
+ module: str,
1246
+ /,
1247
+ *args: str,
1248
+ cwd: PathLike | None = None,
1249
+ print: bool = False,
1250
+ print_stdout: bool = False,
1251
+ print_stderr: bool = False,
1252
+ return_: bool = False,
1253
+ return_stdout: Literal[True],
1254
+ return_stderr: bool = False,
1255
+ retry: Retry | None = None,
1256
+ logger: LoggerLike | None = None,
1257
+ ) -> str: ...
1258
+ @overload
1259
+ def uv_run(
1260
+ module: str,
1261
+ /,
1262
+ *args: str,
1263
+ cwd: PathLike | None = None,
1264
+ print: bool = False,
1265
+ print_stdout: bool = False,
1266
+ print_stderr: bool = False,
1267
+ return_: bool = False,
1268
+ return_stdout: bool = False,
1269
+ return_stderr: Literal[True],
1270
+ retry: Retry | None = None,
1271
+ logger: LoggerLike | None = None,
1272
+ ) -> str: ...
1273
+ @overload
1274
+ def uv_run(
1275
+ module: str,
1276
+ /,
1277
+ *args: str,
1278
+ cwd: PathLike | None = None,
1279
+ print: bool = False,
1280
+ print_stdout: bool = False,
1281
+ print_stderr: bool = False,
1282
+ return_: Literal[False] = False,
1283
+ return_stdout: Literal[False] = False,
1284
+ return_stderr: Literal[False] = False,
1285
+ retry: Retry | None = None,
1286
+ logger: LoggerLike | None = None,
1287
+ ) -> None: ...
1288
+ @overload
1289
+ def uv_run(
1290
+ module: str,
1291
+ /,
1292
+ *args: str,
1293
+ cwd: PathLike | None = None,
1294
+ print: bool = False,
1295
+ print_stdout: bool = False,
1296
+ print_stderr: bool = False,
1297
+ return_: bool = False,
1298
+ return_stdout: bool = False,
1299
+ return_stderr: bool = False,
1300
+ retry: Retry | None = None,
1301
+ logger: LoggerLike | None = None,
1302
+ ) -> str | None: ...
1303
+ def uv_run(
1304
+ module: str,
1305
+ /,
1306
+ *args: str,
1307
+ cwd: PathLike | None = None,
1308
+ print: bool = False, # noqa: A002
1309
+ print_stdout: bool = False,
1310
+ print_stderr: bool = False,
1311
+ return_: bool = False,
1312
+ return_stdout: bool = False,
1313
+ return_stderr: bool = False,
1314
+ retry: Retry | None = None,
1315
+ logger: LoggerLike | None = None,
1316
+ ) -> str | None:
1138
1317
  """Run a command or script."""
1139
- run(*uv_run_cmd(module, *args)) # pragma: no cover
1318
+ return run( # pragma: no cover
1319
+ *uv_run_cmd(module, *args),
1320
+ cwd=cwd,
1321
+ print=print,
1322
+ print_stdout=print_stdout,
1323
+ print_stderr=print_stderr,
1324
+ return_=return_,
1325
+ return_stdout=return_stdout,
1326
+ return_stderr=return_stderr,
1327
+ retry=retry,
1328
+ logger=logger,
1329
+ )
1140
1330
 
1141
1331
 
1142
1332
  def uv_run_cmd(module: str, /, *args: str) -> list[str]:
@@ -1207,6 +1397,7 @@ __all__ = [
1207
1397
  "RsyncCmdError",
1208
1398
  "RsyncCmdNoSourcesError",
1209
1399
  "RsyncCmdSourcesNotFoundError",
1400
+ "apt_install",
1210
1401
  "apt_install_cmd",
1211
1402
  "cd_cmd",
1212
1403
  "chmod",
@@ -1222,7 +1413,6 @@ __all__ = [
1222
1413
  "git_checkout_cmd",
1223
1414
  "git_clone",
1224
1415
  "git_clone_cmd",
1225
- "git_hard_reset_cmd",
1226
1416
  "maybe_parent",
1227
1417
  "maybe_sudo_cmd",
1228
1418
  "mkdir",
@@ -1238,6 +1428,10 @@ __all__ = [
1238
1428
  "set_hostname_cmd",
1239
1429
  "ssh",
1240
1430
  "ssh_cmd",
1431
+ "ssh_keygen_remove",
1432
+ "ssh_keygen_remove_cmd",
1433
+ "ssh_keyscan",
1434
+ "ssh_keyscan_cmd",
1241
1435
  "ssh_opts_cmd",
1242
1436
  "sudo_cmd",
1243
1437
  "sudo_nopasswd_cmd",
utilities/aeventkit.py DELETED
@@ -1,389 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass
4
- from functools import wraps
5
- from inspect import iscoroutinefunction
6
- from typing import TYPE_CHECKING, Any, Self, assert_never, cast, override
7
-
8
- from eventkit import (
9
- Constant,
10
- Count,
11
- DropWhile,
12
- Enumerate,
13
- Event,
14
- Filter,
15
- Fork,
16
- Iterate,
17
- Map,
18
- Pack,
19
- Partial,
20
- PartialRight,
21
- Pluck,
22
- Skip,
23
- Star,
24
- Take,
25
- TakeUntil,
26
- TakeWhile,
27
- Timestamp,
28
- )
29
-
30
- from utilities.functions import apply_decorators
31
- from utilities.iterables import always_iterable
32
- from utilities.logging import to_logger
33
-
34
- if TYPE_CHECKING:
35
- from collections.abc import Callable
36
-
37
- from utilities.types import Coro, LoggerLike, MaybeCoro, MaybeIterable, TypeLike
38
-
39
-
40
- ##
41
-
42
-
43
- def add_listener[E: Event, F: Callable](
44
- event: E,
45
- listener: Callable[..., MaybeCoro[None]],
46
- /,
47
- *,
48
- error: Callable[[Event, BaseException], MaybeCoro[None]] | None = None,
49
- ignore: TypeLike[BaseException] | None = None,
50
- logger: LoggerLike | None = None,
51
- decorators: MaybeIterable[Callable[[F], F]] | None = None,
52
- done: Callable[..., MaybeCoro[None]] | None = None,
53
- keep_ref: bool = False,
54
- ) -> E:
55
- """Connect a listener to an event."""
56
- lifted = lift_listener(
57
- listener,
58
- event,
59
- error=error,
60
- ignore=ignore,
61
- logger=logger,
62
- decorators=decorators,
63
- )
64
- return cast("E", event.connect(lifted, done=done, keep_ref=keep_ref))
65
-
66
-
67
- ##
68
-
69
-
70
- @dataclass(repr=False, kw_only=True)
71
- class LiftedEvent[F: Callable[..., MaybeCoro[None]]]:
72
- """A lifted version of `Event`."""
73
-
74
- event: Event
75
-
76
- def name(self) -> str:
77
- return self.event.name() # pragma: no cover
78
-
79
- def done(self) -> bool:
80
- return self.event.done() # pragma: no cover
81
-
82
- def set_done(self) -> None:
83
- self.event.set_done() # pragma: no cover
84
-
85
- def value(self) -> Any:
86
- return self.event.value() # pragma: no cover
87
-
88
- def connect[F2: Callable](
89
- self,
90
- listener: F,
91
- /,
92
- *,
93
- error: Callable[[Event, BaseException], MaybeCoro[None]] | None = None,
94
- ignore: TypeLike[BaseException] | None = None,
95
- logger: LoggerLike | None = None,
96
- decorators: MaybeIterable[Callable[[F2], F2]] | None = None,
97
- done: Callable[..., MaybeCoro[None]] | None = None,
98
- keep_ref: bool = False,
99
- ) -> Event:
100
- return add_listener(
101
- self.event,
102
- listener,
103
- error=error,
104
- ignore=ignore,
105
- logger=logger,
106
- decorators=decorators,
107
- done=done,
108
- keep_ref=keep_ref,
109
- )
110
-
111
- def disconnect(
112
- self, listener: Any, /, *, error: Any = None, done: Any = None
113
- ) -> Any:
114
- return self.event.disconnect( # pragma: no cover
115
- listener, error=error, done=done
116
- )
117
-
118
- def disconnect_obj(self, obj: Any, /) -> None:
119
- self.event.disconnect_obj(obj) # pragma: no cover
120
-
121
- def emit(self, *args: Any) -> None:
122
- self.event.emit(*args) # pragma: no cover
123
-
124
- def emit_threadsafe(self, *args: Any) -> None:
125
- self.event.emit_threadsafe(*args) # pragma: no cover
126
-
127
- def clear(self) -> None:
128
- self.event.clear() # pragma: no cover
129
-
130
- def run(self) -> list[Any]:
131
- return self.event.run() # pragma: no cover
132
-
133
- def pipe(self, *targets: Event) -> Event:
134
- return self.event.pipe(*targets) # pragma: no cover
135
-
136
- def fork(self, *targets: Event) -> Fork:
137
- return self.event.fork(*targets) # pragma: no cover
138
-
139
- def set_source(self, source: Any, /) -> None:
140
- self.event.set_source(source) # pragma: no cover
141
-
142
- def _onFinalize(self, ref: Any) -> None: # noqa: N802
143
- self.event._onFinalize(ref) # noqa: SLF001 # pragma: no cover
144
-
145
- async def aiter(self, *, skip_to_last: bool = False, tuples: bool = False) -> Any:
146
- async for i in self.event.aiter( # pragma: no cover
147
- skip_to_last=skip_to_last, tuples=tuples
148
- ):
149
- yield i
150
-
151
- __iadd__ = connect
152
- __isub__ = disconnect
153
- __call__ = emit
154
- __or__ = pipe
155
-
156
- @override
157
- def __repr__(self) -> str:
158
- return self.event.__repr__() # pragma: no cover
159
-
160
- def __len__(self) -> int:
161
- return self.event.__len__() # pragma: no cover
162
-
163
- def __bool__(self) -> bool:
164
- return self.event.__bool__() # pragma: no cover
165
-
166
- def __getitem__(self, fork_targets: Any, /) -> Fork:
167
- return self.event.__getitem__(fork_targets) # pragma: no cover
168
-
169
- def __await__(self) -> Any:
170
- return self.event.__await__() # pragma: no cover
171
-
172
- def __aiter__(self) -> Any:
173
- return self.event.aiter() # pragma: no cover
174
-
175
- def __contains__(self, c: Any, /) -> bool:
176
- return self.event.__contains__(c) # pragma: no cover
177
-
178
- @override
179
- def __reduce__(self) -> Any:
180
- return self.event.__reduce__() # pragma: no cover
181
-
182
- def filter(self, *, predicate: Any = bool) -> Filter:
183
- return self.event.filter(predicate=predicate) # pragma: no cover
184
-
185
- def skip(self, *, count: int = 1) -> Skip:
186
- return self.event.skip(count=count) # pragma: no cover
187
-
188
- def take(self, *, count: int = 1) -> Take:
189
- return self.event.take(count=count) # pragma: no cover
190
-
191
- def takewhile(self, *, predicate: Any = bool) -> TakeWhile:
192
- return self.event.takewhile(predicate=predicate) # pragma: no cover
193
-
194
- def dropwhile(self, *, predicate: Any = lambda x: not x) -> DropWhile: # pyright: ignore[reportUnknownLambdaType]
195
- return self.event.dropwhile(predicate=predicate) # pragma: no cover
196
-
197
- def takeuntil(self, notifier: Event, /) -> TakeUntil:
198
- return self.event.takeuntil(notifier) # pragma: no cover
199
-
200
- def constant(self, constant: Any, /) -> Constant:
201
- return self.event.constant(constant) # pragma: no cover
202
-
203
- def iterate(self, it: Any, /) -> Iterate:
204
- return self.event.iterate(it) # pragma: no cover
205
-
206
- def count(self, *, start: int = 0, step: int = 1) -> Count:
207
- return self.event.count(start=start, step=step) # pragma: no cover
208
-
209
- def enumerate(self, *, start: int = 0, step: int = 1) -> Enumerate:
210
- return self.event.enumerate(start=start, step=step) # pragma: no cover
211
-
212
- def timestamp(self) -> Timestamp:
213
- return self.event.timestamp() # pragma: no cover
214
-
215
- def partial(self, *left_args: Any) -> Partial:
216
- return self.event.partial(*left_args) # pragma: no cover
217
-
218
- def partial_right(self, *right_args: Any) -> PartialRight:
219
- return self.event.partial_right(*right_args) # pragma: no cover
220
-
221
- def star(self) -> Star:
222
- return self.event.star() # pragma: no cover
223
-
224
- def pack(self) -> Pack:
225
- return self.event.pack() # pragma: no cover
226
-
227
- def pluck(self, *selections: int | str) -> Pluck:
228
- return self.event.pluck(*selections) # pragma: no cover
229
-
230
- def map(
231
- self,
232
- func: Any,
233
- /,
234
- *,
235
- timeout: float | None = None,
236
- ordered: bool = True,
237
- task_limit: int | None = None,
238
- ) -> Map:
239
- return self.event.map( # pragma: no cover
240
- func, timeout=timeout, ordered=ordered, task_limit=task_limit
241
- )
242
-
243
-
244
- ##
245
-
246
-
247
- class TypedEvent[F: Callable[..., MaybeCoro[None]]](Event):
248
- """A typed version of `Event`."""
249
-
250
- @override
251
- def connect[F2: Callable](
252
- self,
253
- listener: F,
254
- error: Callable[[Self, BaseException], MaybeCoro[None]] | None = None,
255
- done: Callable[[Self], MaybeCoro[None]] | None = None,
256
- keep_ref: bool = False,
257
- *,
258
- ignore: TypeLike[BaseException] | None = None,
259
- logger: LoggerLike | None = None,
260
- decorators: MaybeIterable[Callable[[F2], F2]] | None = None,
261
- ) -> Self:
262
- lifted = lift_listener(
263
- listener,
264
- self,
265
- error=cast(
266
- "Callable[[Event, BaseException], MaybeCoro[None]] | None", error
267
- ),
268
- ignore=ignore,
269
- logger=logger,
270
- decorators=decorators,
271
- )
272
- return cast(
273
- "Self", super().connect(lifted, error=error, done=done, keep_ref=keep_ref)
274
- )
275
-
276
-
277
- ##
278
-
279
-
280
- def lift_listener[F1: Callable[..., MaybeCoro[None]], F2: Callable](
281
- listener: F1,
282
- event: Event,
283
- /,
284
- *,
285
- error: Callable[[Event, BaseException], MaybeCoro[None]] | None = None,
286
- ignore: TypeLike[BaseException] | None = None,
287
- logger: LoggerLike | None = None,
288
- decorators: MaybeIterable[Callable[[F2], F2]] | None = None,
289
- ) -> F1:
290
- match error, bool(iscoroutinefunction(listener)):
291
- case None, False:
292
- listener_typed = cast("Callable[..., None]", listener)
293
-
294
- @wraps(listener)
295
- def listener_no_error_sync(*args: Any, **kwargs: Any) -> None:
296
- try:
297
- listener_typed(*args, **kwargs)
298
- except Exception as exc: # noqa: BLE001
299
- if (ignore is not None) and isinstance(exc, ignore):
300
- return
301
- to_logger(logger).exception("")
302
-
303
- lifted = listener_no_error_sync
304
-
305
- case None, True:
306
- listener_typed = cast("Callable[..., Coro[None]]", listener)
307
-
308
- @wraps(listener)
309
- async def listener_no_error_async(*args: Any, **kwargs: Any) -> None:
310
- try:
311
- await listener_typed(*args, **kwargs)
312
- except Exception as exc: # noqa: BLE001
313
- if (ignore is not None) and isinstance(exc, ignore):
314
- return
315
- to_logger(logger).exception("")
316
-
317
- lifted = listener_no_error_async
318
- case _, _:
319
- match bool(iscoroutinefunction(listener)), bool(iscoroutinefunction(error)):
320
- case False, False:
321
- listener_typed = cast("Callable[..., None]", listener)
322
- error_typed = cast("Callable[[Event, Exception], None]", error)
323
-
324
- @wraps(listener)
325
- def listener_have_error_sync(*args: Any, **kwargs: Any) -> None:
326
- try:
327
- listener_typed(*args, **kwargs)
328
- except Exception as exc: # noqa: BLE001
329
- if (ignore is not None) and isinstance(exc, ignore):
330
- return
331
- error_typed(event, exc)
332
-
333
- lifted = listener_have_error_sync
334
- case False, True:
335
- listener_typed = cast("Callable[..., None]", listener)
336
- error_typed = cast(
337
- "Callable[[Event, Exception], Coro[None]]", error
338
- )
339
- raise LiftListenerError(listener=listener_typed, error=error_typed)
340
- case True, _:
341
- listener_typed = cast("Callable[..., Coro[None]]", listener)
342
-
343
- @wraps(listener)
344
- async def listener_have_error_async(
345
- *args: Any, **kwargs: Any
346
- ) -> None:
347
- try:
348
- await listener_typed(*args, **kwargs)
349
- except Exception as exc: # noqa: BLE001
350
- if (ignore is not None) and isinstance(exc, ignore):
351
- return None
352
- if iscoroutinefunction(error):
353
- error_typed = cast(
354
- "Callable[[Event, Exception], Coro[None]]", error
355
- )
356
- return await error_typed(event, exc)
357
- error_typed = cast(
358
- "Callable[[Event, Exception], None]", error
359
- )
360
- error_typed(event, exc)
361
-
362
- lifted = listener_have_error_async
363
- case never:
364
- assert_never(never)
365
- case never:
366
- assert_never(never)
367
-
368
- if decorators is not None:
369
- lifted = apply_decorators(lifted, *always_iterable(decorators))
370
- return cast("F1", lifted)
371
-
372
-
373
- @dataclass(kw_only=True, slots=True)
374
- class LiftListenerError(Exception):
375
- listener: Callable[..., None]
376
- error: Callable[[Event, Exception], Coro[None]]
377
-
378
- @override
379
- def __str__(self) -> str:
380
- return f"Synchronous listener {self.listener} cannot be paired with an asynchronous error handler {self.error}"
381
-
382
-
383
- __all__ = [
384
- "LiftListenerError",
385
- "LiftedEvent",
386
- "TypedEvent",
387
- "add_listener",
388
- "lift_listener",
389
- ]