dycw-utilities 0.175.17__py3-none-any.whl → 0.185.8__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 (94) hide show
  1. dycw_utilities-0.185.8.dist-info/METADATA +33 -0
  2. dycw_utilities-0.185.8.dist-info/RECORD +90 -0
  3. {dycw_utilities-0.175.17.dist-info → dycw_utilities-0.185.8.dist-info}/WHEEL +2 -2
  4. utilities/__init__.py +1 -1
  5. utilities/altair.py +8 -6
  6. utilities/asyncio.py +40 -56
  7. utilities/atools.py +9 -11
  8. utilities/cachetools.py +8 -6
  9. utilities/click.py +4 -3
  10. utilities/concurrent.py +1 -1
  11. utilities/constants.py +492 -0
  12. utilities/contextlib.py +23 -30
  13. utilities/contextvars.py +1 -23
  14. utilities/core.py +2581 -0
  15. utilities/dataclasses.py +16 -119
  16. utilities/docker.py +139 -45
  17. utilities/enum.py +1 -1
  18. utilities/errors.py +2 -16
  19. utilities/fastapi.py +5 -5
  20. utilities/fpdf2.py +2 -1
  21. utilities/functions.py +33 -264
  22. utilities/http.py +2 -3
  23. utilities/hypothesis.py +48 -25
  24. utilities/iterables.py +39 -575
  25. utilities/jinja2.py +3 -6
  26. utilities/jupyter.py +5 -3
  27. utilities/libcst.py +1 -1
  28. utilities/lightweight_charts.py +4 -6
  29. utilities/logging.py +17 -15
  30. utilities/math.py +1 -36
  31. utilities/more_itertools.py +4 -6
  32. utilities/numpy.py +2 -1
  33. utilities/operator.py +2 -2
  34. utilities/orjson.py +24 -25
  35. utilities/os.py +4 -185
  36. utilities/packaging.py +129 -0
  37. utilities/parse.py +33 -13
  38. utilities/pathlib.py +2 -136
  39. utilities/platform.py +8 -90
  40. utilities/polars.py +34 -31
  41. utilities/postgres.py +9 -4
  42. utilities/pottery.py +20 -18
  43. utilities/pqdm.py +3 -4
  44. utilities/psutil.py +2 -3
  45. utilities/pydantic.py +18 -4
  46. utilities/pydantic_settings.py +7 -9
  47. utilities/pydantic_settings_sops.py +3 -3
  48. utilities/pyinstrument.py +4 -4
  49. utilities/pytest.py +49 -108
  50. utilities/pytest_plugins/pytest_regressions.py +2 -2
  51. utilities/pytest_regressions.py +8 -6
  52. utilities/random.py +2 -8
  53. utilities/redis.py +98 -94
  54. utilities/reprlib.py +11 -118
  55. utilities/shellingham.py +66 -0
  56. utilities/slack_sdk.py +13 -12
  57. utilities/sqlalchemy.py +42 -30
  58. utilities/sqlalchemy_polars.py +16 -25
  59. utilities/subprocess.py +1166 -148
  60. utilities/tabulate.py +32 -0
  61. utilities/testbook.py +8 -8
  62. utilities/text.py +24 -115
  63. utilities/throttle.py +159 -0
  64. utilities/time.py +18 -0
  65. utilities/timer.py +29 -12
  66. utilities/traceback.py +15 -22
  67. utilities/types.py +38 -3
  68. utilities/typing.py +18 -12
  69. utilities/uuid.py +1 -1
  70. utilities/version.py +202 -45
  71. utilities/whenever.py +22 -150
  72. dycw_utilities-0.175.17.dist-info/METADATA +0 -34
  73. dycw_utilities-0.175.17.dist-info/RECORD +0 -103
  74. utilities/atomicwrites.py +0 -182
  75. utilities/cryptography.py +0 -41
  76. utilities/getpass.py +0 -8
  77. utilities/git.py +0 -19
  78. utilities/grp.py +0 -28
  79. utilities/gzip.py +0 -31
  80. utilities/json.py +0 -70
  81. utilities/permissions.py +0 -298
  82. utilities/pickle.py +0 -25
  83. utilities/pwd.py +0 -28
  84. utilities/re.py +0 -156
  85. utilities/sentinel.py +0 -73
  86. utilities/socket.py +0 -8
  87. utilities/string.py +0 -20
  88. utilities/tempfile.py +0 -136
  89. utilities/tzdata.py +0 -11
  90. utilities/tzlocal.py +0 -28
  91. utilities/warnings.py +0 -65
  92. utilities/zipfile.py +0 -25
  93. utilities/zoneinfo.py +0 -133
  94. {dycw_utilities-0.175.17.dist-info → dycw_utilities-0.185.8.dist-info}/entry_points.txt +0 -0
utilities/tabulate.py ADDED
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from textwrap import indent
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from tabulate import tabulate
7
+
8
+ from utilities.core import get_func_name, normalize_str
9
+ from utilities.text import split_f_str_equals
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+
14
+
15
+ def func_param_desc(func: Callable[..., Any], version: str, /, *variables: str) -> str:
16
+ """Generate a string describing a function & its parameters."""
17
+ name = get_func_name(func)
18
+ table = indent(params_table(*variables), " ")
19
+ return normalize_str(f"""\
20
+ Running {name!r} (version {version}) with:
21
+ {table}
22
+ """)
23
+
24
+
25
+ def params_table(*variables: str) -> str:
26
+ """Generate a table of parameter names and values."""
27
+ data = list(map(split_f_str_equals, variables))
28
+ table = tabulate(data, tablefmt="rounded_outline")
29
+ return normalize_str(table)
30
+
31
+
32
+ __all__ = ["func_param_desc", "params_table"]
utilities/testbook.py CHANGED
@@ -5,17 +5,17 @@ from typing import TYPE_CHECKING, Any
5
5
 
6
6
  from testbook import testbook
7
7
 
8
- from utilities.pytest import throttle
9
- from utilities.text import pascal_case
8
+ from utilities.core import pascal_case
9
+ from utilities.pytest import throttle_test
10
10
 
11
11
  if TYPE_CHECKING:
12
12
  from collections.abc import Callable
13
13
 
14
- from utilities.types import Delta, PathLike
14
+ from utilities.types import Duration, PathLike
15
15
 
16
16
 
17
17
  def build_notebook_tester(
18
- path: PathLike, /, *, throttle: Delta | None = None, on_try: bool = False
18
+ path: PathLike, /, *, throttle: Duration | None = None, on_try: bool = False
19
19
  ) -> type[Any]:
20
20
  """Build the notebook tester class."""
21
21
  path = Path(path)
@@ -27,7 +27,7 @@ def build_notebook_tester(
27
27
  ]
28
28
  namespace = {
29
29
  f"test_{p.stem.replace('-', '_')}": _build_test_method(
30
- p, delta=throttle, on_try=on_try
30
+ p, duration=throttle, on_try=on_try
31
31
  )
32
32
  for p in notebooks
33
33
  }
@@ -35,14 +35,14 @@ def build_notebook_tester(
35
35
 
36
36
 
37
37
  def _build_test_method(
38
- path: Path, /, *, delta: Delta | None = None, on_try: bool = False
38
+ path: Path, /, *, duration: Duration | None = None, on_try: bool = False
39
39
  ) -> Callable[..., Any]:
40
40
  @testbook(path, execute=True)
41
41
  def method(self: Any, tb: Any) -> None:
42
42
  _ = (self, tb) # pragma: no cover
43
43
 
44
- if delta is not None:
45
- method = throttle(delta=delta, on_try=on_try)(method)
44
+ if duration is not None:
45
+ method = throttle_test(duration=duration, on_try=on_try)(method)
46
46
 
47
47
  return method
48
48
 
utilities/text.py CHANGED
@@ -5,11 +5,7 @@ from collections import deque
5
5
  from collections.abc import Callable
6
6
  from dataclasses import dataclass
7
7
  from itertools import chain
8
- from os import getpid
9
- from re import IGNORECASE, VERBOSE, escape, search
10
- from textwrap import dedent
11
- from threading import get_ident
12
- from time import time_ns
8
+ from re import IGNORECASE, escape, search
13
9
  from typing import (
14
10
  TYPE_CHECKING,
15
11
  Any,
@@ -19,11 +15,10 @@ from typing import (
19
15
  overload,
20
16
  override,
21
17
  )
22
- from uuid import uuid4
23
18
 
24
- from utilities.iterables import CheckDuplicatesError, check_duplicates, transpose
25
- from utilities.reprlib import get_repr
26
- from utilities.sentinel import Sentinel
19
+ from utilities.constants import BRACKETS, LIST_SEPARATOR, PAIR_SEPARATOR, Sentinel
20
+ from utilities.core import repr_, transpose
21
+ from utilities.iterables import CheckDuplicatesError, check_duplicates
27
22
 
28
23
  if TYPE_CHECKING:
29
24
  from collections.abc import Iterable, Mapping, Sequence
@@ -31,17 +26,6 @@ if TYPE_CHECKING:
31
26
  from utilities.types import MaybeCallableBoolLike, MaybeCallableStr, StrStrMapping
32
27
 
33
28
 
34
- _DEFAULT_SEPARATOR = ","
35
-
36
-
37
- ##
38
-
39
-
40
- def kebab_case(text: str, /) -> str:
41
- """Convert text into kebab case."""
42
- return _kebab_snake_case(text, "-")
43
-
44
-
45
29
  ##
46
30
 
47
31
 
@@ -85,21 +69,6 @@ class ParseNoneError(Exception):
85
69
  ##
86
70
 
87
71
 
88
- def pascal_case(text: str, /) -> str:
89
- """Convert text to pascal case."""
90
- parts = _SPLIT_TEXT.findall(text)
91
- parts = [p for p in parts if len(p) >= 1]
92
- parts = list(map(_pascal_case_one, parts))
93
- return "".join(parts)
94
-
95
-
96
- def _pascal_case_one(text: str, /) -> str:
97
- return text if text.isupper() else text.title()
98
-
99
-
100
- ##
101
-
102
-
103
72
  def prompt_bool(prompt: object = "", /, *, confirm: bool = False) -> bool:
104
73
  """Prompt for a boolean."""
105
74
  return True if confirm else parse_bool(input(prompt))
@@ -108,33 +77,21 @@ def prompt_bool(prompt: object = "", /, *, confirm: bool = False) -> bool:
108
77
  ##
109
78
 
110
79
 
111
- def repr_encode(obj: Any, /) -> bytes:
112
- """Return the representation of the object encoded as bytes."""
113
- return repr(obj).encode()
80
+ def split_f_str_equals(text: str, /) -> tuple[str, str]:
81
+ """Split an `f`-string with `=`."""
82
+ first, second = text.split(sep="=", maxsplit=1)
83
+ return first, second
114
84
 
115
85
 
116
86
  ##
117
87
 
118
88
 
119
- def snake_case(text: str, /) -> str:
120
- """Convert text into snake case."""
121
- return _kebab_snake_case(text, "_")
122
-
123
-
124
- ##
125
-
126
-
127
- LIST_SEPARATOR = _DEFAULT_SEPARATOR
128
- PAIR_SEPARATOR = "="
129
- BRACKETS = [("(", ")"), ("[", "]"), ("{", "}")]
130
-
131
-
132
89
  @overload
133
90
  def split_key_value_pairs(
134
91
  text: str,
135
92
  /,
136
93
  *,
137
- list_separator: str = _DEFAULT_SEPARATOR,
94
+ list_separator: str = LIST_SEPARATOR,
138
95
  pair_separator: str = PAIR_SEPARATOR,
139
96
  brackets: Iterable[tuple[str, str]] | None = BRACKETS,
140
97
  mapping: Literal[True],
@@ -144,7 +101,7 @@ def split_key_value_pairs(
144
101
  text: str,
145
102
  /,
146
103
  *,
147
- list_separator: str = _DEFAULT_SEPARATOR,
104
+ list_separator: str = LIST_SEPARATOR,
148
105
  pair_separator: str = PAIR_SEPARATOR,
149
106
  brackets: Iterable[tuple[str, str]] | None = BRACKETS,
150
107
  mapping: Literal[False] = False,
@@ -154,7 +111,7 @@ def split_key_value_pairs(
154
111
  text: str,
155
112
  /,
156
113
  *,
157
- list_separator: str = _DEFAULT_SEPARATOR,
114
+ list_separator: str = LIST_SEPARATOR,
158
115
  pair_separator: str = PAIR_SEPARATOR,
159
116
  brackets: Iterable[tuple[str, str]] | None = BRACKETS,
160
117
  mapping: bool = False,
@@ -163,7 +120,7 @@ def split_key_value_pairs(
163
120
  text: str,
164
121
  /,
165
122
  *,
166
- list_separator: str = _DEFAULT_SEPARATOR,
123
+ list_separator: str = LIST_SEPARATOR,
167
124
  pair_separator: str = PAIR_SEPARATOR,
168
125
  brackets: Iterable[tuple[str, str]] | None = BRACKETS,
169
126
  mapping: bool = False,
@@ -211,7 +168,7 @@ class _SplitKeyValuePairsDuplicateKeysError(SplitKeyValuePairsError):
211
168
 
212
169
  @override
213
170
  def __str__(self) -> str:
214
- return f"Unable to split {self.text!r} into a mapping since there are duplicate keys; got {get_repr(self.counts)}"
171
+ return f"Unable to split {self.text!r} into a mapping since there are duplicate keys; got {repr_(self.counts)}"
215
172
 
216
173
 
217
174
  ##
@@ -222,7 +179,7 @@ def split_str(
222
179
  text: str,
223
180
  /,
224
181
  *,
225
- separator: str = _DEFAULT_SEPARATOR,
182
+ separator: str = LIST_SEPARATOR,
226
183
  brackets: Iterable[tuple[str, str]] | None = None,
227
184
  n: Literal[1],
228
185
  ) -> tuple[str]: ...
@@ -231,7 +188,7 @@ def split_str(
231
188
  text: str,
232
189
  /,
233
190
  *,
234
- separator: str = _DEFAULT_SEPARATOR,
191
+ separator: str = LIST_SEPARATOR,
235
192
  brackets: Iterable[tuple[str, str]] | None = None,
236
193
  n: Literal[2],
237
194
  ) -> tuple[str, str]: ...
@@ -240,7 +197,7 @@ def split_str(
240
197
  text: str,
241
198
  /,
242
199
  *,
243
- separator: str = _DEFAULT_SEPARATOR,
200
+ separator: str = LIST_SEPARATOR,
244
201
  brackets: Iterable[tuple[str, str]] | None = None,
245
202
  n: Literal[3],
246
203
  ) -> tuple[str, str, str]: ...
@@ -249,7 +206,7 @@ def split_str(
249
206
  text: str,
250
207
  /,
251
208
  *,
252
- separator: str = _DEFAULT_SEPARATOR,
209
+ separator: str = LIST_SEPARATOR,
253
210
  brackets: Iterable[tuple[str, str]] | None = None,
254
211
  n: Literal[4],
255
212
  ) -> tuple[str, str, str, str]: ...
@@ -258,7 +215,7 @@ def split_str(
258
215
  text: str,
259
216
  /,
260
217
  *,
261
- separator: str = _DEFAULT_SEPARATOR,
218
+ separator: str = LIST_SEPARATOR,
262
219
  brackets: Iterable[tuple[str, str]] | None = None,
263
220
  n: Literal[5],
264
221
  ) -> tuple[str, str, str, str, str]: ...
@@ -267,7 +224,7 @@ def split_str(
267
224
  text: str,
268
225
  /,
269
226
  *,
270
- separator: str = _DEFAULT_SEPARATOR,
227
+ separator: str = LIST_SEPARATOR,
271
228
  brackets: Iterable[tuple[str, str]] | None = None,
272
229
  n: int | None = None,
273
230
  ) -> tuple[str, ...]: ...
@@ -275,7 +232,7 @@ def split_str(
275
232
  text: str,
276
233
  /,
277
234
  *,
278
- separator: str = _DEFAULT_SEPARATOR,
235
+ separator: str = LIST_SEPARATOR,
279
236
  brackets: Iterable[tuple[str, str]] | None = None,
280
237
  n: int | None = None,
281
238
  ) -> tuple[str, ...]:
@@ -300,7 +257,7 @@ def _split_str_brackets(
300
257
  brackets: Iterable[tuple[str, str]],
301
258
  /,
302
259
  *,
303
- separator: str = _DEFAULT_SEPARATOR,
260
+ separator: str = LIST_SEPARATOR,
304
261
  ) -> list[str]:
305
262
  brackets = list(brackets)
306
263
  opens, closes = transpose(brackets)
@@ -391,7 +348,7 @@ class _SplitStrOpeningBracketUnmatchedError(SplitStrError):
391
348
 
392
349
 
393
350
  def join_strs(
394
- texts: Iterable[str], /, *, sort: bool = False, separator: str = _DEFAULT_SEPARATOR
351
+ texts: Iterable[str], /, *, sort: bool = False, separator: str = LIST_SEPARATOR
395
352
  ) -> str:
396
353
  """Join a collection of strings, with a special provision for the empty list."""
397
354
  texts = list(texts)
@@ -404,7 +361,7 @@ def join_strs(
404
361
  return separator.join(texts)
405
362
 
406
363
 
407
- def _escape_separator(*, separator: str = _DEFAULT_SEPARATOR) -> str:
364
+ def _escape_separator(*, separator: str = LIST_SEPARATOR) -> str:
408
365
  return f"\\{separator}"
409
366
 
410
367
 
@@ -445,15 +402,6 @@ def str_encode(obj: Any, /) -> bytes:
445
402
  ##
446
403
 
447
404
 
448
- def strip_and_dedent(text: str, /, *, trailing: bool = False) -> str:
449
- """Strip and dedent a string."""
450
- result = dedent(text.strip("\n")).strip("\n")
451
- return f"{result}\n" if trailing else result
452
-
453
-
454
- ##
455
-
456
-
457
405
  @overload
458
406
  def to_bool(bool_: MaybeCallableBoolLike, /) -> bool: ...
459
407
  @overload
@@ -498,59 +446,20 @@ def to_str(text: MaybeCallableStr | None | Sentinel, /) -> str | None | Sentinel
498
446
  ##
499
447
 
500
448
 
501
- def unique_str() -> str:
502
- """Generate at unique string."""
503
- now = time_ns()
504
- pid = getpid()
505
- ident = get_ident()
506
- key = str(uuid4()).replace("-", "")
507
- return f"{now}_{pid}_{ident}_{key}"
508
-
509
-
510
- ##
511
-
512
-
513
- def _kebab_snake_case(text: str, separator: str, /) -> str:
514
- """Convert text into kebab/snake case."""
515
- leading = bool(search(r"^_", text))
516
- trailing = bool(search(r"_$", text))
517
- parts = _SPLIT_TEXT.findall(text)
518
- parts = (p for p in parts if len(p) >= 1)
519
- parts = chain([""] if leading else [], parts, [""] if trailing else [])
520
- return separator.join(parts).lower()
521
-
522
-
523
- _SPLIT_TEXT = re.compile(
524
- r"""
525
- [A-Z]+(?=[A-Z][a-z0-9]) | # all caps followed by Upper+lower or digit (API in APIResponse2)
526
- [A-Z]?[a-z]+[0-9]* | # normal words with optional trailing digits (Text123)
527
- [A-Z]+[0-9]* | # consecutive caps with optional trailing digits (ID2)
528
- """,
529
- flags=VERBOSE,
530
- )
531
-
532
449
  __all__ = [
533
- "BRACKETS",
534
- "LIST_SEPARATOR",
535
- "PAIR_SEPARATOR",
536
450
  "ParseBoolError",
537
451
  "ParseNoneError",
538
452
  "SplitKeyValuePairsError",
539
453
  "SplitStrError",
540
454
  "join_strs",
541
- "kebab_case",
542
455
  "parse_bool",
543
456
  "parse_none",
544
- "pascal_case",
545
457
  "prompt_bool",
546
- "repr_encode",
547
458
  "secret_str",
548
- "snake_case",
459
+ "split_f_str_equals",
549
460
  "split_key_value_pairs",
550
461
  "split_str",
551
462
  "str_encode",
552
- "strip_and_dedent",
553
463
  "to_bool",
554
464
  "to_str",
555
- "unique_str",
556
465
  ]
utilities/throttle.py ADDED
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from functools import partial, wraps
6
+ from inspect import iscoroutinefunction
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, NoReturn, assert_never, cast, override
9
+
10
+ from whenever import ZonedDateTime
11
+
12
+ from utilities.constants import SECOND
13
+ from utilities.core import get_env, get_now_local, write_text
14
+ from utilities.functions import in_timedelta
15
+ from utilities.pathlib import to_path
16
+ from utilities.types import Duration, MaybeCallablePathLike, MaybeCoro
17
+
18
+ if TYPE_CHECKING:
19
+ from utilities.types import Coro
20
+
21
+
22
+ def throttle[F: Callable[..., MaybeCoro[None]]](
23
+ *,
24
+ on_try: bool = False,
25
+ duration: Duration = SECOND,
26
+ path: MaybeCallablePathLike = Path.cwd,
27
+ raiser: Callable[[], NoReturn] | None = None,
28
+ ) -> Callable[[F], F]:
29
+ """Throttle a function. On success by default, on try otherwise."""
30
+ return cast(
31
+ "Any",
32
+ partial(
33
+ _throttle_inner, on_try=on_try, duration=duration, path=path, raiser=raiser
34
+ ),
35
+ )
36
+
37
+
38
+ def _throttle_inner[F: Callable[..., MaybeCoro[None]]](
39
+ func: F,
40
+ /,
41
+ *,
42
+ on_try: bool = False,
43
+ duration: Duration = SECOND,
44
+ path: MaybeCallablePathLike = Path.cwd,
45
+ raiser: Callable[[], NoReturn] | None = None,
46
+ ) -> F:
47
+ match bool(iscoroutinefunction(func)), on_try:
48
+ case False, False:
49
+
50
+ @wraps(func)
51
+ def throttle_sync_on_pass(*args: Any, **kwargs: Any) -> None:
52
+ path_use = to_path(path)
53
+ if _is_throttle(path=path_use, duration=duration):
54
+ _try_raise(raiser=raiser)
55
+ else:
56
+ cast("Callable[..., None]", func)(*args, **kwargs)
57
+ _write_throttle(path=path_use)
58
+
59
+ return cast("Any", throttle_sync_on_pass)
60
+
61
+ case False, True:
62
+
63
+ @wraps(func)
64
+ def throttle_sync_on_try(*args: Any, **kwargs: Any) -> None:
65
+ path_use = to_path(path)
66
+ if _is_throttle(path=path_use, duration=duration):
67
+ _try_raise(raiser=raiser)
68
+ else:
69
+ _write_throttle(path=path_use)
70
+ cast("Callable[..., None]", func)(*args, **kwargs)
71
+
72
+ return cast("Any", throttle_sync_on_try)
73
+
74
+ case True, False:
75
+
76
+ @wraps(func)
77
+ async def throttle_async_on_pass(*args: Any, **kwargs: Any) -> None:
78
+ path_use = to_path(path)
79
+ if _is_throttle(path=path_use, duration=duration):
80
+ _try_raise(raiser=raiser)
81
+ else:
82
+ await cast("Callable[..., Coro[None]]", func)(*args, **kwargs)
83
+ _write_throttle(path=path_use)
84
+
85
+ return cast("Any", throttle_async_on_pass)
86
+
87
+ case True, True:
88
+
89
+ @wraps(func)
90
+ async def throttle_async_on_try(*args: Any, **kwargs: Any) -> None:
91
+ path_use = to_path(path)
92
+ if _is_throttle(path=path_use, duration=duration):
93
+ _try_raise(raiser=raiser)
94
+ else:
95
+ _write_throttle(path=path_use)
96
+ await cast("Callable[..., Coro[None]]", func)(*args, **kwargs)
97
+
98
+ return cast("Any", throttle_async_on_try)
99
+
100
+ case never:
101
+ assert_never(never)
102
+
103
+
104
+ def _is_throttle(
105
+ *, path: MaybeCallablePathLike = Path.cwd, duration: Duration = SECOND
106
+ ) -> bool:
107
+ if get_env("THROTTLE", nullable=True):
108
+ return False
109
+ path = to_path(path)
110
+ if path.is_file():
111
+ text = path.read_text().rstrip("\n")
112
+ if text == "":
113
+ path.unlink(missing_ok=True)
114
+ return False
115
+ try:
116
+ last = ZonedDateTime.parse_iso(text)
117
+ except ValueError:
118
+ raise _ThrottleParseZonedDateTimeError(path=path, text=text) from None
119
+ threshold = get_now_local() - in_timedelta(duration)
120
+ return threshold <= last
121
+ if not path.exists():
122
+ return False
123
+ raise _ThrottleMarkerFileError(path=path)
124
+
125
+
126
+ def _try_raise(*, raiser: Callable[[], NoReturn] | None = None) -> None:
127
+ if raiser is not None:
128
+ raiser()
129
+
130
+
131
+ def _write_throttle(*, path: MaybeCallablePathLike = Path.cwd) -> None:
132
+ path_use = to_path(path)
133
+ write_text(path_use, get_now_local().format_iso(), overwrite=True)
134
+
135
+
136
+ @dataclass(kw_only=True, slots=True)
137
+ class ThrottleError(Exception): ...
138
+
139
+
140
+ @dataclass(kw_only=True, slots=True)
141
+ class _ThrottleParseZonedDateTimeError(ThrottleError):
142
+ path: Path
143
+ text: str
144
+
145
+ @override
146
+ def __str__(self) -> str:
147
+ return f"Unable to parse the contents {self.text!r} of {str(self.path)!r} to a ZonedDateTime"
148
+
149
+
150
+ @dataclass(kw_only=True, slots=True)
151
+ class _ThrottleMarkerFileError(ThrottleError):
152
+ path: Path
153
+
154
+ @override
155
+ def __str__(self) -> str:
156
+ return f"Invalid marker file {str(self.path)!r}"
157
+
158
+
159
+ __all__ = ["ThrottleError", "throttle"]
utilities/time.py ADDED
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import TYPE_CHECKING
5
+
6
+ from utilities.functions import in_seconds
7
+
8
+ if TYPE_CHECKING:
9
+ from utilities.types import Duration
10
+
11
+
12
+ def sleep(duration: Duration | None = None, /) -> None:
13
+ """Sleep which accepts durations."""
14
+ if duration is not None:
15
+ time.sleep(in_seconds(duration))
16
+
17
+
18
+ __all__ = ["sleep"]
utilities/timer.py CHANGED
@@ -3,12 +3,15 @@ from __future__ import annotations
3
3
  from operator import add, eq, ge, gt, le, lt, mul, ne, sub, truediv
4
4
  from typing import TYPE_CHECKING, Any, Self, override
5
5
 
6
- from utilities.whenever import get_now_local
6
+ from whenever import TimeDelta
7
+
8
+ from utilities.core import get_now_local
9
+ from utilities.functions import in_timedelta
7
10
 
8
11
  if TYPE_CHECKING:
9
12
  from collections.abc import Callable
10
13
 
11
- from whenever import TimeDelta, ZonedDateTime
14
+ from whenever import ZonedDateTime
12
15
 
13
16
 
14
17
  class Timer:
@@ -66,23 +69,23 @@ class Timer:
66
69
 
67
70
  @override
68
71
  def __eq__(self, other: object) -> bool:
69
- return self._apply_op(eq, other)
72
+ return self._apply_op(eq, other, cast=True, type_error=False)
70
73
 
71
74
  def __ge__(self, other: Any) -> bool:
72
- return self._apply_op(ge, other)
75
+ return self._apply_op(ge, other, cast=True)
73
76
 
74
77
  def __gt__(self, other: Any) -> bool:
75
- return self._apply_op(gt, other)
78
+ return self._apply_op(gt, other, cast=True)
76
79
 
77
80
  def __le__(self, other: Any) -> bool:
78
- return self._apply_op(le, other)
81
+ return self._apply_op(le, other, cast=True)
79
82
 
80
83
  def __lt__(self, other: Any) -> bool:
81
- return self._apply_op(lt, other)
84
+ return self._apply_op(lt, other, cast=True)
82
85
 
83
86
  @override
84
87
  def __ne__(self, other: object) -> bool:
85
- return self._apply_op(ne, other)
88
+ return self._apply_op(ne, other, cast=True, type_error=True)
86
89
 
87
90
  # properties
88
91
 
@@ -94,10 +97,24 @@ class Timer:
94
97
 
95
98
  # private
96
99
 
97
- def _apply_op(self, op: Callable[[Any, Any], Any], other: Any, /) -> Any:
98
- if isinstance(other, Timer):
99
- return op(self.timedelta, other.timedelta)
100
- return op(self.timedelta, other)
100
+ def _apply_op(
101
+ self,
102
+ op: Callable[[Any, Any], Any],
103
+ other: Any,
104
+ /,
105
+ *,
106
+ cast: bool = False,
107
+ type_error: bool | None = None,
108
+ ) -> Any:
109
+ other_use = other.timedelta if isinstance(other, Timer) else other
110
+ if cast:
111
+ if isinstance(other_use, float | int | TimeDelta):
112
+ other_use = in_timedelta(other_use)
113
+ elif type_error is not None:
114
+ return type_error
115
+ else:
116
+ raise TypeError # pragma: no cover
117
+ return op(self.timedelta, other_use)
101
118
 
102
119
 
103
120
  __all__ = ["Timer"]