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/pytest.py CHANGED
@@ -1,68 +1,82 @@
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
8
- from typing import TYPE_CHECKING, Any, ParamSpec, assert_never, cast, override
9
+ from re import sub
10
+ from types import FunctionType
11
+ from typing import TYPE_CHECKING, Any, assert_never, cast, override
9
12
 
10
- from pytest import fixture
13
+ from whenever import ZonedDateTime
11
14
 
12
- from utilities.datetime import datetime_duration_to_float, get_now
15
+ from utilities.atomicwrites import writer
13
16
  from utilities.functools import cache
14
17
  from utilities.hashlib import md5_hash
15
- from utilities.pathlib import ensure_suffix
16
- from utilities.platform import (
17
- IS_LINUX,
18
- IS_MAC,
19
- IS_NOT_LINUX,
20
- IS_NOT_MAC,
21
- IS_NOT_WINDOWS,
22
- IS_WINDOWS,
18
+ from utilities.os import get_env_var
19
+ from utilities.pathlib import (
20
+ _GetTailEmptyError,
21
+ ensure_suffix,
22
+ get_root,
23
+ get_tail,
24
+ module_path,
23
25
  )
24
- from utilities.random import get_state
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
30
+ from utilities.whenever import SECOND, get_now_local
25
31
 
26
32
  if TYPE_CHECKING:
27
- from collections.abc import Callable, Iterable, Sequence
28
- from random import Random
29
-
30
- from utilities.types import (
31
- Coroutine1,
32
- Duration,
33
- PathLike,
34
- TCallableMaybeCoroutine1None,
35
- )
33
+ from collections.abc import Iterable
36
34
 
37
- try: # WARNING: this package cannot use unguarded `pytest` imports
38
35
  from _pytest.config import Config
39
36
  from _pytest.config.argparsing import Parser
40
37
  from _pytest.python import Function
41
- 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
42
48
  except ModuleNotFoundError: # pragma: no cover
43
- from typing import Any as Config
44
- from typing import Any as Function
45
- from typing import Any as Parser
46
49
 
47
- mark = skip = skipif_windows = skipif_mac = skipif_linux = skipif_not_windows = (
48
- skipif_not_mac
49
- ) = 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
+
50
68
  else:
51
- skipif_windows = mark.skipif(IS_WINDOWS, reason="Skipped for Windows")
69
+ skipif_ci = mark.skipif(IS_CI, reason="Skipped for CI")
52
70
  skipif_mac = mark.skipif(IS_MAC, reason="Skipped for Mac")
53
71
  skipif_linux = mark.skipif(IS_LINUX, reason="Skipped for Linux")
54
- skipif_not_windows = mark.skipif(IS_NOT_WINDOWS, reason="Skipped for non-Windows")
55
72
  skipif_not_mac = mark.skipif(IS_NOT_MAC, reason="Skipped for non-Mac")
56
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
+ )
57
77
 
58
78
 
59
- _P = ParamSpec("_P")
60
-
61
-
62
- ##
63
-
64
-
65
- def add_pytest_addoption(parser: Parser, options: Sequence[str], /) -> None:
79
+ def add_pytest_addoption(parser: Parser, options: list[str], /) -> None:
66
80
  """Add the `--slow`, etc options to pytest.
67
81
 
68
82
  Usage:
@@ -83,7 +97,7 @@ def add_pytest_addoption(parser: Parser, options: Sequence[str], /) -> None:
83
97
 
84
98
 
85
99
  def add_pytest_collection_modifyitems(
86
- config: Config, items: Iterable[Function], options: Sequence[str], /
100
+ config: Config, items: Iterable[Function], options: list[str], /
87
101
  ) -> None:
88
102
  """Add the @mark.skips as necessary.
89
103
 
@@ -92,6 +106,8 @@ def add_pytest_collection_modifyitems(
92
106
  def pytest_collection_modifyitems(config, items):
93
107
  add_pytest_collection_modifyitems(config, items, ["slow"])
94
108
  """
109
+ from pytest import mark
110
+
95
111
  options = list(options)
96
112
  missing = {opt for opt in options if not config.getoption(f"--{opt}")}
97
113
  for item in items:
@@ -121,26 +137,32 @@ def add_pytest_configure(config: Config, options: Iterable[tuple[str, str]], /)
121
137
  ##
122
138
 
123
139
 
124
- def is_pytest() -> bool:
125
- """Check if `pytest` is running."""
126
- 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)
127
144
 
128
145
 
129
146
  ##
130
147
 
131
148
 
132
- def node_id_to_path(
133
- node_id: str, /, *, head: PathLike | None = None, suffix: str | None = None
149
+ def node_id_path(
150
+ node_id: str, /, *, root: PathLike | None = None, suffix: str | None = None
134
151
  ) -> Path:
135
- """Map a node ID to a path."""
152
+ """Get the path of a node ID."""
136
153
  path_file, *parts = node_id.split("::")
137
154
  path_file = Path(path_file)
138
155
  if path_file.suffix != ".py":
139
- raise NodeIdToPathError(node_id=node_id)
156
+ raise _NodeIdToPathNotPythonFileError(node_id=node_id)
140
157
  path = path_file.with_suffix("")
141
- if head is not None:
142
- path = path.relative_to(head)
143
- path = Path(".".join(path.parts), "__".join(parts))
158
+ if root is not None:
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
165
+ path = Path(module_path(path), "__".join(parts))
144
166
  if suffix is not None:
145
167
  path = ensure_suffix(path, suffix)
146
168
  return path
@@ -150,123 +172,207 @@ def node_id_to_path(
150
172
  class NodeIdToPathError(Exception):
151
173
  node_id: str
152
174
 
175
+
176
+ @dataclass(kw_only=True, slots=True)
177
+ class _NodeIdToPathNotPythonFileError(NodeIdToPathError):
153
178
  @override
154
179
  def __str__(self) -> str:
155
180
  return f"Node ID must be a Python file; got {self.node_id!r}"
156
181
 
157
182
 
158
- ##
159
-
183
+ @dataclass(kw_only=True, slots=True)
184
+ class _NodeIdToPathNotGetTailError(NodeIdToPathError):
185
+ path: PathLike
186
+ root: PathLike
160
187
 
161
- @fixture
162
- def random_state(*, seed: int) -> Random:
163
- """Fixture for a random state."""
164
- return get_state(seed=seed)
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
+ )
165
193
 
166
194
 
167
195
  ##
168
196
 
169
197
 
170
- def throttle(
171
- *, root: PathLike | None = None, duration: Duration = 1.0, on_try: bool = False
172
- ) -> Callable[[TCallableMaybeCoroutine1None], TCallableMaybeCoroutine1None]:
173
- """Throttle a test. On success by default, on try otherwise."""
174
- root_use = Path(".pytest_cache", "throttle") if root is None else Path(root)
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.."""
175
205
  return cast(
176
- "Any", partial(_throttle_inner, root=root_use, duration=duration, on_try=on_try)
206
+ "Any", partial(_run_frac_inner, predicate=predicate, frac=frac, seed=seed)
177
207
  )
178
208
 
179
209
 
180
- def _throttle_inner(
181
- func: TCallableMaybeCoroutine1None,
210
+ def _run_frac_inner[F: Callable[..., MaybeCoro[None]]](
211
+ func: F,
182
212
  /,
183
213
  *,
184
- root: Path,
185
- duration: Duration = 1.0,
186
- on_try: bool = False,
187
- ) -> TCallableMaybeCoroutine1None:
188
- """Throttle a test function/method."""
214
+ predicate: MaybeCallableBoolLike | None = None,
215
+ frac: float = 0.5,
216
+ seed: Seed | None = None,
217
+ ) -> F:
189
218
  match bool(iscoroutinefunction(func)):
190
219
  case False:
191
- func_typed = cast("Callable[..., None]", func)
192
220
 
193
221
  @wraps(func)
194
- def throttle_sync(*args: _P.args, **kwargs: _P.kwargs) -> None:
195
- """Call the throttled sync test function/method."""
196
- path, now = _throttle_path_and_now(root, duration=duration)
197
- if on_try:
198
- _throttle_write(path, now)
199
- return func_typed(*args, **kwargs)
200
- func_typed(*args, **kwargs)
201
- _throttle_write(path, now)
202
- return None
203
-
204
- return cast("TCallableMaybeCoroutine1None", throttle_sync)
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
+
205
228
  case True:
206
- func_typed = cast("Callable[..., Coroutine1[None]]", func)
207
229
 
208
230
  @wraps(func)
209
- async def throttle_async(*args: _P.args, **kwargs: _P.kwargs) -> None:
210
- """Call the throttled async test function/method."""
211
- path, now = _throttle_path_and_now(root, duration=duration)
212
- if on_try:
213
- _throttle_write(path, now)
214
- return await func_typed(*args, **kwargs)
215
- await func_typed(*args, **kwargs)
216
- _throttle_write(path, now)
217
- return None
218
-
219
- return cast("TCallableMaybeCoroutine1None", throttle_async)
220
- case _ as never:
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:
221
238
  assert_never(never)
222
239
 
223
240
 
224
- def _throttle_path_and_now(
225
- root: Path, /, *, duration: Duration = 1.0
226
- ) -> tuple[Path, float]:
227
- test = environ["PYTEST_CURRENT_TEST"]
228
- path = Path(root, _throttle_md5_hash(test))
229
- if path.exists():
230
- with path.open(mode="r") as fh:
231
- contents = fh.read()
232
- prev = float(contents)
233
- else:
234
- prev = None
235
- now = get_now().timestamp()
236
- if (
237
- (skip is not None)
238
- and (prev is not None)
239
- and ((now - prev) < datetime_duration_to_float(duration))
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
240
251
  ):
241
- _ = skip(reason=f"{test} throttled")
242
- return path, now
252
+ skip(reason=f"{_get_name()} skipped (run {frac:.0%})")
253
+
254
+
255
+ ##
256
+
257
+
258
+ def throttle[F: Callable[..., MaybeCoro[None]]](
259
+ *, root: PathLike | None = None, delta: Delta = SECOND, on_try: bool = False
260
+ ) -> Callable[[F], F]:
261
+ """Throttle a test. On success by default, on try otherwise."""
262
+ return cast("Any", partial(_throttle_inner, root=root, delta=delta, on_try=on_try))
263
+
264
+
265
+ def _throttle_inner[F: Callable[..., MaybeCoro[None]]](
266
+ func: F,
267
+ /,
268
+ *,
269
+ root: PathLike | None = None,
270
+ delta: Delta = SECOND,
271
+ on_try: bool = False,
272
+ ) -> F:
273
+ if get_env_var("THROTTLE", nullable=True) is not None:
274
+ return func
275
+ match bool(iscoroutinefunction(func)), on_try:
276
+ case False, False:
277
+
278
+ @wraps(func)
279
+ def throttle_sync_on_pass(*args: Any, **kwargs: Any) -> None:
280
+ _skipif_recent(root=root, delta=delta)
281
+ cast("Callable[..., None]", func)(*args, **kwargs)
282
+ _write(root)
283
+
284
+ return cast("Any", throttle_sync_on_pass)
285
+
286
+ case False, True:
287
+
288
+ @wraps(func)
289
+ def throttle_sync_on_try(*args: Any, **kwargs: Any) -> None:
290
+ _skipif_recent(root=root, delta=delta)
291
+ _write(root)
292
+ cast("Callable[..., None]", func)(*args, **kwargs)
293
+
294
+ return cast("Any", throttle_sync_on_try)
295
+
296
+ case True, False:
297
+
298
+ @wraps(func)
299
+ async def throttle_async_on_pass(*args: Any, **kwargs: Any) -> None:
300
+ _skipif_recent(root=root, delta=delta)
301
+ await cast("Callable[..., Coro[None]]", func)(*args, **kwargs)
302
+ _write(root)
303
+
304
+ return cast("Any", throttle_async_on_pass)
305
+
306
+ case True, True:
307
+
308
+ @wraps(func)
309
+ async def throttle_async_on_try(*args: Any, **kwargs: Any) -> None:
310
+ _skipif_recent(root=root, delta=delta)
311
+ _write(root)
312
+ await cast("Callable[..., Coro[None]]", func)(*args, **kwargs)
313
+
314
+ return cast("Any", throttle_async_on_try)
315
+
316
+ case never:
317
+ assert_never(never)
318
+
319
+
320
+ def _skipif_recent(*, root: PathLike | None = None, delta: Delta = SECOND) -> None:
321
+ from pytest import skip
322
+
323
+ path = _get_path(root)
324
+ try:
325
+ contents = path.read_text()
326
+ except FileNotFoundError:
327
+ return
328
+ try:
329
+ last = ZonedDateTime.parse_iso(contents)
330
+ except ValueError:
331
+ return
332
+ now = get_now_local()
333
+ if (now - delta) < last:
334
+ age = now - last
335
+ _ = skip(reason=f"{_get_name()} throttled (age {age})")
336
+
337
+
338
+ def _get_path(root: PathLike | None = None, /) -> Path:
339
+ if root is None:
340
+ root_use = get_root().joinpath(".pytest_cache", "throttle") # pragma: no cover
341
+ else:
342
+ root_use = root
343
+ return Path(root_use, _md5_hash_cached(_get_name()))
243
344
 
244
345
 
245
346
  @cache
246
- def _throttle_md5_hash(text: str, /) -> str:
347
+ def _md5_hash_cached(text: str, /) -> str:
247
348
  return md5_hash(text)
248
349
 
249
350
 
250
- def _throttle_write(path: Path, now: float, /) -> None:
251
- from utilities.atomicwrites import writer
351
+ def _get_name() -> str:
352
+ return environ["PYTEST_CURRENT_TEST"]
353
+
252
354
 
253
- with writer(path, overwrite=True) as temp, temp.open(mode="w") as fh:
254
- _ = fh.write(str(now))
355
+ def _write(root: PathLike | None = None, /) -> None:
356
+ path = _get_path(root)
357
+ with writer(path, overwrite=True) as temp:
358
+ _ = temp.write_text(get_now_local().format_iso())
255
359
 
256
360
 
257
361
  __all__ = [
362
+ "IS_CI",
363
+ "IS_CI_AND_NOT_LINUX",
258
364
  "NodeIdToPathError",
259
365
  "add_pytest_addoption",
260
366
  "add_pytest_collection_modifyitems",
261
367
  "add_pytest_configure",
262
- "is_pytest",
263
- "node_id_to_path",
264
- "random_state",
368
+ "make_ids",
369
+ "node_id_path",
370
+ "run_frac",
371
+ "skipif_ci",
372
+ "skipif_ci_and_not_linux",
265
373
  "skipif_linux",
266
374
  "skipif_mac",
267
375
  "skipif_not_linux",
268
376
  "skipif_not_mac",
269
- "skipif_not_windows",
270
- "skipif_windows",
271
377
  "throttle",
272
378
  ]
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from random import Random
7
+
8
+
9
+ try:
10
+ from pytest import fixture
11
+ except ModuleNotFoundError:
12
+ pass
13
+ else:
14
+
15
+ @fixture
16
+ def random_state(*, seed: int) -> Random:
17
+ """Fixture for a random state."""
18
+ from utilities.random import get_state
19
+
20
+ return get_state(seed)
21
+
22
+
23
+ __all__ = ["random_state"]
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Any, cast
5
+
6
+ if TYPE_CHECKING:
7
+ from pytest import FixtureRequest
8
+
9
+ from utilities.pytest_regressions import (
10
+ OrjsonRegressionFixture,
11
+ PolarsRegressionFixture,
12
+ )
13
+
14
+
15
+ try:
16
+ from pytest import fixture
17
+ except ModuleNotFoundError:
18
+ pass
19
+ else:
20
+
21
+ @fixture
22
+ def orjson_regression(
23
+ *, request: FixtureRequest, tmp_path: Path
24
+ ) -> OrjsonRegressionFixture:
25
+ """Instance of the `OrjsonRegressionFixture`."""
26
+ from utilities.pytest_regressions import OrjsonRegressionFixture
27
+
28
+ path = _get_path(request)
29
+ return OrjsonRegressionFixture(path, request, tmp_path)
30
+
31
+ @fixture
32
+ def polars_regression(
33
+ *, request: FixtureRequest, tmp_path: Path
34
+ ) -> PolarsRegressionFixture:
35
+ """Instance of the `PolarsRegressionFixture`."""
36
+ from utilities.pytest_regressions import PolarsRegressionFixture
37
+
38
+ path = _get_path(request)
39
+ return PolarsRegressionFixture(path, request, tmp_path)
40
+
41
+
42
+ def _get_path(request: FixtureRequest, /) -> Path:
43
+ from utilities.pathlib import get_root
44
+ from utilities.pytest import _NodeIdToPathNotGetTailError, node_id_path
45
+
46
+ path = Path(cast("Any", request).fspath)
47
+ root = Path("src", "tests")
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)
54
+
55
+
56
+ __all__ = ["orjson_regression", "polars_regression"]
@@ -1,18 +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
- from pytest import fixture
10
10
  from pytest_regressions.file_regression import FileRegressionFixture
11
11
 
12
12
  from utilities.functions import ensure_str
13
13
  from utilities.operator import is_equal
14
- from utilities.pathlib import get_root
15
- from utilities.pytest import node_id_to_path
14
+ from utilities.reprlib import get_repr
16
15
 
17
16
  if TYPE_CHECKING:
18
17
  from polars import DataFrame, Series
@@ -21,9 +20,6 @@ if TYPE_CHECKING:
21
20
  from utilities.types import PathLike, StrMapping
22
21
 
23
22
 
24
- _PATH_TESTS = Path("src", "tests")
25
-
26
-
27
23
  ##
28
24
 
29
25
 
@@ -76,21 +72,28 @@ class OrjsonRegressionFixture:
76
72
  check_fn=self._check_fn,
77
73
  )
78
74
 
79
- def _check_fn(self, path1: Path, path2: Path, /) -> None:
80
- with path1.open(mode="r") as fh:
81
- left = loads(fh.read())
82
- with path2.open(mode="r") as fh:
83
- right = loads(fh.read())
84
- 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
+
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
86
93
 
87
- @fixture
88
- def orjson_regression(
89
- *, request: FixtureRequest, tmp_path: Path
90
- ) -> OrjsonRegressionFixture:
91
- """Instance of the `OrjsonRegressionFixture`."""
92
- path = _get_path(request)
93
- return OrjsonRegressionFixture(path, request, tmp_path)
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)}"
94
97
 
95
98
 
96
99
  ##
@@ -114,7 +117,6 @@ class PolarsRegressionFixture:
114
117
  "describe": obj.describe(percentiles=[i / 10 for i in range(1, 10)]).rows(
115
118
  named=True
116
119
  ),
117
- "estimated_size": obj.estimated_size(),
118
120
  "is_empty": obj.is_empty(),
119
121
  "n_unique": obj.n_unique(),
120
122
  }
@@ -132,33 +134,11 @@ class PolarsRegressionFixture:
132
134
  col(column).approx_n_unique()
133
135
  ).item()
134
136
  data["approx_n_unique"] = approx_n_unique
135
- data["glimpse"] = df.glimpse(return_as_string=True)
137
+ data["glimpse"] = df.glimpse(return_type="string")
136
138
  data["null_count"] = df.null_count().row(0, named=True)
137
- case _ as never:
139
+ case never:
138
140
  assert_never(never)
139
141
  self._fixture.check(data, suffix=suffix)
140
142
 
141
143
 
142
- @fixture
143
- def polars_regression(
144
- *, request: FixtureRequest, tmp_path: Path
145
- ) -> PolarsRegressionFixture:
146
- """Instance of the `PolarsRegressionFixture`."""
147
- path = _get_path(request)
148
- return PolarsRegressionFixture(path, request, tmp_path)
149
-
150
-
151
- ##
152
-
153
-
154
- def _get_path(request: FixtureRequest, /) -> Path:
155
- tail = node_id_to_path(request.node.nodeid, head=_PATH_TESTS)
156
- return get_root().joinpath(_PATH_TESTS, "regressions", tail)
157
-
158
-
159
- __all__ = [
160
- "OrjsonRegressionFixture",
161
- "PolarsRegressionFixture",
162
- "orjson_regression",
163
- "polars_regression",
164
- ]
144
+ __all__ = ["OrjsonRegressionFixture", "PolarsRegressionFixture"]