dycw-utilities 0.148.5__py3-none-any.whl → 0.175.31__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.

Potentially problematic release.


This version of dycw-utilities might be problematic. Click here for more details.

Files changed (84) hide show
  1. dycw_utilities-0.175.31.dist-info/METADATA +34 -0
  2. dycw_utilities-0.175.31.dist-info/RECORD +103 -0
  3. dycw_utilities-0.175.31.dist-info/WHEEL +4 -0
  4. {dycw_utilities-0.148.5.dist-info → dycw_utilities-0.175.31.dist-info}/entry_points.txt +1 -0
  5. utilities/__init__.py +1 -1
  6. utilities/altair.py +10 -7
  7. utilities/asyncio.py +113 -64
  8. utilities/atomicwrites.py +1 -1
  9. utilities/atools.py +64 -4
  10. utilities/cachetools.py +9 -6
  11. utilities/click.py +144 -49
  12. utilities/concurrent.py +1 -1
  13. utilities/contextlib.py +4 -2
  14. utilities/contextvars.py +20 -1
  15. utilities/cryptography.py +3 -3
  16. utilities/dataclasses.py +15 -28
  17. utilities/docker.py +381 -0
  18. utilities/enum.py +2 -2
  19. utilities/errors.py +1 -1
  20. utilities/fastapi.py +8 -3
  21. utilities/fpdf2.py +2 -2
  22. utilities/functions.py +20 -297
  23. utilities/git.py +19 -0
  24. utilities/grp.py +28 -0
  25. utilities/hypothesis.py +361 -79
  26. utilities/importlib.py +17 -1
  27. utilities/inflect.py +1 -1
  28. utilities/iterables.py +12 -58
  29. utilities/jinja2.py +148 -0
  30. utilities/json.py +1 -1
  31. utilities/libcst.py +7 -7
  32. utilities/logging.py +74 -85
  33. utilities/math.py +8 -4
  34. utilities/more_itertools.py +4 -6
  35. utilities/operator.py +1 -1
  36. utilities/orjson.py +86 -34
  37. utilities/os.py +49 -2
  38. utilities/parse.py +2 -2
  39. utilities/pathlib.py +66 -34
  40. utilities/permissions.py +298 -0
  41. utilities/platform.py +4 -4
  42. utilities/polars.py +934 -420
  43. utilities/polars_ols.py +1 -1
  44. utilities/postgres.py +296 -174
  45. utilities/pottery.py +8 -73
  46. utilities/pqdm.py +3 -3
  47. utilities/pwd.py +28 -0
  48. utilities/pydantic.py +11 -0
  49. utilities/pydantic_settings.py +240 -0
  50. utilities/pydantic_settings_sops.py +76 -0
  51. utilities/pyinstrument.py +5 -5
  52. utilities/pytest.py +155 -46
  53. utilities/pytest_plugins/pytest_randomly.py +1 -1
  54. utilities/pytest_plugins/pytest_regressions.py +7 -3
  55. utilities/pytest_regressions.py +27 -8
  56. utilities/random.py +11 -6
  57. utilities/re.py +1 -1
  58. utilities/redis.py +101 -64
  59. utilities/sentinel.py +10 -0
  60. utilities/shelve.py +4 -1
  61. utilities/shutil.py +25 -0
  62. utilities/slack_sdk.py +8 -3
  63. utilities/sqlalchemy.py +422 -352
  64. utilities/sqlalchemy_polars.py +28 -52
  65. utilities/string.py +1 -1
  66. utilities/subprocess.py +1947 -0
  67. utilities/tempfile.py +95 -4
  68. utilities/testbook.py +50 -0
  69. utilities/text.py +165 -42
  70. utilities/timer.py +2 -2
  71. utilities/traceback.py +46 -36
  72. utilities/types.py +62 -23
  73. utilities/typing.py +479 -19
  74. utilities/uuid.py +42 -5
  75. utilities/version.py +27 -26
  76. utilities/whenever.py +661 -151
  77. utilities/zoneinfo.py +80 -22
  78. dycw_utilities-0.148.5.dist-info/METADATA +0 -41
  79. dycw_utilities-0.148.5.dist-info/RECORD +0 -95
  80. dycw_utilities-0.148.5.dist-info/WHEEL +0 -4
  81. dycw_utilities-0.148.5.dist-info/licenses/LICENSE +0 -21
  82. utilities/eventkit.py +0 -388
  83. utilities/period.py +0 -237
  84. utilities/typed_settings.py +0 -144
utilities/pytest.py CHANGED
@@ -1,10 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Callable
3
4
  from dataclasses import dataclass
4
5
  from functools import partial, wraps
5
6
  from inspect import iscoroutinefunction
6
7
  from os import environ
7
8
  from pathlib import Path
9
+ from re import sub
10
+ from types import FunctionType
8
11
  from typing import TYPE_CHECKING, Any, assert_never, cast, override
9
12
 
10
13
  from whenever import ZonedDateTime
@@ -13,46 +16,67 @@ from utilities.atomicwrites import writer
13
16
  from utilities.functools import cache
14
17
  from utilities.hashlib import md5_hash
15
18
  from utilities.os import get_env_var
16
- from utilities.pathlib import ensure_suffix, get_root, get_tail, module_path
17
- from utilities.platform import (
18
- IS_LINUX,
19
- IS_MAC,
20
- IS_NOT_LINUX,
21
- IS_NOT_MAC,
22
- IS_NOT_WINDOWS,
23
- IS_WINDOWS,
19
+ from utilities.pathlib import (
20
+ _GetTailEmptyError,
21
+ ensure_suffix,
22
+ get_root,
23
+ get_tail,
24
+ module_path,
24
25
  )
25
- from utilities.types import MaybeCoro
26
+ from utilities.platform import IS_LINUX, IS_MAC, IS_NOT_LINUX, IS_NOT_MAC
27
+ from utilities.random import bernoulli
28
+ from utilities.text import to_bool
29
+ from utilities.types import MaybeCallableBoolLike, MaybeCoro, Seed
26
30
  from utilities.whenever import SECOND, get_now_local
27
31
 
28
32
  if TYPE_CHECKING:
29
- from collections.abc import Callable, Iterable, Sequence
33
+ from collections.abc import Iterable
30
34
 
31
- from utilities.types import Coro, Delta, PathLike
32
-
33
- try: # WARNING: this package cannot use unguarded `pytest` imports
34
35
  from _pytest.config import Config
35
36
  from _pytest.config.argparsing import Parser
36
37
  from _pytest.python import Function
37
- from pytest import mark, skip
38
+
39
+ from utilities.types import Coro, Delta, PathLike
40
+
41
+
42
+ IS_CI = "CI" in environ
43
+ IS_CI_AND_NOT_LINUX = IS_CI and IS_NOT_LINUX
44
+
45
+
46
+ try: # WARNING: this package cannot use unguarded `pytest` imports
47
+ from pytest import mark
38
48
  except ModuleNotFoundError: # pragma: no cover
39
- from typing import Any as Config
40
- from typing import Any as Function
41
- from typing import Any as Parser
42
49
 
43
- mark = skip = skipif_windows = skipif_mac = skipif_linux = skipif_not_windows = (
44
- skipif_not_mac
45
- ) = skipif_not_linux = None
50
+ def skipif_ci[F: Callable](func: F) -> F:
51
+ return func
52
+
53
+ def skipif_mac[F: Callable](func: F) -> F:
54
+ return func
55
+
56
+ def skipif_linux[F: Callable](func: F) -> F:
57
+ return func
58
+
59
+ def skipif_not_mac[F: Callable](func: F) -> F:
60
+ return func
61
+
62
+ def skipif_not_linux[F: Callable](func: F) -> F:
63
+ return func
64
+
65
+ def skipif_ci_and_not_linux[F: Callable](func: F) -> F:
66
+ return func
67
+
46
68
  else:
47
- skipif_windows = mark.skipif(IS_WINDOWS, reason="Skipped for Windows")
69
+ skipif_ci = mark.skipif(IS_CI, reason="Skipped for CI")
48
70
  skipif_mac = mark.skipif(IS_MAC, reason="Skipped for Mac")
49
71
  skipif_linux = mark.skipif(IS_LINUX, reason="Skipped for Linux")
50
- skipif_not_windows = mark.skipif(IS_NOT_WINDOWS, reason="Skipped for non-Windows")
51
72
  skipif_not_mac = mark.skipif(IS_NOT_MAC, reason="Skipped for non-Mac")
52
73
  skipif_not_linux = mark.skipif(IS_NOT_LINUX, reason="Skipped for non-Linux")
74
+ skipif_ci_and_not_linux = mark.skipif(
75
+ IS_CI_AND_NOT_LINUX, reason="Skipped for CI/non-Linux"
76
+ )
53
77
 
54
78
 
55
- def add_pytest_addoption(parser: Parser, options: Sequence[str], /) -> None:
79
+ def add_pytest_addoption(parser: Parser, options: list[str], /) -> None:
56
80
  """Add the `--slow`, etc options to pytest.
57
81
 
58
82
  Usage:
@@ -73,7 +97,7 @@ def add_pytest_addoption(parser: Parser, options: Sequence[str], /) -> None:
73
97
 
74
98
 
75
99
  def add_pytest_collection_modifyitems(
76
- config: Config, items: Iterable[Function], options: Sequence[str], /
100
+ config: Config, items: Iterable[Function], options: list[str], /
77
101
  ) -> None:
78
102
  """Add the @mark.skips as necessary.
79
103
 
@@ -82,6 +106,8 @@ def add_pytest_collection_modifyitems(
82
106
  def pytest_collection_modifyitems(config, items):
83
107
  add_pytest_collection_modifyitems(config, items, ["slow"])
84
108
  """
109
+ from pytest import mark
110
+
85
111
  options = list(options)
86
112
  missing = {opt for opt in options if not config.getoption(f"--{opt}")}
87
113
  for item in items:
@@ -111,9 +137,10 @@ def add_pytest_configure(config: Config, options: Iterable[tuple[str, str]], /)
111
137
  ##
112
138
 
113
139
 
114
- def is_pytest() -> bool:
115
- """Check if `pytest` is running."""
116
- return "PYTEST_VERSION" in environ
140
+ def make_ids(obj: Any, /) -> str:
141
+ if isinstance(obj, FunctionType):
142
+ return sub(r"\s+at +0x[0-9a-fA-F]+", "", repr(obj))
143
+ return repr(obj)
117
144
 
118
145
 
119
146
  ##
@@ -126,10 +153,15 @@ def node_id_path(
126
153
  path_file, *parts = node_id.split("::")
127
154
  path_file = Path(path_file)
128
155
  if path_file.suffix != ".py":
129
- raise NodeIdToPathError(node_id=node_id)
156
+ raise _NodeIdToPathNotPythonFileError(node_id=node_id)
130
157
  path = path_file.with_suffix("")
131
158
  if root is not None:
132
- path = get_tail(path, root)
159
+ try:
160
+ path = get_tail(path, root)
161
+ except _GetTailEmptyError as error:
162
+ raise _NodeIdToPathNotGetTailError(
163
+ node_id=node_id, path=error.path, root=error.root
164
+ ) from None
133
165
  path = Path(module_path(path), "__".join(parts))
134
166
  if suffix is not None:
135
167
  path = ensure_suffix(path, suffix)
@@ -140,11 +172,86 @@ def node_id_path(
140
172
  class NodeIdToPathError(Exception):
141
173
  node_id: str
142
174
 
175
+
176
+ @dataclass(kw_only=True, slots=True)
177
+ class _NodeIdToPathNotPythonFileError(NodeIdToPathError):
143
178
  @override
144
179
  def __str__(self) -> str:
145
180
  return f"Node ID must be a Python file; got {self.node_id!r}"
146
181
 
147
182
 
183
+ @dataclass(kw_only=True, slots=True)
184
+ class _NodeIdToPathNotGetTailError(NodeIdToPathError):
185
+ path: PathLike
186
+ root: PathLike
187
+
188
+ @override
189
+ def __str__(self) -> str:
190
+ return (
191
+ f"Unable to get the tail of {str(self.path)!r} with root {str(self.root)!r}"
192
+ )
193
+
194
+
195
+ ##
196
+
197
+
198
+ def run_frac[F: Callable[..., MaybeCoro[None]]](
199
+ *,
200
+ predicate: MaybeCallableBoolLike | None = None,
201
+ frac: float = 0.5,
202
+ seed: Seed | None = None,
203
+ ) -> Callable[[F], F]:
204
+ """Run a test only a fraction of the time.."""
205
+ return cast(
206
+ "Any", partial(_run_frac_inner, predicate=predicate, frac=frac, seed=seed)
207
+ )
208
+
209
+
210
+ def _run_frac_inner[F: Callable[..., MaybeCoro[None]]](
211
+ func: F,
212
+ /,
213
+ *,
214
+ predicate: MaybeCallableBoolLike | None = None,
215
+ frac: float = 0.5,
216
+ seed: Seed | None = None,
217
+ ) -> F:
218
+ match bool(iscoroutinefunction(func)):
219
+ case False:
220
+
221
+ @wraps(func)
222
+ def run_frac_sync(*args: Any, **kwargs: Any) -> None:
223
+ _skipif_frac(predicate=predicate, frac=frac, seed=seed)
224
+ cast("Callable[..., None]", func)(*args, **kwargs)
225
+
226
+ return cast("Any", run_frac_sync)
227
+
228
+ case True:
229
+
230
+ @wraps(func)
231
+ async def run_frac_async(*args: Any, **kwargs: Any) -> None:
232
+ _skipif_frac(predicate=predicate, frac=frac, seed=seed)
233
+ await cast("Callable[..., Coro[None]]", func)(*args, **kwargs)
234
+
235
+ return cast("Any", run_frac_async)
236
+
237
+ case never:
238
+ assert_never(never)
239
+
240
+
241
+ def _skipif_frac(
242
+ *,
243
+ predicate: MaybeCallableBoolLike | None = None,
244
+ frac: float = 0.5,
245
+ seed: Seed | None = None,
246
+ ) -> None:
247
+ from pytest import skip
248
+
249
+ if ((predicate is None) or to_bool(predicate)) and bernoulli(
250
+ true=1 - frac, seed=seed
251
+ ):
252
+ skip(reason=f"{_get_name()} skipped (run {frac:.0%})")
253
+
254
+
148
255
  ##
149
256
 
150
257
 
@@ -163,7 +270,6 @@ def _throttle_inner[F: Callable[..., MaybeCoro[None]]](
163
270
  delta: Delta = SECOND,
164
271
  on_try: bool = False,
165
272
  ) -> F:
166
- """Throttle a test function/method."""
167
273
  if get_env_var("THROTTLE", nullable=True) is not None:
168
274
  return func
169
275
  match bool(iscoroutinefunction(func)), on_try:
@@ -173,7 +279,7 @@ def _throttle_inner[F: Callable[..., MaybeCoro[None]]](
173
279
  def throttle_sync_on_pass(*args: Any, **kwargs: Any) -> None:
174
280
  _skipif_recent(root=root, delta=delta)
175
281
  cast("Callable[..., None]", func)(*args, **kwargs)
176
- _write(root=root)
282
+ _write(root)
177
283
 
178
284
  return cast("Any", throttle_sync_on_pass)
179
285
 
@@ -182,7 +288,7 @@ def _throttle_inner[F: Callable[..., MaybeCoro[None]]](
182
288
  @wraps(func)
183
289
  def throttle_sync_on_try(*args: Any, **kwargs: Any) -> None:
184
290
  _skipif_recent(root=root, delta=delta)
185
- _write(root=root)
291
+ _write(root)
186
292
  cast("Callable[..., None]", func)(*args, **kwargs)
187
293
 
188
294
  return cast("Any", throttle_sync_on_try)
@@ -193,7 +299,7 @@ def _throttle_inner[F: Callable[..., MaybeCoro[None]]](
193
299
  async def throttle_async_on_pass(*args: Any, **kwargs: Any) -> None:
194
300
  _skipif_recent(root=root, delta=delta)
195
301
  await cast("Callable[..., Coro[None]]", func)(*args, **kwargs)
196
- _write(root=root)
302
+ _write(root)
197
303
 
198
304
  return cast("Any", throttle_async_on_pass)
199
305
 
@@ -202,25 +308,25 @@ def _throttle_inner[F: Callable[..., MaybeCoro[None]]](
202
308
  @wraps(func)
203
309
  async def throttle_async_on_try(*args: Any, **kwargs: Any) -> None:
204
310
  _skipif_recent(root=root, delta=delta)
205
- _write(root=root)
311
+ _write(root)
206
312
  await cast("Callable[..., Coro[None]]", func)(*args, **kwargs)
207
313
 
208
314
  return cast("Any", throttle_async_on_try)
209
315
 
210
- case _ as never:
316
+ case never:
211
317
  assert_never(never)
212
318
 
213
319
 
214
320
  def _skipif_recent(*, root: PathLike | None = None, delta: Delta = SECOND) -> None:
215
- if skip is None:
216
- return # pragma: no cover
217
- path = _get_path(root=root)
321
+ from pytest import skip
322
+
323
+ path = _get_path(root)
218
324
  try:
219
325
  contents = path.read_text()
220
326
  except FileNotFoundError:
221
327
  return
222
328
  try:
223
- last = ZonedDateTime.parse_common_iso(contents)
329
+ last = ZonedDateTime.parse_iso(contents)
224
330
  except ValueError:
225
331
  return
226
332
  now = get_now_local()
@@ -229,7 +335,7 @@ def _skipif_recent(*, root: PathLike | None = None, delta: Delta = SECOND) -> No
229
335
  _ = skip(reason=f"{_get_name()} throttled (age {age})")
230
336
 
231
337
 
232
- def _get_path(*, root: PathLike | None = None) -> Path:
338
+ def _get_path(root: PathLike | None = None, /) -> Path:
233
339
  if root is None:
234
340
  root_use = get_root().joinpath(".pytest_cache", "throttle") # pragma: no cover
235
341
  else:
@@ -246,24 +352,27 @@ def _get_name() -> str:
246
352
  return environ["PYTEST_CURRENT_TEST"]
247
353
 
248
354
 
249
- def _write(*, root: PathLike | None = None) -> None:
250
- path = _get_path(root=root)
355
+ def _write(root: PathLike | None = None, /) -> None:
356
+ path = _get_path(root)
251
357
  with writer(path, overwrite=True) as temp:
252
- _ = temp.write_text(get_now_local().format_common_iso())
358
+ _ = temp.write_text(get_now_local().format_iso())
253
359
 
254
360
 
255
361
  __all__ = [
362
+ "IS_CI",
363
+ "IS_CI_AND_NOT_LINUX",
256
364
  "NodeIdToPathError",
257
365
  "add_pytest_addoption",
258
366
  "add_pytest_collection_modifyitems",
259
367
  "add_pytest_configure",
260
- "is_pytest",
368
+ "make_ids",
261
369
  "node_id_path",
370
+ "run_frac",
371
+ "skipif_ci",
372
+ "skipif_ci_and_not_linux",
262
373
  "skipif_linux",
263
374
  "skipif_mac",
264
375
  "skipif_not_linux",
265
376
  "skipif_not_mac",
266
- "skipif_not_windows",
267
- "skipif_windows",
268
377
  "throttle",
269
378
  ]
@@ -17,7 +17,7 @@ else:
17
17
  """Fixture for a random state."""
18
18
  from utilities.random import get_state
19
19
 
20
- return get_state(seed=seed)
20
+ return get_state(seed)
21
21
 
22
22
 
23
23
  __all__ = ["random_state"]
@@ -41,12 +41,16 @@ else:
41
41
 
42
42
  def _get_path(request: FixtureRequest, /) -> Path:
43
43
  from utilities.pathlib import get_root
44
- from utilities.pytest import node_id_path
44
+ from utilities.pytest import _NodeIdToPathNotGetTailError, node_id_path
45
45
 
46
46
  path = Path(cast("Any", request).fspath)
47
47
  root = Path("src", "tests")
48
- tail = node_id_path(request.node.nodeid, root=root)
49
- return get_root(path=path).joinpath(root, "regressions", tail)
48
+ try:
49
+ tail = node_id_path(request.node.nodeid, root=root)
50
+ except _NodeIdToPathNotGetTailError:
51
+ root = Path("tests")
52
+ tail = node_id_path(request.node.nodeid, root=root)
53
+ return get_root(path).joinpath(root, "regressions", tail)
50
54
 
51
55
 
52
56
  __all__ = ["orjson_regression", "polars_regression"]
@@ -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
  ##
@@ -97,7 +117,6 @@ class PolarsRegressionFixture:
97
117
  "describe": obj.describe(percentiles=[i / 10 for i in range(1, 10)]).rows(
98
118
  named=True
99
119
  ),
100
- "estimated_size": obj.estimated_size(),
101
120
  "is_empty": obj.is_empty(),
102
121
  "n_unique": obj.n_unique(),
103
122
  }
@@ -115,9 +134,9 @@ class PolarsRegressionFixture:
115
134
  col(column).approx_n_unique()
116
135
  ).item()
117
136
  data["approx_n_unique"] = approx_n_unique
118
- data["glimpse"] = df.glimpse(return_as_string=True)
137
+ data["glimpse"] = df.glimpse(return_type="string")
119
138
  data["null_count"] = df.null_count().row(0, named=True)
120
- case _ as never:
139
+ case never:
121
140
  assert_never(never)
122
141
  self._fixture.check(data, suffix=suffix)
123
142
 
utilities/random.py CHANGED
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  from random import Random, SystemRandom
4
4
  from typing import TYPE_CHECKING
5
5
 
6
+ from utilities.functools import cache
7
+
6
8
  if TYPE_CHECKING:
7
9
  from collections.abc import Iterable
8
10
 
@@ -17,16 +19,16 @@ SYSTEM_RANDOM = SystemRandom()
17
19
 
18
20
  def bernoulli(*, true: float = 0.5, seed: Seed | None = None) -> bool:
19
21
  """Return a Bernoulli random variate."""
20
- seed = get_state(seed=seed)
21
- return bool(seed.binomialvariate(p=true))
22
+ state = get_state(seed)
23
+ return bool(state.binomialvariate(p=true))
22
24
 
23
25
 
24
26
  ##
25
27
 
26
28
 
27
- def get_docker_name(*, seed: Seed | None = None) -> str:
29
+ def get_docker_name(seed: Seed | None = None, /) -> str:
28
30
  """Get a docker name."""
29
- state = get_state(seed=seed)
31
+ state = get_state(seed)
30
32
  prefix = state.choice(_DOCKER_PREFIXES)
31
33
  suffix = state.choice(_DOCKER_SUFFIXES)
32
34
  digit = state.randint(0, 9)
@@ -47,16 +49,19 @@ _DOCKER_SUFFIXES = [
47
49
  ##
48
50
 
49
51
 
50
- def get_state(*, seed: Seed | None = None) -> Random:
52
+ @cache
53
+ def get_state(seed: Seed | None = None, /) -> Random:
51
54
  """Get a random state."""
52
55
  return seed if isinstance(seed, Random) else Random(x=seed)
53
56
 
54
57
 
55
58
  ##
59
+
60
+
56
61
  def shuffle[T](iterable: Iterable[T], /, *, seed: Seed | None = None) -> list[T]:
57
62
  """Shuffle an iterable."""
58
63
  copy = list(iterable).copy()
59
- state = get_state(seed=seed)
64
+ state = get_state(seed)
60
65
  state.shuffle(copy)
61
66
  return copy
62
67
 
utilities/re.py CHANGED
@@ -16,7 +16,7 @@ def ensure_pattern(pattern: PatternLike, /, *, flags: int = 0) -> Pattern[str]:
16
16
  return pattern
17
17
  case str():
18
18
  return re.compile(pattern, flags=flags)
19
- case _ as never:
19
+ case never:
20
20
  assert_never(never)
21
21
 
22
22