dycw-utilities 0.174.2__tar.gz → 0.174.4__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 (102) hide show
  1. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/PKG-INFO +1 -1
  2. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/pyproject.toml +2 -2
  3. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/__init__.py +1 -1
  4. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/docker.py +29 -4
  5. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/subprocess.py +80 -82
  6. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/types.py +5 -0
  7. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/README.md +0 -0
  8. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/aeventkit.py +0 -0
  9. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/altair.py +0 -0
  10. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/asyncio.py +0 -0
  11. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/atomicwrites.py +0 -0
  12. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/atools.py +0 -0
  13. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/cachetools.py +0 -0
  14. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/click.py +0 -0
  15. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/concurrent.py +0 -0
  16. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/contextlib.py +0 -0
  17. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/contextvars.py +0 -0
  18. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/cryptography.py +0 -0
  19. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/cvxpy.py +0 -0
  20. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/dataclasses.py +0 -0
  21. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/enum.py +0 -0
  22. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/errors.py +0 -0
  23. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/fastapi.py +0 -0
  24. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/fpdf2.py +0 -0
  25. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/functions.py +0 -0
  26. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/functools.py +0 -0
  27. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/getpass.py +0 -0
  28. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/git.py +0 -0
  29. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/grp.py +0 -0
  30. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/gzip.py +0 -0
  31. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/hashlib.py +0 -0
  32. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/http.py +0 -0
  33. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/hypothesis.py +0 -0
  34. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/importlib.py +0 -0
  35. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/inflect.py +0 -0
  36. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/ipython.py +0 -0
  37. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/iterables.py +0 -0
  38. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/jinja2.py +0 -0
  39. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/json.py +0 -0
  40. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/jupyter.py +0 -0
  41. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/libcst.py +0 -0
  42. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/lightweight_charts.py +0 -0
  43. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/logging.py +0 -0
  44. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/math.py +0 -0
  45. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/memory_profiler.py +0 -0
  46. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/modules.py +0 -0
  47. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/more_itertools.py +0 -0
  48. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/numpy.py +0 -0
  49. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/operator.py +0 -0
  50. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/optuna.py +0 -0
  51. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/orjson.py +0 -0
  52. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/os.py +0 -0
  53. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/parse.py +0 -0
  54. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/pathlib.py +0 -0
  55. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/pickle.py +0 -0
  56. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/platform.py +0 -0
  57. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/polars.py +0 -0
  58. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/polars_ols.py +0 -0
  59. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/postgres.py +0 -0
  60. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/pottery.py +0 -0
  61. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/pqdm.py +0 -0
  62. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/psutil.py +0 -0
  63. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/pwd.py +0 -0
  64. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/py.typed +0 -0
  65. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/pydantic.py +0 -0
  66. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/pydantic_settings.py +0 -0
  67. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/pydantic_settings_sops.py +0 -0
  68. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/pyinstrument.py +0 -0
  69. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/pytest.py +0 -0
  70. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/pytest_plugins/__init__.py +0 -0
  71. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/pytest_plugins/pytest_randomly.py +0 -0
  72. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/pytest_plugins/pytest_regressions.py +0 -0
  73. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/pytest_regressions.py +0 -0
  74. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/random.py +0 -0
  75. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/re.py +0 -0
  76. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/redis.py +0 -0
  77. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/reprlib.py +0 -0
  78. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/scipy.py +0 -0
  79. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/sentinel.py +0 -0
  80. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/shelve.py +0 -0
  81. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/shutil.py +0 -0
  82. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/slack_sdk.py +0 -0
  83. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/socket.py +0 -0
  84. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/sqlalchemy.py +0 -0
  85. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/sqlalchemy_polars.py +0 -0
  86. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/statsmodels.py +0 -0
  87. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/string.py +0 -0
  88. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/tempfile.py +0 -0
  89. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/testbook.py +0 -0
  90. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/text.py +0 -0
  91. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/threading.py +0 -0
  92. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/timer.py +0 -0
  93. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/traceback.py +0 -0
  94. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/typing.py +0 -0
  95. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/tzdata.py +0 -0
  96. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/tzlocal.py +0 -0
  97. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/uuid.py +0 -0
  98. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/version.py +0 -0
  99. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/warnings.py +0 -0
  100. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/whenever.py +0 -0
  101. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/src/utilities/zipfile.py +0 -0
  102. {dycw_utilities-0.174.2 → dycw_utilities-0.174.4}/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.2
3
+ Version: 0.174.4
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
@@ -101,7 +101,7 @@
101
101
  name = "dycw-utilities"
102
102
  readme = "README.md"
103
103
  requires-python = ">= 3.12"
104
- version = "0.174.2"
104
+ version = "0.174.4"
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.2"
138
+ current_version = "0.174.4"
139
139
 
140
140
  [[tool.bumpversion.files]]
141
141
  filename = "src/utilities/__init__.py"
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.174.2"
3
+ __version__ = "0.174.4"
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  from typing import TYPE_CHECKING, Literal, overload
6
6
 
7
7
  from utilities.errors import ImpossibleCaseError
8
+ from utilities.logging import to_logger
8
9
  from utilities.subprocess import (
9
10
  MKTEMP_DIR_CMD,
10
11
  maybe_sudo_cmd,
@@ -17,7 +18,7 @@ from utilities.subprocess import (
17
18
  if TYPE_CHECKING:
18
19
  from collections.abc import Iterator
19
20
 
20
- from utilities.types import LoggerLike, PathLike, StrStrMapping
21
+ from utilities.types import LoggerLike, PathLike, Retry, StrStrMapping
21
22
 
22
23
 
23
24
  @overload
@@ -106,6 +107,7 @@ def docker_exec(
106
107
  return_: Literal[True],
107
108
  return_stdout: bool = False,
108
109
  return_stderr: bool = False,
110
+ retry: Retry | None = None,
109
111
  logger: LoggerLike | None = None,
110
112
  **env_kwargs: str,
111
113
  ) -> str: ...
@@ -125,6 +127,7 @@ def docker_exec(
125
127
  return_: bool = False,
126
128
  return_stdout: Literal[True],
127
129
  return_stderr: bool = False,
130
+ retry: Retry | None = None,
128
131
  logger: LoggerLike | None = None,
129
132
  **env_kwargs: str,
130
133
  ) -> str: ...
@@ -144,6 +147,7 @@ def docker_exec(
144
147
  return_: bool = False,
145
148
  return_stdout: bool = False,
146
149
  return_stderr: Literal[True],
150
+ retry: Retry | None = None,
147
151
  logger: LoggerLike | None = None,
148
152
  **env_kwargs: str,
149
153
  ) -> str: ...
@@ -163,6 +167,7 @@ def docker_exec(
163
167
  return_: Literal[False] = False,
164
168
  return_stdout: Literal[False] = False,
165
169
  return_stderr: Literal[False] = False,
170
+ retry: Retry | None = None,
166
171
  logger: LoggerLike | None = None,
167
172
  **env_kwargs: str,
168
173
  ) -> None: ...
@@ -182,6 +187,7 @@ def docker_exec(
182
187
  return_: bool = False,
183
188
  return_stdout: bool = False,
184
189
  return_stderr: bool = False,
190
+ retry: Retry | None = None,
185
191
  logger: LoggerLike | None = None,
186
192
  **env_kwargs: str,
187
193
  ) -> str | None: ...
@@ -200,6 +206,7 @@ def docker_exec(
200
206
  return_: bool = False,
201
207
  return_stdout: bool = False,
202
208
  return_stderr: bool = False,
209
+ retry: Retry | None = None,
203
210
  logger: LoggerLike | None = None,
204
211
  **env_kwargs: str,
205
212
  ) -> str | None:
@@ -222,6 +229,7 @@ def docker_exec(
222
229
  return_=return_,
223
230
  return_stdout=return_stdout,
224
231
  return_stderr=return_stderr,
232
+ retry=retry,
225
233
  logger=logger,
226
234
  )
227
235
 
@@ -253,15 +261,32 @@ def docker_exec_cmd(
253
261
 
254
262
  @contextmanager
255
263
  def yield_docker_temp_dir(
256
- container: str, /, *, user: str | None = None, logger: LoggerLike | None = None
264
+ container: str,
265
+ /,
266
+ *,
267
+ user: str | None = None,
268
+ retry: Retry | None = None,
269
+ logger: LoggerLike | None = None,
270
+ keep: bool = False,
257
271
  ) -> Iterator[Path]:
258
272
  path = Path( # skipif-ci
259
- docker_exec(container, *MKTEMP_DIR_CMD, user=user, return_=True, logger=logger)
273
+ docker_exec(
274
+ container,
275
+ *MKTEMP_DIR_CMD,
276
+ user=user,
277
+ return_=True,
278
+ retry=retry,
279
+ logger=logger,
280
+ )
260
281
  )
261
282
  try: # skipif-ci
262
283
  yield path
263
284
  finally: # skipif-ci
264
- docker_exec(container, *rm_cmd(path), user=user, logger=logger)
285
+ if keep:
286
+ if logger is not None:
287
+ to_logger(logger).info("Keeping temporary directory '%s'...", path)
288
+ else:
289
+ docker_exec(container, *rm_cmd(path), user=user, retry=retry, logger=logger)
265
290
 
266
291
 
267
292
  __all__ = ["docker_cp_cmd", "docker_exec", "docker_exec_cmd", "yield_docker_temp_dir"]
@@ -13,13 +13,12 @@ from typing import IO, TYPE_CHECKING, Literal, assert_never, overload
13
13
  from utilities.errors import ImpossibleCaseError
14
14
  from utilities.logging import to_logger
15
15
  from utilities.text import strip_and_dedent
16
+ from utilities.whenever import to_seconds
16
17
 
17
18
  if TYPE_CHECKING:
18
19
  from collections.abc import Iterator
19
20
 
20
- from whenever import TimeDelta
21
-
22
- from utilities.types import LoggerLike, PathLike, StrMapping, StrStrMapping
21
+ from utilities.types import LoggerLike, PathLike, Retry, StrMapping, StrStrMapping
23
22
 
24
23
 
25
24
  _HOST_KEY_ALGORITHMS = ["ssh-ed25519"]
@@ -82,6 +81,7 @@ def run(
82
81
  return_: Literal[True],
83
82
  return_stdout: bool = False,
84
83
  return_stderr: bool = False,
84
+ retry: Retry | None = None,
85
85
  logger: LoggerLike | None = None,
86
86
  ) -> str: ...
87
87
  @overload
@@ -101,6 +101,7 @@ def run(
101
101
  return_: bool = False,
102
102
  return_stdout: Literal[True],
103
103
  return_stderr: bool = False,
104
+ retry: Retry | None = None,
104
105
  logger: LoggerLike | None = None,
105
106
  ) -> str: ...
106
107
  @overload
@@ -120,6 +121,7 @@ def run(
120
121
  return_: bool = False,
121
122
  return_stdout: bool = False,
122
123
  return_stderr: Literal[True],
124
+ retry: Retry | None = None,
123
125
  logger: LoggerLike | None = None,
124
126
  ) -> str: ...
125
127
  @overload
@@ -139,6 +141,7 @@ def run(
139
141
  return_: Literal[False] = False,
140
142
  return_stdout: Literal[False] = False,
141
143
  return_stderr: Literal[False] = False,
144
+ retry: Retry | None = None,
142
145
  logger: LoggerLike | None = None,
143
146
  ) -> None: ...
144
147
  @overload
@@ -158,6 +161,7 @@ def run(
158
161
  return_: bool = False,
159
162
  return_stdout: bool = False,
160
163
  return_stderr: bool = False,
164
+ retry: Retry | None = None,
161
165
  logger: LoggerLike | None = None,
162
166
  ) -> str | None: ...
163
167
  def run(
@@ -176,10 +180,11 @@ def run(
176
180
  return_: bool = False,
177
181
  return_stdout: bool = False,
178
182
  return_stderr: bool = False,
183
+ retry: Retry | None = None,
179
184
  logger: LoggerLike | None = None,
180
185
  ) -> str | None:
181
186
  args: list[str] = []
182
- if user is not None:
187
+ if user is not None: # pragma: no cover
183
188
  args.extend(["su", "-", str(user)])
184
189
  args.extend([cmd, *cmds_or_args])
185
190
  buffer = StringIO()
@@ -211,8 +216,8 @@ def run(
211
216
  if proc.stderr is None: # pragma: no cover
212
217
  raise ImpossibleCaseError(case=[f"{proc.stderr=}"])
213
218
  with (
214
- _yield_write(proc.stdout, "proc.stdout", *stdout_outputs),
215
- _yield_write(proc.stderr, "proc.stderr", *stderr_outputs),
219
+ _yield_write(proc.stdout, *stdout_outputs),
220
+ _yield_write(proc.stderr, *stderr_outputs),
216
221
  ):
217
222
  if input is not None:
218
223
  _ = proc.stdin.write(input)
@@ -232,6 +237,10 @@ def run(
232
237
  case 0, False, False:
233
238
  return None
234
239
  case _, _, _:
240
+ if retry is None:
241
+ attempts = delta = None
242
+ else:
243
+ attempts, delta = retry
235
244
  _ = stdout.seek(0)
236
245
  stdout_text = stdout.read()
237
246
  _ = stderr.seek(0)
@@ -246,24 +255,52 @@ def run(
246
255
  - shell = {shell}
247
256
  - cwd = {cwd}
248
257
  - env = {env}
249
- - input = {input}
250
258
 
259
+ -- stdin ----------------------------------------------------------------------
260
+ {"" if input is None else input}-------------------------------------------------------------------------------
251
261
  -- stdout ---------------------------------------------------------------------
252
262
  {stdout_text}-------------------------------------------------------------------------------
253
263
  -- stderr ---------------------------------------------------------------------
254
264
  {stderr_text}-------------------------------------------------------------------------------
255
265
  """)
266
+ if (attempts is not None) and (attempts >= 1):
267
+ if delta is None:
268
+ msg = f"{msg}\n\nRetrying {attempts} more time(s)..."
269
+ else:
270
+ msg = f"{msg}\n\nRetrying {attempts} more time(s) after {delta}..."
256
271
  to_logger(logger).error(msg)
257
- raise CalledProcessError(
272
+ error = CalledProcessError(
258
273
  return_code, args, output=stdout_text, stderr=stderr_text
259
274
  )
275
+ if (attempts is None) or (attempts <= 0):
276
+ raise error
277
+ if delta is not None:
278
+ sleep(to_seconds(delta))
279
+ return run(
280
+ cmd,
281
+ *cmds_or_args,
282
+ user=user,
283
+ executable=executable,
284
+ shell=shell,
285
+ cwd=cwd,
286
+ env=env,
287
+ input=input,
288
+ print=print,
289
+ print_stdout=print_stdout,
290
+ print_stderr=print_stderr,
291
+ return_=return_,
292
+ return_stdout=return_stdout,
293
+ return_stderr=return_stderr,
294
+ retry=(attempts - 1, delta),
295
+ logger=logger,
296
+ )
260
297
  case never:
261
298
  assert_never(never)
262
299
 
263
300
 
264
301
  @contextmanager
265
- def _yield_write(input_: IO[str], desc: str, /, *outputs: IO[str]) -> Iterator[None]:
266
- thread = Thread(target=_run_target, args=(input_, desc, *outputs), daemon=True)
302
+ def _yield_write(input_: IO[str], /, *outputs: IO[str]) -> Iterator[None]:
303
+ thread = Thread(target=_run_target, args=(input_, *outputs), daemon=True)
267
304
  thread.start()
268
305
  try:
269
306
  yield
@@ -271,14 +308,10 @@ def _yield_write(input_: IO[str], desc: str, /, *outputs: IO[str]) -> Iterator[N
271
308
  thread.join()
272
309
 
273
310
 
274
- def _run_target(input_: IO[str], desc: str, /, *outputs: IO[str]) -> None:
275
- try:
276
- with input_:
277
- for text in iter(input_.readline, ""):
278
- _write_to_streams(text, *outputs)
279
- except ValueError:
280
- _ = sys.stderr.write(f"Failed to write to {desc!r}...")
281
- raise
311
+ def _run_target(input_: IO[str], /, *outputs: IO[str]) -> None:
312
+ with input_:
313
+ for text in iter(input_.readline, ""):
314
+ _write_to_streams(text, *outputs)
282
315
 
283
316
 
284
317
  def _write_to_streams(text: str, /, *outputs: IO[str]) -> None:
@@ -302,8 +335,8 @@ def ssh(
302
335
  return_: Literal[True],
303
336
  return_stdout: bool = False,
304
337
  return_stderr: bool = False,
338
+ retry: Retry | None = None,
305
339
  logger: LoggerLike | None = None,
306
- retry: tuple[int, TimeDelta] | None = None,
307
340
  ) -> str: ...
308
341
  @overload
309
342
  def ssh(
@@ -321,8 +354,8 @@ def ssh(
321
354
  return_: bool = False,
322
355
  return_stdout: Literal[True],
323
356
  return_stderr: bool = False,
357
+ retry: Retry | None = None,
324
358
  logger: LoggerLike | None = None,
325
- retry: tuple[int, TimeDelta] | None = None,
326
359
  ) -> str: ...
327
360
  @overload
328
361
  def ssh(
@@ -340,8 +373,8 @@ def ssh(
340
373
  return_: bool = False,
341
374
  return_stdout: bool = False,
342
375
  return_stderr: Literal[True],
376
+ retry: Retry | None = None,
343
377
  logger: LoggerLike | None = None,
344
- retry: tuple[int, TimeDelta] | None = None,
345
378
  ) -> str: ...
346
379
  @overload
347
380
  def ssh(
@@ -359,8 +392,8 @@ def ssh(
359
392
  return_: Literal[False] = False,
360
393
  return_stdout: Literal[False] = False,
361
394
  return_stderr: Literal[False] = False,
395
+ retry: Retry | None = None,
362
396
  logger: LoggerLike | None = None,
363
- retry: tuple[int, TimeDelta] | None = None,
364
397
  ) -> None: ...
365
398
  @overload
366
399
  def ssh(
@@ -378,8 +411,8 @@ def ssh(
378
411
  return_: bool = False,
379
412
  return_stdout: bool = False,
380
413
  return_stderr: bool = False,
414
+ retry: Retry | None = None,
381
415
  logger: LoggerLike | None = None,
382
- retry: tuple[int, TimeDelta] | None = None,
383
416
  ) -> str | None: ...
384
417
  def ssh(
385
418
  user: str,
@@ -396,8 +429,8 @@ def ssh(
396
429
  return_: bool = False,
397
430
  return_stdout: bool = False,
398
431
  return_stderr: bool = False,
432
+ retry: Retry | None = None,
399
433
  logger: LoggerLike | None = None,
400
- retry: tuple[int, TimeDelta] | None = None,
401
434
  ) -> str | None:
402
435
  cmd_and_args = ssh_cmd( # skipif-ci
403
436
  user,
@@ -407,61 +440,18 @@ def ssh(
407
440
  host_key_algorithms=host_key_algorithms,
408
441
  strict_host_key_checking=strict_host_key_checking,
409
442
  )
410
- try: # skipif-ci
411
- return run(
412
- *cmd_and_args,
413
- input=input,
414
- print=print,
415
- print_stdout=print_stdout,
416
- print_stderr=print_stderr,
417
- return_=return_,
418
- return_stdout=return_stdout,
419
- return_stderr=return_stderr,
420
- logger=logger,
421
- )
422
- except CalledProcessError as error: # skipif-ci
423
- if retry is None:
424
- raise
425
- attempts, delta = retry
426
- if attempts <= 0:
427
- raise
428
- if logger is not None:
429
- msg = strip_and_dedent(f"""
430
- 'ssh' failed with:
431
- - user = {user}
432
- - hostname = {hostname}
433
- - cmd_and_cmds_or_args = {cmd_and_cmds_or_args}
434
- - batch_mode = {batch_mode}
435
- - host_key_algorithms = {host_key_algorithms}
436
- - strict_host_key_checking = {strict_host_key_checking}
437
- - input = {input}
438
-
439
- -- stdout ---------------------------------------------------------------------
440
- {error.stdout}-------------------------------------------------------------------------------
441
- -- stderr ---------------------------------------------------------------------
442
- {error.stderr}-------------------------------------------------------------------------------
443
-
444
- Retrying {attempts} more time(s) after {delta}...
445
- """)
446
- to_logger(logger).error(msg)
447
- sleep(delta.in_seconds())
448
- return ssh(
449
- user,
450
- hostname,
451
- *cmd_and_cmds_or_args,
452
- batch_mode=batch_mode,
453
- host_key_algorithms=host_key_algorithms,
454
- strict_host_key_checking=strict_host_key_checking,
455
- input=input,
456
- print=print,
457
- print_stdout=print_stdout,
458
- print_stderr=print_stderr,
459
- return_=return_,
460
- return_stdout=return_stdout,
461
- return_stderr=return_stderr,
462
- logger=logger,
463
- retry=(attempts - 1, delta),
464
- )
443
+ return run( # skipif-ci
444
+ *cmd_and_args,
445
+ input=input,
446
+ print=print,
447
+ print_stdout=print_stdout,
448
+ print_stderr=print_stderr,
449
+ return_=return_,
450
+ return_stdout=return_stdout,
451
+ return_stderr=return_stderr,
452
+ retry=retry,
453
+ logger=logger,
454
+ )
465
455
 
466
456
 
467
457
  def ssh_cmd(
@@ -492,9 +482,17 @@ def touch_cmd(path: PathLike, /) -> list[str]:
492
482
 
493
483
  @contextmanager
494
484
  def yield_ssh_temp_dir(
495
- user: str, hostname: str, /, *, keep: bool = False, logger: LoggerLike | None = None
485
+ user: str,
486
+ hostname: str,
487
+ /,
488
+ *,
489
+ retry: Retry | None = None,
490
+ logger: LoggerLike | None = None,
491
+ keep: bool = False,
496
492
  ) -> Iterator[Path]:
497
- path = Path(ssh(user, hostname, *MKTEMP_DIR_CMD, return_=True))
493
+ path = Path(
494
+ ssh(user, hostname, *MKTEMP_DIR_CMD, return_=True, retry=retry, logger=logger)
495
+ )
498
496
  try:
499
497
  yield path
500
498
  finally:
@@ -502,7 +500,7 @@ def yield_ssh_temp_dir(
502
500
  if logger is not None:
503
501
  to_logger(logger).info("Keeping temporary directory '%s'...", path)
504
502
  else:
505
- ssh(user, hostname, *rm_cmd(path))
503
+ ssh(user, hostname, *rm_cmd(path), retry=retry, logger=logger)
506
504
 
507
505
 
508
506
  __all__ = [
@@ -231,6 +231,10 @@ type Seed = int | float | str | bytes | bytearray | Random
231
231
  type PatternLike = MaybeStr[Pattern[str]]
232
232
 
233
233
 
234
+ # retry
235
+ type Retry = tuple[int, Delta | None]
236
+
237
+
234
238
  # text
235
239
  type MaybeCallableStr = MaybeCallable[str]
236
240
 
@@ -332,6 +336,7 @@ __all__ = [
332
336
  "PathLike",
333
337
  "PatternLike",
334
338
  "PlainDateTimeLike",
339
+ "Retry",
335
340
  "Seed",
336
341
  "SequenceStr",
337
342
  "SerializeObjectExtra",