python-utils 2.5.6__py3-none-any.whl → 4.0.0__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.
- python_utils/__about__.py +35 -7
- python_utils/__init__.py +241 -0
- python_utils/_aliases.py +53 -0
- python_utils/aio.py +133 -0
- python_utils/containers.py +637 -0
- python_utils/converters.py +265 -85
- python_utils/decorators.py +216 -6
- python_utils/exceptions.py +47 -0
- python_utils/formatters.py +72 -16
- python_utils/generators.py +126 -0
- python_utils/import_.py +64 -26
- python_utils/logger.py +352 -29
- python_utils/loguru.py +53 -0
- python_utils/terminal.py +127 -67
- python_utils/time.py +371 -18
- python_utils/types.py +179 -0
- python_utils-4.0.0.dist-info/METADATA +389 -0
- python_utils-4.0.0.dist-info/RECORD +21 -0
- {python_utils-2.5.6.dist-info → python_utils-4.0.0.dist-info}/WHEEL +1 -3
- python_utils-2.5.6.dist-info/METADATA +0 -122
- python_utils-2.5.6.dist-info/RECORD +0 -15
- python_utils-2.5.6.dist-info/top_level.txt +0 -1
- /python_utils/{compat.py → py.typed} +0 -0
- {python_utils-2.5.6.dist-info → python_utils-4.0.0.dist-info/licenses}/LICENSE +0 -0
python_utils/decorators.py
CHANGED
|
@@ -1,7 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides various utility decorators for Python functions
|
|
3
|
+
and methods.
|
|
1
4
|
|
|
5
|
+
The decorators include:
|
|
2
6
|
|
|
3
|
-
|
|
4
|
-
|
|
7
|
+
1. `set_attributes`: Sets attributes on functions and classes.
|
|
8
|
+
2. `listify`: Converts any generator to a list or other collection.
|
|
9
|
+
3. `sample`: Limits calls to a function based on a sample rate.
|
|
10
|
+
4. `wraps_classmethod`: Wraps classmethods with type info from a
|
|
11
|
+
regular method.
|
|
12
|
+
|
|
13
|
+
Each decorator is designed to enhance the functionality of Python
|
|
14
|
+
functions and methods in a simple and reusable manner.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import collections.abc
|
|
18
|
+
import contextlib
|
|
19
|
+
import functools
|
|
20
|
+
import logging
|
|
21
|
+
import random
|
|
22
|
+
import typing
|
|
23
|
+
|
|
24
|
+
_T = typing.TypeVar('_T')
|
|
25
|
+
_P = typing.ParamSpec('_P')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_attributes(
|
|
29
|
+
**kwargs: typing.Any,
|
|
30
|
+
) -> collections.abc.Callable[..., typing.Any]:
|
|
31
|
+
"""Decorator to set attributes on functions and classes.
|
|
5
32
|
|
|
6
33
|
A common usage for this pattern is the Django Admin where
|
|
7
34
|
functions can get an optional short_description. To illustrate:
|
|
@@ -13,18 +40,201 @@ def set_attributes(**kwargs):
|
|
|
13
40
|
|
|
14
41
|
>>> @set_attributes(short_description='Name')
|
|
15
42
|
... def upper_case_name(self, obj):
|
|
16
|
-
... return (
|
|
43
|
+
... return ('%s %s' % (obj.first_name, obj.last_name)).upper()
|
|
17
44
|
|
|
18
45
|
The standard Django version:
|
|
19
46
|
|
|
20
47
|
>>> def upper_case_name(obj):
|
|
21
|
-
... return (
|
|
48
|
+
... return ('%s %s' % (obj.first_name, obj.last_name)).upper()
|
|
22
49
|
|
|
23
50
|
>>> upper_case_name.short_description = 'Name'
|
|
24
51
|
|
|
25
|
-
|
|
26
|
-
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def _set_attributes(
|
|
55
|
+
function: collections.abc.Callable[_P, _T],
|
|
56
|
+
) -> collections.abc.Callable[_P, _T]:
|
|
57
|
+
"""Attach the captured ``kwargs`` as attributes on ``function``."""
|
|
27
58
|
for key, value in kwargs.items():
|
|
28
59
|
setattr(function, key, value)
|
|
29
60
|
return function
|
|
61
|
+
|
|
30
62
|
return _set_attributes
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def listify(
|
|
66
|
+
collection: collections.abc.Callable[
|
|
67
|
+
[collections.abc.Iterable[_T]], collections.abc.Collection[_T]
|
|
68
|
+
] = list,
|
|
69
|
+
allow_empty: bool = True,
|
|
70
|
+
) -> collections.abc.Callable[
|
|
71
|
+
[collections.abc.Callable[..., collections.abc.Iterable[_T] | None]],
|
|
72
|
+
collections.abc.Callable[..., collections.abc.Collection[_T]],
|
|
73
|
+
]:
|
|
74
|
+
"""
|
|
75
|
+
Convert any generator to a list or other type of collection.
|
|
76
|
+
|
|
77
|
+
>>> @listify()
|
|
78
|
+
... def generator():
|
|
79
|
+
... yield 1
|
|
80
|
+
... yield 2
|
|
81
|
+
... yield 3
|
|
82
|
+
|
|
83
|
+
>>> generator()
|
|
84
|
+
[1, 2, 3]
|
|
85
|
+
|
|
86
|
+
>>> @listify()
|
|
87
|
+
... def empty_generator():
|
|
88
|
+
... pass
|
|
89
|
+
|
|
90
|
+
>>> empty_generator()
|
|
91
|
+
[]
|
|
92
|
+
|
|
93
|
+
>>> @listify(allow_empty=False)
|
|
94
|
+
... def empty_generator_not_allowed():
|
|
95
|
+
... pass
|
|
96
|
+
|
|
97
|
+
>>> empty_generator_not_allowed() # doctest: +ELLIPSIS
|
|
98
|
+
Traceback (most recent call last):
|
|
99
|
+
...
|
|
100
|
+
TypeError: ... `allow_empty` is `False`
|
|
101
|
+
|
|
102
|
+
>>> @listify(collection=set)
|
|
103
|
+
... def set_generator():
|
|
104
|
+
... yield 1
|
|
105
|
+
... yield 1
|
|
106
|
+
... yield 2
|
|
107
|
+
|
|
108
|
+
>>> set_generator()
|
|
109
|
+
{1, 2}
|
|
110
|
+
|
|
111
|
+
>>> @listify(collection=dict)
|
|
112
|
+
... def dict_generator():
|
|
113
|
+
... yield 'a', 1
|
|
114
|
+
... yield 'b', 2
|
|
115
|
+
|
|
116
|
+
>>> dict_generator()
|
|
117
|
+
{'a': 1, 'b': 2}
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def _listify(
|
|
121
|
+
function: collections.abc.Callable[
|
|
122
|
+
..., collections.abc.Iterable[_T] | None
|
|
123
|
+
],
|
|
124
|
+
) -> collections.abc.Callable[..., collections.abc.Collection[_T]]:
|
|
125
|
+
"""Materialize ``function``'s result into ``collection``."""
|
|
126
|
+
|
|
127
|
+
def __listify(
|
|
128
|
+
*args: typing.Any, **kwargs: typing.Any
|
|
129
|
+
) -> collections.abc.Collection[_T]:
|
|
130
|
+
"""Call ``function`` and gather its result into ``collection``."""
|
|
131
|
+
result: collections.abc.Iterable[_T] | None = function(
|
|
132
|
+
*args, **kwargs
|
|
133
|
+
)
|
|
134
|
+
if result is None:
|
|
135
|
+
if allow_empty:
|
|
136
|
+
return collection(iter(()))
|
|
137
|
+
else:
|
|
138
|
+
raise TypeError(
|
|
139
|
+
f'{function} returned `None` and `allow_empty` '
|
|
140
|
+
'is `False`'
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
return collection(result)
|
|
144
|
+
|
|
145
|
+
return __listify
|
|
146
|
+
|
|
147
|
+
return _listify
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def sample(
|
|
151
|
+
sample_rate: float,
|
|
152
|
+
) -> collections.abc.Callable[
|
|
153
|
+
[collections.abc.Callable[_P, _T]],
|
|
154
|
+
collections.abc.Callable[_P, _T | None],
|
|
155
|
+
]:
|
|
156
|
+
"""
|
|
157
|
+
Limit calls to a function based on given sample rate.
|
|
158
|
+
Number of calls to the function will be roughly equal to
|
|
159
|
+
sample_rate percentage.
|
|
160
|
+
|
|
161
|
+
Usage:
|
|
162
|
+
|
|
163
|
+
>>> @sample(0.5)
|
|
164
|
+
... def demo_function(*args, **kwargs):
|
|
165
|
+
... return 1
|
|
166
|
+
|
|
167
|
+
Calls to *demo_function* will be limited to 50% approximately.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def _sample(
|
|
171
|
+
function: collections.abc.Callable[_P, _T],
|
|
172
|
+
) -> collections.abc.Callable[_P, _T | None]:
|
|
173
|
+
"""Wrap ``function`` so it only runs on a sampled fraction of calls."""
|
|
174
|
+
|
|
175
|
+
@functools.wraps(function)
|
|
176
|
+
def __sample(*args: _P.args, **kwargs: _P.kwargs) -> _T | None:
|
|
177
|
+
"""Run ``function`` with probability ``sample_rate``, else skip."""
|
|
178
|
+
if random.random() < sample_rate:
|
|
179
|
+
return function(*args, **kwargs)
|
|
180
|
+
else:
|
|
181
|
+
logging.debug(
|
|
182
|
+
'Skipped execution of %r(%r, %r) due to sampling',
|
|
183
|
+
function,
|
|
184
|
+
args,
|
|
185
|
+
kwargs,
|
|
186
|
+
)
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
return __sample
|
|
190
|
+
|
|
191
|
+
return _sample
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def wraps_classmethod(
|
|
195
|
+
wrapped: collections.abc.Callable[typing.Concatenate[typing.Any, _P], _T],
|
|
196
|
+
) -> collections.abc.Callable[
|
|
197
|
+
[
|
|
198
|
+
collections.abc.Callable[typing.Concatenate[typing.Any, _P], _T],
|
|
199
|
+
],
|
|
200
|
+
collections.abc.Callable[typing.Concatenate[typing.Any, _P], _T],
|
|
201
|
+
]:
|
|
202
|
+
"""Like ``functools.wraps``, but for wrapping classmethods.
|
|
203
|
+
|
|
204
|
+
Copies the wrapped method's metadata (name, docstring and annotations) onto
|
|
205
|
+
the wrapper, so a classmethod wrapper carries the type information of the
|
|
206
|
+
regular method it stands in for.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
wrapped: The method whose metadata should be copied onto the wrapper.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
A decorator that updates its wrapper with ``wrapped``'s metadata.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
def _wraps_classmethod(
|
|
216
|
+
wrapper: collections.abc.Callable[
|
|
217
|
+
typing.Concatenate[typing.Any, _P], _T
|
|
218
|
+
],
|
|
219
|
+
) -> collections.abc.Callable[typing.Concatenate[typing.Any, _P], _T]:
|
|
220
|
+
"""Copy ``wrapped``'s metadata onto ``wrapper`` and return it."""
|
|
221
|
+
# For some reason `functools.update_wrapper` fails on some test
|
|
222
|
+
# runs but not while running actual code
|
|
223
|
+
with contextlib.suppress(AttributeError):
|
|
224
|
+
wrapper = functools.update_wrapper(
|
|
225
|
+
wrapper,
|
|
226
|
+
wrapped,
|
|
227
|
+
assigned=tuple(
|
|
228
|
+
a
|
|
229
|
+
for a in functools.WRAPPER_ASSIGNMENTS
|
|
230
|
+
if a != '__annotations__'
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
if annotations := getattr(wrapped, '__annotations__', {}):
|
|
234
|
+
# Drop `self`: the wrapper is a classmethod, so it takes no `self`.
|
|
235
|
+
annotations.pop('self', None)
|
|
236
|
+
wrapper.__annotations__ = annotations
|
|
237
|
+
|
|
238
|
+
return wrapper
|
|
239
|
+
|
|
240
|
+
return _wraps_classmethod
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides utility functions for raising and reraising exceptions.
|
|
3
|
+
|
|
4
|
+
Functions::
|
|
5
|
+
|
|
6
|
+
raise_exception(exception_class, *args, **kwargs):
|
|
7
|
+
Returns a function that raises an exception of the given type with
|
|
8
|
+
the given arguments.
|
|
9
|
+
|
|
10
|
+
reraise(*args, **kwargs):
|
|
11
|
+
Reraises the current exception.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import collections.abc
|
|
15
|
+
import typing
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def raise_exception(
|
|
19
|
+
exception_class: type[Exception],
|
|
20
|
+
*args: typing.Any,
|
|
21
|
+
**kwargs: typing.Any,
|
|
22
|
+
) -> collections.abc.Callable[..., None]:
|
|
23
|
+
"""
|
|
24
|
+
Returns a function that raises an exception of the given type with the
|
|
25
|
+
given arguments.
|
|
26
|
+
|
|
27
|
+
>>> raise_exception(ValueError, 'spam')('eggs')
|
|
28
|
+
Traceback (most recent call last):
|
|
29
|
+
...
|
|
30
|
+
ValueError: spam
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def raise_(*args_: typing.Any, **kwargs_: typing.Any) -> typing.Any:
|
|
34
|
+
"""Raise ``exception_class`` with the captured args."""
|
|
35
|
+
raise exception_class(*args, **kwargs)
|
|
36
|
+
|
|
37
|
+
return raise_
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def reraise(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
|
41
|
+
"""
|
|
42
|
+
Reraises the current exception.
|
|
43
|
+
|
|
44
|
+
This function seems useless, but it can be useful when you need to pass
|
|
45
|
+
a callable to another function that raises an exception.
|
|
46
|
+
"""
|
|
47
|
+
raise
|
python_utils/formatters.py
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides utility functions for formatting strings and dates.
|
|
3
|
+
|
|
4
|
+
Functions::
|
|
5
|
+
|
|
6
|
+
camel_to_underscore: Convert camel case naming to underscore/snake case.
|
|
7
|
+
apply_recursive: Apply a function to all keys in a scope recursively.
|
|
8
|
+
timesince: Return a string representing 'time since', e.g. 3 days ago.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# pyright: reportUnnecessaryIsInstance=false
|
|
12
|
+
import collections.abc
|
|
1
13
|
import datetime
|
|
14
|
+
import typing
|
|
2
15
|
|
|
16
|
+
from python_utils import _aliases
|
|
3
17
|
|
|
4
|
-
|
|
5
|
-
|
|
18
|
+
|
|
19
|
+
def camel_to_underscore(name: str) -> str:
|
|
20
|
+
"""Convert camel case style naming to underscore/snake case style naming.
|
|
6
21
|
|
|
7
22
|
If there are existing underscores they will be collapsed with the
|
|
8
23
|
to-be-added underscores. Multiple consecutive capital letters will not be
|
|
@@ -18,8 +33,8 @@ def camel_to_underscore(name):
|
|
|
18
33
|
'__spam_and_bacon__'
|
|
19
34
|
>>> camel_to_underscore('__SpamANDBacon__')
|
|
20
35
|
'__spam_and_bacon__'
|
|
21
|
-
|
|
22
|
-
output = []
|
|
36
|
+
"""
|
|
37
|
+
output: list[str] = []
|
|
23
38
|
for i, c in enumerate(name):
|
|
24
39
|
if i > 0:
|
|
25
40
|
pc = name[i - 1]
|
|
@@ -30,7 +45,7 @@ def camel_to_underscore(name):
|
|
|
30
45
|
elif i > 3 and not c.isupper():
|
|
31
46
|
# Will return the last 3 letters to check if we are changing
|
|
32
47
|
# case
|
|
33
|
-
previous = name[i - 3:i]
|
|
48
|
+
previous = name[i - 3 : i]
|
|
34
49
|
if previous.isalpha() and previous.isupper():
|
|
35
50
|
output.insert(len(output) - 1, '_')
|
|
36
51
|
|
|
@@ -39,8 +54,50 @@ def camel_to_underscore(name):
|
|
|
39
54
|
return ''.join(output)
|
|
40
55
|
|
|
41
56
|
|
|
42
|
-
def
|
|
43
|
-
|
|
57
|
+
def apply_recursive(
|
|
58
|
+
function: collections.abc.Callable[[str], str],
|
|
59
|
+
data: _aliases.OptionalScope = None,
|
|
60
|
+
**kwargs: typing.Any,
|
|
61
|
+
) -> _aliases.OptionalScope:
|
|
62
|
+
"""
|
|
63
|
+
Apply a function to all keys in a scope recursively.
|
|
64
|
+
|
|
65
|
+
>>> apply_recursive(camel_to_underscore, {'SpamEggsAndBacon': 'spam'})
|
|
66
|
+
{'spam_eggs_and_bacon': 'spam'}
|
|
67
|
+
>>> apply_recursive(
|
|
68
|
+
... camel_to_underscore,
|
|
69
|
+
... {
|
|
70
|
+
... 'SpamEggsAndBacon': {
|
|
71
|
+
... 'SpamEggsAndBacon': 'spam',
|
|
72
|
+
... }
|
|
73
|
+
... },
|
|
74
|
+
... )
|
|
75
|
+
{'spam_eggs_and_bacon': {'spam_eggs_and_bacon': 'spam'}}
|
|
76
|
+
|
|
77
|
+
>>> a = {'a_b_c': 123, 'def': {'DeF': 456}}
|
|
78
|
+
>>> b = apply_recursive(camel_to_underscore, a)
|
|
79
|
+
>>> b
|
|
80
|
+
{'a_b_c': 123, 'def': {'de_f': 456}}
|
|
81
|
+
|
|
82
|
+
>>> apply_recursive(camel_to_underscore, None)
|
|
83
|
+
"""
|
|
84
|
+
if data is None:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
elif isinstance(data, dict):
|
|
88
|
+
return {
|
|
89
|
+
function(key): apply_recursive(function, value, **kwargs)
|
|
90
|
+
for key, value in data.items()
|
|
91
|
+
}
|
|
92
|
+
else:
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def timesince(
|
|
97
|
+
dt: datetime.datetime | datetime.timedelta,
|
|
98
|
+
default: str = 'just now',
|
|
99
|
+
) -> str:
|
|
100
|
+
"""
|
|
44
101
|
Returns string representing 'time since' e.g.
|
|
45
102
|
3 days ago, 5 hours ago etc.
|
|
46
103
|
|
|
@@ -81,7 +138,7 @@ def timesince(dt, default='just now'):
|
|
|
81
138
|
'1 hour and 2 minutes ago'
|
|
82
139
|
>>> timesince(datetime.timedelta(seconds=3721))
|
|
83
140
|
'1 hour and 2 minutes ago'
|
|
84
|
-
|
|
141
|
+
"""
|
|
85
142
|
if isinstance(dt, datetime.timedelta):
|
|
86
143
|
diff = dt
|
|
87
144
|
else:
|
|
@@ -98,16 +155,15 @@ def timesince(dt, default='just now'):
|
|
|
98
155
|
(diff.seconds % 60, 'second', 'seconds'),
|
|
99
156
|
)
|
|
100
157
|
|
|
101
|
-
output = []
|
|
158
|
+
output: list[str] = []
|
|
102
159
|
for period, singular, plural in periods:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
160
|
+
int_period = int(period)
|
|
161
|
+
if int_period == 1:
|
|
162
|
+
output.append(f'{int_period} {singular}')
|
|
163
|
+
elif int_period:
|
|
164
|
+
output.append(f'{int_period} {plural}')
|
|
108
165
|
|
|
109
166
|
if output:
|
|
110
|
-
return '
|
|
167
|
+
return f'{" and ".join(output[:2])} ago'
|
|
111
168
|
|
|
112
169
|
return default
|
|
113
|
-
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides generator utilities for batching items from
|
|
3
|
+
iterables and async iterables.
|
|
4
|
+
|
|
5
|
+
Functions:
|
|
6
|
+
abatcher(generator, batch_size=None, interval=None):
|
|
7
|
+
Asyncio generator wrapper that returns items with a given batch
|
|
8
|
+
size or interval (whichever is reached first).
|
|
9
|
+
|
|
10
|
+
batcher(iterable, batch_size=10):
|
|
11
|
+
Generator wrapper that returns items with a given batch size.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import collections.abc
|
|
16
|
+
import time
|
|
17
|
+
import typing
|
|
18
|
+
|
|
19
|
+
import python_utils
|
|
20
|
+
from python_utils import _aliases
|
|
21
|
+
|
|
22
|
+
#: Element type of the iterables being batched.
|
|
23
|
+
_T = typing.TypeVar('_T')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def abatcher(
|
|
27
|
+
generator: collections.abc.AsyncGenerator[_T, None]
|
|
28
|
+
| collections.abc.AsyncIterator[_T],
|
|
29
|
+
batch_size: int | None = None,
|
|
30
|
+
interval: _aliases.delta_type | None = None,
|
|
31
|
+
) -> collections.abc.AsyncGenerator[list[_T], None]:
|
|
32
|
+
"""
|
|
33
|
+
Asyncio generator wrapper that returns items with a given batch size or
|
|
34
|
+
interval (whichever is reached first).
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
generator: The async generator or iterator to batch.
|
|
38
|
+
batch_size (typing.Optional[int], optional): The number of items per
|
|
39
|
+
batch. Defaults to None.
|
|
40
|
+
interval (typing.Optional[_aliases.delta_type], optional): The time
|
|
41
|
+
interval to wait before yielding a batch. Defaults to None.
|
|
42
|
+
|
|
43
|
+
Yields:
|
|
44
|
+
collections.abc.AsyncGenerator[list[_T], None]: A generator that yields
|
|
45
|
+
batches of items.
|
|
46
|
+
"""
|
|
47
|
+
batch: list[_T] = []
|
|
48
|
+
|
|
49
|
+
assert batch_size or interval, 'Must specify either batch_size or interval'
|
|
50
|
+
|
|
51
|
+
# If interval is specified, use it to determine when to yield the batch
|
|
52
|
+
# Alternatively set a really long timeout to keep the code simpler
|
|
53
|
+
if interval:
|
|
54
|
+
interval_s = python_utils.delta_to_seconds(interval)
|
|
55
|
+
else:
|
|
56
|
+
# Set the timeout to 10 years
|
|
57
|
+
interval_s = 60 * 60 * 24 * 365 * 10.0
|
|
58
|
+
|
|
59
|
+
next_yield: float = time.perf_counter() + interval_s
|
|
60
|
+
|
|
61
|
+
done: set[asyncio.Task[_T]]
|
|
62
|
+
pending: set[asyncio.Task[_T]] = set()
|
|
63
|
+
|
|
64
|
+
while True:
|
|
65
|
+
try:
|
|
66
|
+
done, pending = await asyncio.wait(
|
|
67
|
+
pending
|
|
68
|
+
or [
|
|
69
|
+
asyncio.create_task(
|
|
70
|
+
typing.cast(
|
|
71
|
+
collections.abc.Coroutine[None, None, _T],
|
|
72
|
+
generator.__anext__(),
|
|
73
|
+
)
|
|
74
|
+
),
|
|
75
|
+
],
|
|
76
|
+
timeout=interval_s,
|
|
77
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if done:
|
|
81
|
+
batch.extend(result.result() for result in done)
|
|
82
|
+
|
|
83
|
+
except StopAsyncIteration:
|
|
84
|
+
if batch:
|
|
85
|
+
yield batch
|
|
86
|
+
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
if batch_size is not None and len(batch) == batch_size:
|
|
90
|
+
yield batch
|
|
91
|
+
batch = []
|
|
92
|
+
|
|
93
|
+
if interval and batch and time.perf_counter() > next_yield:
|
|
94
|
+
yield batch
|
|
95
|
+
batch = []
|
|
96
|
+
# Always set the next yield time to the current time. If the
|
|
97
|
+
# loop is running slow due to blocking functions we do not
|
|
98
|
+
# want to burst too much
|
|
99
|
+
next_yield = time.perf_counter() + interval_s
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def batcher(
|
|
103
|
+
iterable: collections.abc.Iterable[_T],
|
|
104
|
+
batch_size: int = 10,
|
|
105
|
+
) -> collections.abc.Generator[list[_T], None, None]:
|
|
106
|
+
"""
|
|
107
|
+
Generator wrapper that returns items with a given batch size.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
iterable (collections.abc.Iterable[_T]): The iterable to batch.
|
|
111
|
+
batch_size (int, optional): The number of items per batch. Defaults
|
|
112
|
+
to 10.
|
|
113
|
+
|
|
114
|
+
Yields:
|
|
115
|
+
collections.abc.Generator[list[_T], None, None]: A generator that
|
|
116
|
+
yields batches of items.
|
|
117
|
+
"""
|
|
118
|
+
batch: list[_T] = []
|
|
119
|
+
for item in iterable:
|
|
120
|
+
batch.append(item)
|
|
121
|
+
if len(batch) == batch_size:
|
|
122
|
+
yield batch
|
|
123
|
+
batch = []
|
|
124
|
+
|
|
125
|
+
if batch:
|
|
126
|
+
yield batch
|
python_utils/import_.py
CHANGED
|
@@ -1,32 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides utilities for importing modules and handling exceptions.
|
|
1
3
|
|
|
2
|
-
|
|
3
|
-
|
|
4
|
+
Classes:
|
|
5
|
+
DummyError(Exception):
|
|
6
|
+
A custom exception class used as a default for exception handling.
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
name, modules=None, exceptions=DummyException, locals_=None,
|
|
8
|
+
Functions:
|
|
9
|
+
import_global(name, modules=None, exceptions=DummyError, locals_=None,
|
|
8
10
|
globals_=None, level=-1):
|
|
9
|
-
|
|
11
|
+
Imports the requested items into the global scope, with support for
|
|
12
|
+
relative imports and custom exception handling.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import typing
|
|
16
|
+
|
|
17
|
+
from python_utils import _aliases
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DummyError(Exception):
|
|
21
|
+
"""A custom exception class used as a default for exception handling."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
#: Backwards-compatible legacy alias for ``DummyError``.
|
|
25
|
+
DummyException = DummyError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def import_global( # noqa: C901
|
|
29
|
+
name: str,
|
|
30
|
+
modules: list[str] | None = None,
|
|
31
|
+
exceptions: _aliases.ExceptionsType = DummyError,
|
|
32
|
+
locals_: _aliases.OptionalScope = None,
|
|
33
|
+
globals_: _aliases.OptionalScope = None,
|
|
34
|
+
level: int = -1,
|
|
35
|
+
) -> typing.Any: # sourcery skip: hoist-if-from-if
|
|
36
|
+
"""Import the requested items into the global scope.
|
|
10
37
|
|
|
11
38
|
WARNING! this method _will_ overwrite your global scope
|
|
12
|
-
If you have a variable named
|
|
13
|
-
it will be overwritten with sys.path
|
|
39
|
+
If you have a variable named `path` and you call `import_global('sys')`
|
|
40
|
+
it will be overwritten with `sys.path`
|
|
14
41
|
|
|
15
42
|
Args:
|
|
16
43
|
name (str): the name of the module to import, e.g. sys
|
|
17
44
|
modules (str): the modules to import, use None for everything
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
45
|
+
exceptions (Exception): the exception to catch, e.g. ImportError
|
|
46
|
+
locals_: the `locals()` method (in case you need a different scope)
|
|
47
|
+
globals_: the `globals()` method (in case you need a different scope)
|
|
21
48
|
level (int): the level to import from, this can be used for
|
|
22
49
|
relative imports
|
|
23
|
-
|
|
50
|
+
"""
|
|
24
51
|
frame = None
|
|
52
|
+
name_parts: list[str] = name.split('.')
|
|
53
|
+
modules_set: set[str] = set()
|
|
25
54
|
try:
|
|
26
55
|
# If locals_ or globals_ are not given, autodetect them by inspecting
|
|
27
56
|
# the current stack
|
|
28
57
|
if locals_ is None or globals_ is None:
|
|
29
58
|
import inspect
|
|
59
|
+
|
|
30
60
|
frame = inspect.stack()[1][0]
|
|
31
61
|
|
|
32
62
|
if locals_ is None:
|
|
@@ -36,44 +66,52 @@ def import_global(
|
|
|
36
66
|
globals_ = frame.f_globals
|
|
37
67
|
|
|
38
68
|
try:
|
|
39
|
-
name = name.split('.')
|
|
40
|
-
|
|
41
69
|
# Relative imports are supported (from .spam import eggs)
|
|
42
|
-
if not
|
|
43
|
-
|
|
70
|
+
if not name_parts[0]:
|
|
71
|
+
name_parts = name_parts[1:]
|
|
44
72
|
level = 1
|
|
45
73
|
|
|
46
74
|
# raise IOError((name, level))
|
|
47
75
|
module = __import__(
|
|
48
|
-
name=
|
|
76
|
+
name=name_parts[0] or '.',
|
|
49
77
|
globals=globals_,
|
|
50
78
|
locals=locals_,
|
|
51
|
-
fromlist=
|
|
79
|
+
fromlist=name_parts[1:],
|
|
52
80
|
level=max(level, 0),
|
|
53
81
|
)
|
|
54
82
|
|
|
55
83
|
# Make sure we get the right part of a dotted import (i.e.
|
|
56
84
|
# spam.eggs should return eggs, not spam)
|
|
57
85
|
try:
|
|
58
|
-
for attr in
|
|
86
|
+
for attr in name_parts[1:]:
|
|
59
87
|
module = getattr(module, attr)
|
|
60
|
-
except AttributeError:
|
|
61
|
-
raise ImportError(
|
|
88
|
+
except AttributeError as e:
|
|
89
|
+
raise ImportError(
|
|
90
|
+
'No module named ' + '.'.join(name_parts)
|
|
91
|
+
) from e
|
|
62
92
|
|
|
63
93
|
# If no list of modules is given, autodetect from either __all__
|
|
64
94
|
# or a dir() of the module
|
|
65
95
|
if not modules:
|
|
66
|
-
|
|
96
|
+
modules_set = set(getattr(module, '__all__', dir(module)))
|
|
67
97
|
else:
|
|
68
|
-
|
|
98
|
+
modules_set = set(modules).intersection(dir(module))
|
|
69
99
|
|
|
70
100
|
# Add all items in modules to the global scope
|
|
71
|
-
for k in set(dir(module)).intersection(
|
|
101
|
+
for k in set(dir(module)).intersection(modules_set):
|
|
72
102
|
if k and k[0] != '_':
|
|
73
103
|
globals_[k] = getattr(module, k)
|
|
74
104
|
except exceptions as e:
|
|
75
105
|
return e
|
|
76
106
|
finally:
|
|
77
107
|
# Clean up, just to be sure
|
|
78
|
-
del
|
|
79
|
-
|
|
108
|
+
del ( # pyrefly: ignore[unsupported-delete]
|
|
109
|
+
name,
|
|
110
|
+
name_parts,
|
|
111
|
+
modules,
|
|
112
|
+
modules_set,
|
|
113
|
+
exceptions,
|
|
114
|
+
locals_,
|
|
115
|
+
globals_,
|
|
116
|
+
frame,
|
|
117
|
+
)
|