dycw-utilities 0.129.10__py3-none-any.whl → 0.175.17__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. dycw_utilities-0.175.17.dist-info/METADATA +34 -0
  2. dycw_utilities-0.175.17.dist-info/RECORD +103 -0
  3. dycw_utilities-0.175.17.dist-info/WHEEL +4 -0
  4. dycw_utilities-0.175.17.dist-info/entry_points.txt +4 -0
  5. utilities/__init__.py +1 -1
  6. utilities/altair.py +14 -14
  7. utilities/asyncio.py +350 -819
  8. utilities/atomicwrites.py +18 -6
  9. utilities/atools.py +77 -22
  10. utilities/cachetools.py +24 -29
  11. utilities/click.py +393 -237
  12. utilities/concurrent.py +8 -11
  13. utilities/contextlib.py +216 -17
  14. utilities/contextvars.py +20 -1
  15. utilities/cryptography.py +3 -3
  16. utilities/dataclasses.py +83 -118
  17. utilities/docker.py +293 -0
  18. utilities/enum.py +26 -23
  19. utilities/errors.py +17 -3
  20. utilities/fastapi.py +29 -65
  21. utilities/fpdf2.py +3 -3
  22. utilities/functions.py +169 -416
  23. utilities/functools.py +18 -19
  24. utilities/git.py +9 -30
  25. utilities/grp.py +28 -0
  26. utilities/gzip.py +31 -0
  27. utilities/http.py +3 -2
  28. utilities/hypothesis.py +738 -589
  29. utilities/importlib.py +17 -1
  30. utilities/inflect.py +25 -0
  31. utilities/iterables.py +194 -262
  32. utilities/jinja2.py +148 -0
  33. utilities/json.py +70 -0
  34. utilities/libcst.py +38 -17
  35. utilities/lightweight_charts.py +5 -9
  36. utilities/logging.py +345 -543
  37. utilities/math.py +18 -13
  38. utilities/memory_profiler.py +11 -15
  39. utilities/more_itertools.py +200 -131
  40. utilities/operator.py +33 -29
  41. utilities/optuna.py +6 -6
  42. utilities/orjson.py +272 -137
  43. utilities/os.py +61 -4
  44. utilities/parse.py +59 -61
  45. utilities/pathlib.py +281 -40
  46. utilities/permissions.py +298 -0
  47. utilities/pickle.py +2 -2
  48. utilities/platform.py +24 -5
  49. utilities/polars.py +1214 -430
  50. utilities/polars_ols.py +1 -1
  51. utilities/postgres.py +408 -0
  52. utilities/pottery.py +113 -26
  53. utilities/pqdm.py +10 -11
  54. utilities/psutil.py +6 -57
  55. utilities/pwd.py +28 -0
  56. utilities/pydantic.py +4 -54
  57. utilities/pydantic_settings.py +240 -0
  58. utilities/pydantic_settings_sops.py +76 -0
  59. utilities/pyinstrument.py +8 -10
  60. utilities/pytest.py +227 -121
  61. utilities/pytest_plugins/__init__.py +1 -0
  62. utilities/pytest_plugins/pytest_randomly.py +23 -0
  63. utilities/pytest_plugins/pytest_regressions.py +56 -0
  64. utilities/pytest_regressions.py +26 -46
  65. utilities/random.py +13 -9
  66. utilities/re.py +58 -28
  67. utilities/redis.py +401 -550
  68. utilities/scipy.py +1 -1
  69. utilities/sentinel.py +10 -0
  70. utilities/shelve.py +4 -1
  71. utilities/shutil.py +25 -0
  72. utilities/slack_sdk.py +36 -106
  73. utilities/sqlalchemy.py +502 -473
  74. utilities/sqlalchemy_polars.py +38 -94
  75. utilities/string.py +2 -3
  76. utilities/subprocess.py +1572 -0
  77. utilities/tempfile.py +86 -4
  78. utilities/testbook.py +50 -0
  79. utilities/text.py +165 -42
  80. utilities/timer.py +37 -65
  81. utilities/traceback.py +158 -929
  82. utilities/types.py +146 -116
  83. utilities/typing.py +531 -71
  84. utilities/tzdata.py +1 -53
  85. utilities/tzlocal.py +6 -23
  86. utilities/uuid.py +43 -5
  87. utilities/version.py +27 -26
  88. utilities/whenever.py +1776 -386
  89. utilities/zoneinfo.py +84 -22
  90. dycw_utilities-0.129.10.dist-info/METADATA +0 -241
  91. dycw_utilities-0.129.10.dist-info/RECORD +0 -96
  92. dycw_utilities-0.129.10.dist-info/WHEEL +0 -4
  93. dycw_utilities-0.129.10.dist-info/licenses/LICENSE +0 -21
  94. utilities/datetime.py +0 -1409
  95. utilities/eventkit.py +0 -402
  96. utilities/loguru.py +0 -144
  97. utilities/luigi.py +0 -228
  98. utilities/period.py +0 -324
  99. utilities/pyrsistent.py +0 -89
  100. utilities/python_dotenv.py +0 -105
  101. utilities/streamlit.py +0 -105
  102. utilities/sys.py +0 -87
  103. utilities/tenacity.py +0 -145
utilities/docker.py ADDED
@@ -0,0 +1,293 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, Literal, overload
6
+
7
+ from utilities.errors import ImpossibleCaseError
8
+ from utilities.logging import to_logger
9
+ from utilities.subprocess import (
10
+ MKTEMP_DIR_CMD,
11
+ maybe_sudo_cmd,
12
+ mkdir,
13
+ mkdir_cmd,
14
+ rm_cmd,
15
+ run,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Iterator
20
+
21
+ from utilities.types import LoggerLike, PathLike, Retry, StrStrMapping
22
+
23
+
24
+ @overload
25
+ def docker_cp(
26
+ src: tuple[str, PathLike],
27
+ dest: PathLike,
28
+ /,
29
+ *,
30
+ sudo: bool = False,
31
+ logger: LoggerLike | None = None,
32
+ ) -> None: ...
33
+ @overload
34
+ def docker_cp(
35
+ src: PathLike,
36
+ dest: tuple[str, PathLike],
37
+ /,
38
+ *,
39
+ sudo: bool = False,
40
+ logger: LoggerLike | None = None,
41
+ ) -> None: ...
42
+ def docker_cp(
43
+ src: PathLike | tuple[str, PathLike],
44
+ dest: PathLike | tuple[str, PathLike],
45
+ /,
46
+ *,
47
+ sudo: bool = False,
48
+ logger: LoggerLike | None = None,
49
+ ) -> None:
50
+ match src, dest: # skipif-ci
51
+ case Path() | str(), (str() as cont, Path() | str() as dest_path):
52
+ docker_exec(
53
+ cont, *maybe_sudo_cmd(*mkdir_cmd(dest_path, parent=True), sudo=sudo)
54
+ )
55
+ run(*docker_cp_cmd(src, dest, sudo=sudo), logger=logger)
56
+ case (str(), Path() | str()), Path() | str():
57
+ mkdir(dest, parent=True, sudo=sudo)
58
+ run(*docker_cp_cmd(src, dest, sudo=sudo), logger=logger)
59
+ case _: # pragma: no cover
60
+ raise ImpossibleCaseError(case=[f"{src}", f"{dest=}"])
61
+
62
+
63
+ @overload
64
+ def docker_cp_cmd(
65
+ src: tuple[str, PathLike], dest: PathLike, /, *, sudo: bool = False
66
+ ) -> list[str]: ...
67
+ @overload
68
+ def docker_cp_cmd(
69
+ src: PathLike, dest: tuple[str, PathLike], /, *, sudo: bool = False
70
+ ) -> list[str]: ...
71
+ def docker_cp_cmd(
72
+ src: PathLike | tuple[str, PathLike],
73
+ dest: PathLike | tuple[str, PathLike],
74
+ /,
75
+ *,
76
+ sudo: bool = False,
77
+ ) -> list[str]:
78
+ match src, dest:
79
+ case (Path() | str()) as src_use, (
80
+ str() as dest_cont,
81
+ Path() | str() as dest_path,
82
+ ):
83
+ dest_use = f"{dest_cont}:{dest_path}"
84
+ case (str() as src_cont, (Path() | str()) as src_path), (
85
+ Path() | str() as dest_use
86
+ ):
87
+ src_use = f"{src_cont}:{src_path}"
88
+ case _: # pragma: no cover
89
+ raise ImpossibleCaseError(case=[f"{src}", f"{dest=}"])
90
+ parts: list[str] = ["docker", "cp", str(src_use), str(dest_use)]
91
+ return maybe_sudo_cmd(*parts, sudo=sudo)
92
+
93
+
94
+ @overload
95
+ def docker_exec(
96
+ container: str,
97
+ cmd: str,
98
+ /,
99
+ *cmds_or_args: str,
100
+ env: StrStrMapping | None = None,
101
+ user: str | None = None,
102
+ workdir: PathLike | None = None,
103
+ input: str | None = None,
104
+ print: bool = False,
105
+ print_stdout: bool = False,
106
+ print_stderr: bool = False,
107
+ return_: Literal[True],
108
+ return_stdout: bool = False,
109
+ return_stderr: bool = False,
110
+ retry: Retry | None = None,
111
+ logger: LoggerLike | None = None,
112
+ **env_kwargs: str,
113
+ ) -> str: ...
114
+ @overload
115
+ def docker_exec(
116
+ container: str,
117
+ cmd: str,
118
+ /,
119
+ *cmds_or_args: str,
120
+ env: StrStrMapping | None = None,
121
+ user: str | None = None,
122
+ workdir: PathLike | None = None,
123
+ input: str | None = None,
124
+ print: bool = False,
125
+ print_stdout: bool = False,
126
+ print_stderr: bool = False,
127
+ return_: bool = False,
128
+ return_stdout: Literal[True],
129
+ return_stderr: bool = False,
130
+ retry: Retry | None = None,
131
+ logger: LoggerLike | None = None,
132
+ **env_kwargs: str,
133
+ ) -> str: ...
134
+ @overload
135
+ def docker_exec(
136
+ container: str,
137
+ cmd: str,
138
+ /,
139
+ *cmds_or_args: str,
140
+ env: StrStrMapping | None = None,
141
+ user: str | None = None,
142
+ workdir: PathLike | None = None,
143
+ input: str | None = None,
144
+ print: bool = False,
145
+ print_stdout: bool = False,
146
+ print_stderr: bool = False,
147
+ return_: bool = False,
148
+ return_stdout: bool = False,
149
+ return_stderr: Literal[True],
150
+ retry: Retry | None = None,
151
+ logger: LoggerLike | None = None,
152
+ **env_kwargs: str,
153
+ ) -> str: ...
154
+ @overload
155
+ def docker_exec(
156
+ container: str,
157
+ cmd: str,
158
+ /,
159
+ *cmds_or_args: str,
160
+ env: StrStrMapping | None = None,
161
+ user: str | None = None,
162
+ workdir: PathLike | None = None,
163
+ input: str | None = None,
164
+ print: bool = False,
165
+ print_stdout: bool = False,
166
+ print_stderr: bool = False,
167
+ return_: Literal[False] = False,
168
+ return_stdout: Literal[False] = False,
169
+ return_stderr: Literal[False] = False,
170
+ retry: Retry | None = None,
171
+ logger: LoggerLike | None = None,
172
+ **env_kwargs: str,
173
+ ) -> None: ...
174
+ @overload
175
+ def docker_exec(
176
+ container: str,
177
+ cmd: str,
178
+ /,
179
+ *cmds_or_args: str,
180
+ env: StrStrMapping | None = None,
181
+ user: str | None = None,
182
+ workdir: PathLike | None = None,
183
+ input: str | None = None,
184
+ print: bool = False,
185
+ print_stdout: bool = False,
186
+ print_stderr: bool = False,
187
+ return_: bool = False,
188
+ return_stdout: bool = False,
189
+ return_stderr: bool = False,
190
+ retry: Retry | None = None,
191
+ logger: LoggerLike | None = None,
192
+ **env_kwargs: str,
193
+ ) -> str | None: ...
194
+ def docker_exec(
195
+ container: str,
196
+ cmd: str,
197
+ /,
198
+ *cmds_or_args: str,
199
+ env: StrStrMapping | None = None,
200
+ user: str | None = None,
201
+ workdir: PathLike | None = None,
202
+ input: str | None = None, # noqa: A002
203
+ print: bool = False, # noqa: A002
204
+ print_stdout: bool = False,
205
+ print_stderr: bool = False,
206
+ return_: bool = False,
207
+ return_stdout: bool = False,
208
+ return_stderr: bool = False,
209
+ retry: Retry | None = None,
210
+ logger: LoggerLike | None = None,
211
+ **env_kwargs: str,
212
+ ) -> str | None:
213
+ cmd_and_args = docker_exec_cmd( # skipif-ci
214
+ container,
215
+ cmd,
216
+ *cmds_or_args,
217
+ env=env,
218
+ interactive=input is not None,
219
+ user=user,
220
+ workdir=workdir,
221
+ **env_kwargs,
222
+ )
223
+ return run( # skipif-ci
224
+ *cmd_and_args,
225
+ input=input,
226
+ print=print,
227
+ print_stdout=print_stdout,
228
+ print_stderr=print_stderr,
229
+ return_=return_,
230
+ return_stdout=return_stdout,
231
+ return_stderr=return_stderr,
232
+ retry=retry,
233
+ logger=logger,
234
+ )
235
+
236
+
237
+ def docker_exec_cmd(
238
+ container: str,
239
+ cmd: str,
240
+ /,
241
+ *cmds_or_args: str,
242
+ env: StrStrMapping | None = None,
243
+ interactive: bool = False,
244
+ user: str | None = None,
245
+ workdir: PathLike | None = None,
246
+ **env_kwargs: str,
247
+ ) -> list[str]:
248
+ """Build a command for `docker exec`."""
249
+ args: list[str] = ["docker", "exec"]
250
+ mapping: dict[str, str] = ({} if env is None else dict(env)) | env_kwargs
251
+ for key, value in mapping.items():
252
+ args.extend(["--env", f"{key}={value}"])
253
+ if interactive:
254
+ args.append("--interactive")
255
+ if user is not None:
256
+ args.extend(["--user", user])
257
+ if workdir is not None:
258
+ args.extend(["--workdir", str(workdir)])
259
+ return [*args, container, cmd, *cmds_or_args]
260
+
261
+
262
+ @contextmanager
263
+ def yield_docker_temp_dir(
264
+ container: str,
265
+ /,
266
+ *,
267
+ user: str | None = None,
268
+ retry: Retry | None = None,
269
+ logger: LoggerLike | None = None,
270
+ keep: bool = False,
271
+ ) -> Iterator[Path]:
272
+ """Yield a temporary directory in a Docker container."""
273
+ path = Path( # skipif-ci
274
+ docker_exec(
275
+ container,
276
+ *MKTEMP_DIR_CMD,
277
+ user=user,
278
+ return_=True,
279
+ retry=retry,
280
+ logger=logger,
281
+ )
282
+ )
283
+ try: # skipif-ci
284
+ yield path
285
+ finally: # skipif-ci
286
+ if keep:
287
+ if logger is not None:
288
+ to_logger(logger).info("Keeping temporary directory '%s'...", path)
289
+ else:
290
+ docker_exec(container, *rm_cmd(path), user=user, retry=retry, logger=logger)
291
+
292
+
293
+ __all__ = ["docker_cp_cmd", "docker_exec", "docker_exec_cmd", "yield_docker_temp_dir"]
utilities/enum.py CHANGED
@@ -2,26 +2,29 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
  from enum import Enum, StrEnum
5
- from typing import Generic, Literal, assert_never, overload, override
5
+ from typing import TYPE_CHECKING, Literal, assert_never, overload, override
6
6
 
7
7
  from utilities.functions import ensure_str
8
8
  from utilities.iterables import OneStrEmptyError, OneStrNonUniqueError, one_str
9
- from utilities.types import EnumLike, TEnum
9
+
10
+ if TYPE_CHECKING:
11
+ from utilities.types import EnumLike
12
+
10
13
 
11
14
  ##
12
15
 
13
16
 
14
17
  @overload
15
- def ensure_enum(
16
- value: None, enum: type[TEnum], /, *, case_sensitive: bool = False
18
+ def ensure_enum[E: Enum](
19
+ value: None, enum: type[E], /, *, case_sensitive: bool = False
17
20
  ) -> None: ...
18
21
  @overload
19
- def ensure_enum(
20
- value: EnumLike[TEnum], enum: type[TEnum], /, *, case_sensitive: bool = False
21
- ) -> TEnum: ...
22
- def ensure_enum(
23
- value: EnumLike[TEnum] | None, enum: type[TEnum], /, *, case_sensitive: bool = False
24
- ) -> TEnum | None:
22
+ def ensure_enum[E: Enum](
23
+ value: EnumLike[E], enum: type[E], /, *, case_sensitive: bool = False
24
+ ) -> E: ...
25
+ def ensure_enum[E: Enum](
26
+ value: EnumLike[E] | None, enum: type[E], /, *, case_sensitive: bool = False
27
+ ) -> E | None:
25
28
  """Ensure the object is a member of the enum."""
26
29
  if value is None:
27
30
  return None
@@ -36,9 +39,9 @@ def ensure_enum(
36
39
 
37
40
 
38
41
  @dataclass(kw_only=True, slots=True)
39
- class EnsureEnumError(Exception, Generic[TEnum]):
40
- value: EnumLike[TEnum]
41
- enum: type[TEnum]
42
+ class EnsureEnumError[E: Enum](Exception):
43
+ value: EnumLike[E]
44
+ enum: type[E]
42
45
 
43
46
 
44
47
  @dataclass(kw_only=True, slots=True)
@@ -58,9 +61,9 @@ class _EnsureEnumParseError(EnsureEnumError):
58
61
  ##
59
62
 
60
63
 
61
- def parse_enum(
62
- value: str, enum: type[TEnum], /, *, case_sensitive: bool = False
63
- ) -> TEnum:
64
+ def parse_enum[E: Enum](
65
+ value: str, enum: type[E], /, *, case_sensitive: bool = False
66
+ ) -> E:
64
67
  """Parse a string into the enum."""
65
68
  by_name = _parse_enum_one(value, enum, "names", case_sensitive=case_sensitive)
66
69
  if not issubclass(enum, StrEnum):
@@ -92,28 +95,28 @@ def parse_enum(
92
95
  by_name=by_name.name,
93
96
  by_value=by_value.name,
94
97
  )
95
- case _ as never:
98
+ case never:
96
99
  assert_never(never)
97
100
 
98
101
 
99
102
  type _NamesOrValues = Literal["names", "values"]
100
103
 
101
104
 
102
- def _parse_enum_one(
105
+ def _parse_enum_one[E: Enum](
103
106
  value: str,
104
- enum: type[TEnum],
107
+ enum: type[E],
105
108
  names_or_values: _NamesOrValues,
106
109
  /,
107
110
  *,
108
111
  case_sensitive: bool = False,
109
- ) -> TEnum | None:
112
+ ) -> E | None:
110
113
  """Pair one aspect of the enums."""
111
114
  match names_or_values:
112
115
  case "names":
113
116
  names = [e.name for e in enum]
114
117
  case "values":
115
118
  names = [ensure_str(e.value) for e in enum]
116
- case _ as never:
119
+ case never:
117
120
  assert_never(never)
118
121
  try:
119
122
  name = one_str(names, value, case_sensitive=case_sensitive)
@@ -132,9 +135,9 @@ def _parse_enum_one(
132
135
 
133
136
 
134
137
  @dataclass(kw_only=True, slots=True)
135
- class ParseEnumError(Exception, Generic[TEnum]):
138
+ class ParseEnumError[E: Enum](Exception):
136
139
  value: str
137
- enum: type[TEnum]
140
+ enum: type[E]
138
141
 
139
142
 
140
143
  @dataclass(kw_only=True, slots=True)
utilities/errors.py CHANGED
@@ -4,7 +4,7 @@ from dataclasses import dataclass
4
4
  from typing import TYPE_CHECKING, assert_never, override
5
5
 
6
6
  if TYPE_CHECKING:
7
- from utilities.types import MaybeType
7
+ from utilities.types import ExceptionTypeLike, MaybeType
8
8
 
9
9
 
10
10
  @dataclass(kw_only=True, slots=True)
@@ -21,6 +21,20 @@ class ImpossibleCaseError(Exception):
21
21
  ##
22
22
 
23
23
 
24
+ def is_instance_error(
25
+ error: BaseException, class_or_tuple: ExceptionTypeLike[Exception], /
26
+ ) -> bool:
27
+ """Check if an instance relationship holds, allowing for groups."""
28
+ if isinstance(error, class_or_tuple):
29
+ return True
30
+ if not isinstance(error, BaseExceptionGroup):
31
+ return False
32
+ return any(is_instance_error(e, class_or_tuple) for e in error.exceptions)
33
+
34
+
35
+ ##
36
+
37
+
24
38
  def repr_error(error: MaybeType[BaseException], /) -> str:
25
39
  """Get a string representation of an error."""
26
40
  match error:
@@ -32,8 +46,8 @@ def repr_error(error: MaybeType[BaseException], /) -> str:
32
46
  return f"{error_obj.__class__.__name__}({error_obj})"
33
47
  case type() as error_cls:
34
48
  return error_cls.__name__
35
- case _ as never:
49
+ case never:
36
50
  assert_never(never)
37
51
 
38
52
 
39
- __all__ = ["ImpossibleCaseError", "repr_error"]
53
+ __all__ = ["ImpossibleCaseError", "is_instance_error", "repr_error"]
utilities/fastapi.py CHANGED
@@ -1,23 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from asyncio import Task, create_task
4
- from dataclasses import InitVar, dataclass, field
5
- from typing import TYPE_CHECKING, Any, Literal, Self, override
4
+ from typing import TYPE_CHECKING, Any, override
6
5
 
7
6
  from fastapi import FastAPI
8
7
  from uvicorn import Config, Server
9
8
 
10
- from utilities.asyncio import Looper
11
- from utilities.datetime import SECOND, datetime_duration_to_float
9
+ from utilities.asyncio import timeout_td
10
+ from utilities.contextlib import enhanced_async_context_manager
11
+ from utilities.whenever import get_now_local
12
12
 
13
13
  if TYPE_CHECKING:
14
- from types import TracebackType
14
+ from collections.abc import AsyncIterator
15
15
 
16
- from utilities.types import Duration
16
+ from utilities.types import Delta, MaybeType
17
17
 
18
18
 
19
- _LOCALHOST: str = "localhost"
20
- _TIMEOUT: Duration = SECOND
19
+ _TASKS: list[Task[None]] = []
21
20
 
22
21
 
23
22
  class _PingerReceiverApp(FastAPI):
@@ -29,64 +28,29 @@ class _PingerReceiverApp(FastAPI):
29
28
 
30
29
  @self.get("/ping") # skipif-ci
31
30
  def ping() -> str:
32
- from utilities.tzlocal import get_now_local # skipif-ci
33
- from utilities.whenever import serialize_zoned_datetime # skipif-ci
34
-
35
- now = serialize_zoned_datetime(get_now_local()) # skipif-ci
36
- return f"pong @ {now}" # skipif-ci
31
+ return f"pong @ {get_now_local()}" # skipif-ci
37
32
 
38
33
  _ = ping # skipif-ci
39
34
 
40
35
 
41
- @dataclass(kw_only=True)
42
- class PingReceiver(Looper[None]):
43
- """A ping receiver."""
44
-
45
- host: InitVar[str] = _LOCALHOST
46
- port: InitVar[int]
47
- _app: _PingerReceiverApp = field(
48
- default_factory=_PingerReceiverApp, init=False, repr=False
49
- )
50
- _server: Server = field(init=False, repr=False)
51
- _server_task: Task[None] | None = field(default=None, init=False, repr=False)
52
-
53
- @override
54
- def __post_init__(self, host: str, port: int, /) -> None:
55
- super().__post_init__() # skipif-ci
56
- self._server = Server(Config(self._app, host=host, port=port)) # skipif-ci
57
-
58
- @override
59
- async def __aenter__(self) -> Self:
60
- _ = await super().__aenter__() # skipif-ci
61
- async with self._lock: # skipif-ci
62
- self._server_task = create_task(self._server.serve())
63
- return self # skipif-ci
64
-
65
- @override
66
- async def __aexit__(
67
- self,
68
- exc_type: type[BaseException] | None = None,
69
- exc_value: BaseException | None = None,
70
- traceback: TracebackType | None = None,
71
- ) -> None:
72
- await super().__aexit__(exc_type, exc_value, traceback) # skipif-ci
73
- await self._server.shutdown() # skipif-ci
74
-
75
- @classmethod
76
- async def ping(
77
- cls, port: int, /, *, host: str = _LOCALHOST, timeout: Duration = _TIMEOUT
78
- ) -> str | Literal[False]:
79
- """Ping the receiver."""
80
- from httpx import AsyncClient, ConnectError # skipif-ci
81
-
82
- url = f"http://{host}:{port}/ping" # skipif-ci
83
- timeout_use = datetime_duration_to_float(timeout) # skipif-ci
84
- try: # skipif-ci
85
- async with AsyncClient() as client:
86
- response = await client.get(url, timeout=timeout_use)
87
- except ConnectError: # skipif-ci
88
- return False
89
- return response.text if response.status_code == 200 else False # skipif-ci
90
-
91
-
92
- __all__ = ["PingReceiver"]
36
+ @enhanced_async_context_manager
37
+ async def yield_ping_receiver(
38
+ port: int,
39
+ /,
40
+ *,
41
+ host: str = "localhost",
42
+ timeout: Delta | None = None,
43
+ error: MaybeType[BaseException] = TimeoutError,
44
+ ) -> AsyncIterator[None]:
45
+ """Yield the ping receiver."""
46
+ app = _PingerReceiverApp() # skipif-ci
47
+ server = Server(Config(app, host=host, port=port)) # skipif-ci
48
+ _TASKS.append(create_task(server.serve())) # skipif-ci
49
+ try: # skipif-ci
50
+ async with timeout_td(timeout, error=error):
51
+ yield
52
+ finally: # skipif-ci
53
+ await server.shutdown()
54
+
55
+
56
+ __all__ = ["yield_ping_receiver"]
utilities/fpdf2.py CHANGED
@@ -6,6 +6,8 @@ from typing import TYPE_CHECKING, override
6
6
  from fpdf import FPDF
7
7
  from fpdf.enums import XPos, YPos
8
8
 
9
+ from utilities.whenever import format_compact, get_now_local
10
+
9
11
  if TYPE_CHECKING:
10
12
  from collections.abc import Iterator
11
13
 
@@ -43,11 +45,9 @@ def yield_pdf(*, header: str | None = None) -> Iterator[_BasePDF]:
43
45
 
44
46
  @override
45
47
  def footer(self) -> None:
46
- from utilities.tzlocal import get_now_local
47
-
48
48
  self.set_y(-15)
49
49
  self.set_font(family="Helvetica", style="I", size=8)
50
- page_no, now = self.page_no(), get_now_local()
50
+ page_no, now = (self.page_no(), format_compact(get_now_local()))
51
51
  text = f"page {page_no}/{{}}; {now}"
52
52
  _ = self.cell(
53
53
  w=0,