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.
- dycw_utilities-0.185.8.dist-info/METADATA +33 -0
- dycw_utilities-0.185.8.dist-info/RECORD +90 -0
- {dycw_utilities-0.175.17.dist-info → dycw_utilities-0.185.8.dist-info}/WHEEL +2 -2
- utilities/__init__.py +1 -1
- utilities/altair.py +8 -6
- utilities/asyncio.py +40 -56
- utilities/atools.py +9 -11
- utilities/cachetools.py +8 -6
- utilities/click.py +4 -3
- utilities/concurrent.py +1 -1
- utilities/constants.py +492 -0
- utilities/contextlib.py +23 -30
- utilities/contextvars.py +1 -23
- utilities/core.py +2581 -0
- utilities/dataclasses.py +16 -119
- utilities/docker.py +139 -45
- utilities/enum.py +1 -1
- utilities/errors.py +2 -16
- utilities/fastapi.py +5 -5
- utilities/fpdf2.py +2 -1
- utilities/functions.py +33 -264
- utilities/http.py +2 -3
- utilities/hypothesis.py +48 -25
- utilities/iterables.py +39 -575
- utilities/jinja2.py +3 -6
- utilities/jupyter.py +5 -3
- utilities/libcst.py +1 -1
- utilities/lightweight_charts.py +4 -6
- utilities/logging.py +17 -15
- utilities/math.py +1 -36
- utilities/more_itertools.py +4 -6
- utilities/numpy.py +2 -1
- utilities/operator.py +2 -2
- utilities/orjson.py +24 -25
- utilities/os.py +4 -185
- utilities/packaging.py +129 -0
- utilities/parse.py +33 -13
- utilities/pathlib.py +2 -136
- utilities/platform.py +8 -90
- utilities/polars.py +34 -31
- utilities/postgres.py +9 -4
- utilities/pottery.py +20 -18
- utilities/pqdm.py +3 -4
- utilities/psutil.py +2 -3
- utilities/pydantic.py +18 -4
- utilities/pydantic_settings.py +7 -9
- utilities/pydantic_settings_sops.py +3 -3
- utilities/pyinstrument.py +4 -4
- utilities/pytest.py +49 -108
- utilities/pytest_plugins/pytest_regressions.py +2 -2
- utilities/pytest_regressions.py +8 -6
- utilities/random.py +2 -8
- utilities/redis.py +98 -94
- utilities/reprlib.py +11 -118
- utilities/shellingham.py +66 -0
- utilities/slack_sdk.py +13 -12
- utilities/sqlalchemy.py +42 -30
- utilities/sqlalchemy_polars.py +16 -25
- utilities/subprocess.py +1166 -148
- utilities/tabulate.py +32 -0
- utilities/testbook.py +8 -8
- utilities/text.py +24 -115
- utilities/throttle.py +159 -0
- utilities/time.py +18 -0
- utilities/timer.py +29 -12
- utilities/traceback.py +15 -22
- utilities/types.py +38 -3
- utilities/typing.py +18 -12
- utilities/uuid.py +1 -1
- utilities/version.py +202 -45
- utilities/whenever.py +22 -150
- dycw_utilities-0.175.17.dist-info/METADATA +0 -34
- dycw_utilities-0.175.17.dist-info/RECORD +0 -103
- utilities/atomicwrites.py +0 -182
- utilities/cryptography.py +0 -41
- utilities/getpass.py +0 -8
- utilities/git.py +0 -19
- utilities/grp.py +0 -28
- utilities/gzip.py +0 -31
- utilities/json.py +0 -70
- utilities/permissions.py +0 -298
- utilities/pickle.py +0 -25
- utilities/pwd.py +0 -28
- utilities/re.py +0 -156
- utilities/sentinel.py +0 -73
- utilities/socket.py +0 -8
- utilities/string.py +0 -20
- utilities/tempfile.py +0 -136
- utilities/tzdata.py +0 -11
- utilities/tzlocal.py +0 -28
- utilities/warnings.py +0 -65
- utilities/zipfile.py +0 -25
- utilities/zoneinfo.py +0 -133
- {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.
|
|
9
|
-
from utilities.
|
|
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
|
|
14
|
+
from utilities.types import Duration, PathLike
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def build_notebook_tester(
|
|
18
|
-
path: PathLike, /, *, throttle:
|
|
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,
|
|
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, /, *,
|
|
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
|
|
45
|
-
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
|
|
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.
|
|
25
|
-
from utilities.
|
|
26
|
-
from utilities.
|
|
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
|
|
112
|
-
"""
|
|
113
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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"]
|