python-utils 4.0.1__py3-none-any.whl → 4.1.1__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/aio.py +5 -2
- python_utils/containers.py +537 -41
- python_utils/converters.py +70 -15
- python_utils/decorators.py +29 -3
- python_utils/formatters.py +34 -12
- python_utils/generators.py +82 -34
- python_utils/import_.py +49 -8
- python_utils/logger.py +89 -30
- python_utils/loguru.py +34 -1
- python_utils/time.py +55 -26
- {python_utils-4.0.1.dist-info → python_utils-4.1.1.dist-info}/METADATA +22 -9
- python_utils-4.1.1.dist-info/RECORD +21 -0
- {python_utils-4.0.1.dist-info → python_utils-4.1.1.dist-info}/WHEEL +1 -1
- python_utils-4.0.1.dist-info/RECORD +0 -21
- {python_utils-4.0.1.dist-info → python_utils-4.1.1.dist-info}/licenses/LICENSE +0 -0
python_utils/converters.py
CHANGED
|
@@ -50,7 +50,13 @@ def to_int(
|
|
|
50
50
|
When a (regexp) object (has a search method) is given, that will be used.
|
|
51
51
|
WHen a string is given, re.compile will be run over it first
|
|
52
52
|
|
|
53
|
-
The last group of the regexp will be used as value
|
|
53
|
+
The last group of the regexp will be used as value. A regexp without a
|
|
54
|
+
group uses the whole match.
|
|
55
|
+
|
|
56
|
+
With ``regexp=True`` only the first run of digits is used. A sign, a
|
|
57
|
+
decimal point and an exponent are not part of it, so the result can differ
|
|
58
|
+
from the same call without a regexp. A regexp can only search a ``str``.
|
|
59
|
+
Any other input gives the default.
|
|
54
60
|
|
|
55
61
|
>>> to_int('abc')
|
|
56
62
|
0
|
|
@@ -94,6 +100,14 @@ def to_int(
|
|
|
94
100
|
1234
|
|
95
101
|
>>> to_int('abc', default=1)
|
|
96
102
|
1
|
|
103
|
+
>>> to_int('abc123', regexp=r'\d+')
|
|
104
|
+
123
|
|
105
|
+
>>> to_int('-5'), to_int('-5', regexp=True)
|
|
106
|
+
(-5, 5)
|
|
107
|
+
>>> to_int('1e3'), to_int('1e3', regexp=True)
|
|
108
|
+
(0, 1)
|
|
109
|
+
>>> to_int(123), to_int(123, regexp=True)
|
|
110
|
+
(123, 0)
|
|
97
111
|
>>> to_int('abc', regexp=123)
|
|
98
112
|
Traceback (most recent call last):
|
|
99
113
|
...
|
|
@@ -110,7 +124,10 @@ def to_int(
|
|
|
110
124
|
|
|
111
125
|
try:
|
|
112
126
|
if regexp and input_ and (match := regexp.search(input_)):
|
|
113
|
-
|
|
127
|
+
# A pattern without a capture group has no last group, so the
|
|
128
|
+
# whole match is the value.
|
|
129
|
+
groups: tuple[str | None, ...] = match.groups()
|
|
130
|
+
input_ = groups[-1] if groups else match.group(0)
|
|
114
131
|
|
|
115
132
|
if input_ is None:
|
|
116
133
|
return default
|
|
@@ -127,10 +144,12 @@ def to_float(
|
|
|
127
144
|
regexp: _RegexpType = None,
|
|
128
145
|
) -> _aliases.Number:
|
|
129
146
|
r"""
|
|
130
|
-
Convert the given `input_` to
|
|
147
|
+
Convert the given `input_` to a float or return default.
|
|
131
148
|
|
|
132
149
|
When trying to convert the exceptions given in the exception parameter
|
|
133
|
-
are automatically caught and the default will be returned.
|
|
150
|
+
are automatically caught and the default will be returned. The default is
|
|
151
|
+
the int ``0``, so a failed conversion returns an ``int`` unless a float
|
|
152
|
+
is passed as default.
|
|
134
153
|
|
|
135
154
|
The regexp parameter allows for a regular expression to find the digits
|
|
136
155
|
in a string.
|
|
@@ -138,8 +157,31 @@ def to_float(
|
|
|
138
157
|
When a (regexp) object (has a search method) is given, that will be used.
|
|
139
158
|
When a string is given, re.compile will be run over it first
|
|
140
159
|
|
|
141
|
-
The
|
|
160
|
+
The first group of the regexp will be used as value. A regexp without a
|
|
161
|
+
group uses the whole match.
|
|
142
162
|
|
|
163
|
+
With ``regexp=True`` only the first run of digits is used, with the
|
|
164
|
+
decimal part that follows it. A sign, a leading decimal point and an
|
|
165
|
+
exponent are not part of it, so the result can differ from the same call
|
|
166
|
+
without a regexp. A regexp can only search a ``str``. Any other input
|
|
167
|
+
gives the default.
|
|
168
|
+
|
|
169
|
+
>>> to_float('abc')
|
|
170
|
+
0
|
|
171
|
+
>>> to_float('abc', default=0.0)
|
|
172
|
+
0.0
|
|
173
|
+
>>> to_float('abc1.5', regexp=r'\d+\.\d+')
|
|
174
|
+
1.5
|
|
175
|
+
>>> to_float('a1b2', regexp=r'(\d)\D(\d)')
|
|
176
|
+
1.0
|
|
177
|
+
>>> to_float('-1.5'), to_float('-1.5', regexp=True)
|
|
178
|
+
(-1.5, 1.5)
|
|
179
|
+
>>> to_float('.5'), to_float('.5', regexp=True)
|
|
180
|
+
(0.5, 5.0)
|
|
181
|
+
>>> to_float('1e3'), to_float('1e3', regexp=True)
|
|
182
|
+
(1000.0, 1.0)
|
|
183
|
+
>>> to_float(1.5), to_float(1.5, regexp=True)
|
|
184
|
+
(1.5, 0)
|
|
143
185
|
>>> '%.2f' % to_float('abc')
|
|
144
186
|
'0.00'
|
|
145
187
|
>>> '%.2f' % to_float('1')
|
|
@@ -188,7 +230,9 @@ def to_float(
|
|
|
188
230
|
|
|
189
231
|
try:
|
|
190
232
|
if regexp and (match := regexp.search(input_)):
|
|
191
|
-
|
|
233
|
+
# A pattern without a capture group has no first group, so the
|
|
234
|
+
# whole match is the value.
|
|
235
|
+
input_ = match.group(1) if match.groups() else match.group(0)
|
|
192
236
|
return float(input_)
|
|
193
237
|
except exception:
|
|
194
238
|
return default
|
|
@@ -231,7 +275,7 @@ def to_str(
|
|
|
231
275
|
) -> bytes:
|
|
232
276
|
"""Convert objects to string, encodes to the given encoding.
|
|
233
277
|
|
|
234
|
-
:rtype:
|
|
278
|
+
:rtype: bytes
|
|
235
279
|
|
|
236
280
|
>>> to_str('a')
|
|
237
281
|
b'a'
|
|
@@ -242,9 +286,9 @@ def to_str(
|
|
|
242
286
|
>>> class Foo(object):
|
|
243
287
|
... __str__ = lambda s: 'a'
|
|
244
288
|
>>> to_str(Foo())
|
|
245
|
-
'a'
|
|
289
|
+
b'a'
|
|
246
290
|
>>> to_str(Foo)
|
|
247
|
-
"<class 'python_utils.converters.Foo'>"
|
|
291
|
+
b"<class 'python_utils.converters.Foo'>"
|
|
248
292
|
"""
|
|
249
293
|
if not isinstance(input_, bytes):
|
|
250
294
|
if not hasattr(input_, 'encode'):
|
|
@@ -278,7 +322,9 @@ def scale_1024(
|
|
|
278
322
|
if x <= 0:
|
|
279
323
|
power = 0
|
|
280
324
|
else:
|
|
281
|
-
|
|
325
|
+
# Never below zero: a number under 1 has a negative logarithm, and a
|
|
326
|
+
# negative power would index the prefixes from the wrong end.
|
|
327
|
+
power = max(min(int(math.log(x, 2) / 10), n_prefixes - 1), 0)
|
|
282
328
|
scaled = float(x) / (2 ** (10 * power))
|
|
283
329
|
return scaled, power
|
|
284
330
|
|
|
@@ -386,11 +432,20 @@ def remap( # pyright: ignore[reportInconsistentOverload]
|
|
|
386
432
|
|
|
387
433
|
If floating point remaps need to be done my suggestion is to pass at least
|
|
388
434
|
one parameter as a `decimal.Decimal`. This will ensure that the output
|
|
389
|
-
from this function is accurate
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
435
|
+
from this function is accurate, as long as every value with a fraction is
|
|
436
|
+
a `decimal.Decimal` itself. A `float` is converted exactly, with its
|
|
437
|
+
binary rounding error included:
|
|
438
|
+
|
|
439
|
+
>>> remap(0.1, 0, 1, 0, decimal.Decimal(10))
|
|
440
|
+
Decimal('1.000000000000000055511151231')
|
|
441
|
+
>>> remap(decimal.Decimal('0.1'), 0, 1, 0, 10)
|
|
442
|
+
Decimal('1.0')
|
|
443
|
+
|
|
444
|
+
I left passing `floats` for backwards compatibility and there is no
|
|
445
|
+
conversion done from float to `decimal.Decimal` unless one of the passed
|
|
446
|
+
parameters has a type of `decimal.Decimal`. This will ensure that any
|
|
447
|
+
existing code that uses this function will work exactly how it has in the
|
|
448
|
+
past.
|
|
394
449
|
|
|
395
450
|
Some edge cases to test
|
|
396
451
|
>>> remap(1, 0, 0, 1, 2)
|
python_utils/decorators.py
CHANGED
|
@@ -24,6 +24,11 @@ import typing
|
|
|
24
24
|
_T = typing.TypeVar('_T')
|
|
25
25
|
_P = typing.ParamSpec('_P')
|
|
26
26
|
|
|
27
|
+
#: Logger of this module. The module-level `logging.debug()` writes to the
|
|
28
|
+
#: root logger and installs a handler on it when it has none, which is the
|
|
29
|
+
#: application's decision to make.
|
|
30
|
+
_logger: logging.Logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
27
32
|
|
|
28
33
|
def set_attributes(
|
|
29
34
|
**kwargs: typing.Any,
|
|
@@ -74,6 +79,11 @@ def listify(
|
|
|
74
79
|
"""
|
|
75
80
|
Convert any generator to a list or other type of collection.
|
|
76
81
|
|
|
82
|
+
A function that returns `None` counts as empty. With `allow_empty` that
|
|
83
|
+
gives an empty collection, and with `allow_empty=False` it raises a
|
|
84
|
+
`TypeError`. The `None` is all that `allow_empty` looks at: a generator
|
|
85
|
+
that yields nothing gives an empty collection either way.
|
|
86
|
+
|
|
77
87
|
>>> @listify()
|
|
78
88
|
... def generator():
|
|
79
89
|
... yield 1
|
|
@@ -99,6 +109,13 @@ def listify(
|
|
|
99
109
|
...
|
|
100
110
|
TypeError: ... `allow_empty` is `False`
|
|
101
111
|
|
|
112
|
+
>>> @listify(allow_empty=False)
|
|
113
|
+
... def generator_without_items():
|
|
114
|
+
... yield from ()
|
|
115
|
+
|
|
116
|
+
>>> generator_without_items()
|
|
117
|
+
[]
|
|
118
|
+
|
|
102
119
|
>>> @listify(collection=set)
|
|
103
120
|
... def set_generator():
|
|
104
121
|
... yield 1
|
|
@@ -124,6 +141,7 @@ def listify(
|
|
|
124
141
|
) -> collections.abc.Callable[..., collections.abc.Collection[_T]]:
|
|
125
142
|
"""Materialize ``function``'s result into ``collection``."""
|
|
126
143
|
|
|
144
|
+
@functools.wraps(function)
|
|
127
145
|
def __listify(
|
|
128
146
|
*args: typing.Any, **kwargs: typing.Any
|
|
129
147
|
) -> collections.abc.Collection[_T]:
|
|
@@ -178,7 +196,7 @@ def sample(
|
|
|
178
196
|
if random.random() < sample_rate:
|
|
179
197
|
return function(*args, **kwargs)
|
|
180
198
|
else:
|
|
181
|
-
|
|
199
|
+
_logger.debug(
|
|
182
200
|
'Skipped execution of %r(%r, %r) due to sampling',
|
|
183
201
|
function,
|
|
184
202
|
args,
|
|
@@ -224,13 +242,21 @@ def wraps_classmethod(
|
|
|
224
242
|
wrapper = functools.update_wrapper(
|
|
225
243
|
wrapper,
|
|
226
244
|
wrapped,
|
|
245
|
+
# The annotations are handled below. Python 3.14 lists
|
|
246
|
+
# `__annotate__` here where older versions list
|
|
247
|
+
# `__annotations__`, so both names are left out.
|
|
227
248
|
assigned=tuple(
|
|
228
249
|
a
|
|
229
250
|
for a in functools.WRAPPER_ASSIGNMENTS
|
|
230
|
-
if a
|
|
251
|
+
if a not in ('__annotations__', '__annotate__')
|
|
231
252
|
),
|
|
232
253
|
)
|
|
233
|
-
|
|
254
|
+
# A copy, because the dictionary belongs to `wrapped` and that one
|
|
255
|
+
# keeps its `self`.
|
|
256
|
+
annotations: dict[str, typing.Any] = dict(
|
|
257
|
+
getattr(wrapped, '__annotations__', {})
|
|
258
|
+
)
|
|
259
|
+
if annotations:
|
|
234
260
|
# Drop `self`: the wrapper is a classmethod, so it takes no `self`.
|
|
235
261
|
annotations.pop('self', None)
|
|
236
262
|
wrapper.__annotations__ = annotations
|
python_utils/formatters.py
CHANGED
|
@@ -21,7 +21,8 @@ def camel_to_underscore(name: str) -> str:
|
|
|
21
21
|
|
|
22
22
|
If there are existing underscores they will be collapsed with the
|
|
23
23
|
to-be-added underscores. Multiple consecutive capital letters will not be
|
|
24
|
-
split except for the last one.
|
|
24
|
+
split except for the last one. A plural ``s`` that ends a word stays with
|
|
25
|
+
the acronym before it.
|
|
25
26
|
|
|
26
27
|
>>> camel_to_underscore('SpamEggsAndBacon')
|
|
27
28
|
'spam_eggs_and_bacon'
|
|
@@ -42,11 +43,16 @@ def camel_to_underscore(name: str) -> str:
|
|
|
42
43
|
# Uppercase and the previous character isn't upper/underscore?
|
|
43
44
|
# Add the underscore
|
|
44
45
|
output.append('_')
|
|
45
|
-
elif i >
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
elif i > 1 and c.islower():
|
|
47
|
+
# Two preceding capitals are enough to mark the boundary:
|
|
48
|
+
# the final capital belongs to the following word. Only a
|
|
49
|
+
# lowercase letter ends an acronym; an underscore or digit
|
|
50
|
+
# after it leaves the acronym whole.
|
|
51
|
+
previous = name[i - 2 : i]
|
|
52
|
+
# A lone ``s`` that ends the word makes the acronym plural
|
|
53
|
+
# (``IDs``, ``URLs``) and does not start a new word.
|
|
54
|
+
plural: bool = c == 's' and not name[i + 1 : i + 2].islower()
|
|
55
|
+
if previous.isalpha() and previous.isupper() and not plural:
|
|
50
56
|
output.insert(len(output) - 1, '_')
|
|
51
57
|
|
|
52
58
|
output.append(c.lower())
|
|
@@ -140,16 +146,32 @@ def timesince(
|
|
|
140
146
|
'1 hour and 2 minutes ago'
|
|
141
147
|
"""
|
|
142
148
|
if isinstance(dt, datetime.timedelta):
|
|
143
|
-
|
|
149
|
+
# A negative timedelta has negative days and positive seconds, so
|
|
150
|
+
# only its size can be described.
|
|
151
|
+
diff = abs(dt)
|
|
144
152
|
else:
|
|
145
|
-
|
|
153
|
+
# An aware datetime can only be compared with an aware current time.
|
|
154
|
+
# For a naive datetime `tzinfo` is `None`, which gives local time.
|
|
155
|
+
# A date has no `tzinfo`, and no time to subtract either. It gets
|
|
156
|
+
# the same `TypeError` from the subtraction as it always did.
|
|
157
|
+
now = datetime.datetime.now(getattr(dt, 'tzinfo', None))
|
|
146
158
|
diff = abs(now - dt)
|
|
147
159
|
|
|
160
|
+
# Every unit takes its share from what the larger units left over, so a
|
|
161
|
+
# day is never counted twice.
|
|
162
|
+
years: int
|
|
163
|
+
months: int
|
|
164
|
+
weeks: int
|
|
165
|
+
days: int
|
|
166
|
+
years, days = divmod(diff.days, 365)
|
|
167
|
+
months, days = divmod(days, 30)
|
|
168
|
+
weeks, days = divmod(days, 7)
|
|
169
|
+
|
|
148
170
|
periods = (
|
|
149
|
-
(
|
|
150
|
-
(
|
|
151
|
-
(
|
|
152
|
-
(
|
|
171
|
+
(years, 'year', 'years'),
|
|
172
|
+
(months, 'month', 'months'),
|
|
173
|
+
(weeks, 'week', 'weeks'),
|
|
174
|
+
(days, 'day', 'days'),
|
|
153
175
|
(diff.seconds / 3600, 'hour', 'hours'),
|
|
154
176
|
(diff.seconds % 3600 / 60, 'minute', 'minutes'),
|
|
155
177
|
(diff.seconds % 60, 'second', 'seconds'),
|
python_utils/generators.py
CHANGED
|
@@ -23,6 +23,41 @@ from python_utils import _aliases
|
|
|
23
23
|
_T = typing.TypeVar('_T')
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
async def _cancel(pending: set[asyncio.Future[_T]]) -> None:
|
|
27
|
+
"""Cancel the items that are still on their way and wait for them.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
pending: The unfinished futures to cancel. An empty set is fine.
|
|
31
|
+
"""
|
|
32
|
+
if not pending:
|
|
33
|
+
# Nothing is on its way. This also covers a batcher that is closed
|
|
34
|
+
# by the garbage collector after its event loop is gone.
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
for future in pending:
|
|
38
|
+
future.cancel()
|
|
39
|
+
|
|
40
|
+
# Waiting lets the cancellation reach the generator. A cancelled item is
|
|
41
|
+
# of no use to anyone. An error that the generator raises while it is
|
|
42
|
+
# being cancelled is reported to the event loop, because raising it here
|
|
43
|
+
# would replace the cancellation of the consumer.
|
|
44
|
+
outcomes: list[_T | BaseException] = await asyncio.gather(
|
|
45
|
+
*pending, return_exceptions=True
|
|
46
|
+
)
|
|
47
|
+
for outcome in outcomes:
|
|
48
|
+
# A generator that returns when it is cancelled ends its item with
|
|
49
|
+
# `StopAsyncIteration`, which is no failure.
|
|
50
|
+
if isinstance(outcome, Exception) and not isinstance(
|
|
51
|
+
outcome, StopAsyncIteration
|
|
52
|
+
):
|
|
53
|
+
asyncio.get_running_loop().call_exception_handler(
|
|
54
|
+
{
|
|
55
|
+
'message': 'abatcher source failed while being cancelled',
|
|
56
|
+
'exception': outcome,
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
26
61
|
async def abatcher(
|
|
27
62
|
generator: collections.abc.AsyncGenerator[_T, None]
|
|
28
63
|
| collections.abc.AsyncIterator[_T],
|
|
@@ -33,6 +68,10 @@ async def abatcher(
|
|
|
33
68
|
Asyncio generator wrapper that returns items with a given batch size or
|
|
34
69
|
interval (whichever is reached first).
|
|
35
70
|
|
|
71
|
+
Stopping early while the next item is still on its way cancels the
|
|
72
|
+
request for it. A source that is an async generator ends at that point.
|
|
73
|
+
A source with nothing on its way stays usable.
|
|
74
|
+
|
|
36
75
|
Args:
|
|
37
76
|
generator: The async generator or iterator to batch.
|
|
38
77
|
batch_size (typing.Optional[int], optional): The number of items per
|
|
@@ -58,45 +97,54 @@ async def abatcher(
|
|
|
58
97
|
|
|
59
98
|
next_yield: float = time.perf_counter() + interval_s
|
|
60
99
|
|
|
61
|
-
done: set[asyncio.
|
|
62
|
-
pending: set[asyncio.
|
|
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
|
-
)
|
|
100
|
+
done: set[asyncio.Future[_T]]
|
|
101
|
+
pending: set[asyncio.Future[_T]] = set()
|
|
79
102
|
|
|
80
|
-
|
|
81
|
-
|
|
103
|
+
try:
|
|
104
|
+
while True:
|
|
105
|
+
try:
|
|
106
|
+
if not pending:
|
|
107
|
+
# Store the task before waiting for it. A cancellation
|
|
108
|
+
# during the wait still finds it in the `finally` below.
|
|
109
|
+
# `ensure_future` takes every awaitable, where
|
|
110
|
+
# `create_task` insists on a coroutine.
|
|
111
|
+
pending = {asyncio.ensure_future(generator.__anext__())}
|
|
82
112
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
113
|
+
done, pending = await asyncio.wait(
|
|
114
|
+
pending,
|
|
115
|
+
timeout=interval_s,
|
|
116
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
117
|
+
)
|
|
86
118
|
|
|
87
|
-
|
|
119
|
+
if done:
|
|
120
|
+
batch.extend(result.result() for result in done)
|
|
88
121
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
122
|
+
except StopAsyncIteration:
|
|
123
|
+
if batch:
|
|
124
|
+
yield batch
|
|
92
125
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
batch
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
126
|
+
break
|
|
127
|
+
|
|
128
|
+
if batch_size is not None and len(batch) == batch_size:
|
|
129
|
+
yield batch
|
|
130
|
+
batch = []
|
|
131
|
+
# A full batch starts a new interval as well. Otherwise the
|
|
132
|
+
# next item is flushed on its own once the old one ran out.
|
|
133
|
+
next_yield = time.perf_counter() + interval_s
|
|
134
|
+
|
|
135
|
+
if interval and batch and time.perf_counter() > next_yield:
|
|
136
|
+
yield batch
|
|
137
|
+
batch = []
|
|
138
|
+
# Always set the next yield time to the current time. If the
|
|
139
|
+
# loop is running slow due to blocking functions we do not
|
|
140
|
+
# want to burst too much
|
|
141
|
+
next_yield = time.perf_counter() + interval_s
|
|
142
|
+
finally:
|
|
143
|
+
# The consumer can stop while the next item is still on its way: an
|
|
144
|
+
# early `break`, `aclose()` or a cancellation. Without this the task
|
|
145
|
+
# keeps running, takes the next item from the generator and nobody
|
|
146
|
+
# ever receives it.
|
|
147
|
+
await _cancel(pending)
|
|
100
148
|
|
|
101
149
|
|
|
102
150
|
def batcher(
|
python_utils/import_.py
CHANGED
|
@@ -12,6 +12,8 @@ Functions:
|
|
|
12
12
|
relative imports and custom exception handling.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
+
import importlib
|
|
16
|
+
import types
|
|
15
17
|
import typing
|
|
16
18
|
|
|
17
19
|
from python_utils import _aliases
|
|
@@ -25,6 +27,46 @@ class DummyError(Exception):
|
|
|
25
27
|
DummyException = DummyError
|
|
26
28
|
|
|
27
29
|
|
|
30
|
+
def _get_attribute(module: typing.Any, attr: str, name: str) -> typing.Any:
|
|
31
|
+
"""Return ``module.attr``, importing it as a submodule when needed.
|
|
32
|
+
|
|
33
|
+
A submodule only becomes an attribute of its parent once something has
|
|
34
|
+
imported it.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
module: The module, or other object, to take the attribute from.
|
|
38
|
+
attr: The name of the attribute or submodule.
|
|
39
|
+
name: The full dotted name that is being imported, for the error.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
The attribute or the imported submodule.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ImportError: When ``module`` has no such attribute or submodule. An
|
|
46
|
+
error that the submodule raises while it is imported is passed
|
|
47
|
+
on as it is, a missing dependency included.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
return getattr(module, attr)
|
|
51
|
+
except AttributeError as error:
|
|
52
|
+
if not isinstance(module, types.ModuleType):
|
|
53
|
+
# The same error as for a missing module, as it always was.
|
|
54
|
+
raise ImportError( # noqa: TRY004
|
|
55
|
+
f'No module named {name}'
|
|
56
|
+
) from error
|
|
57
|
+
|
|
58
|
+
submodule: str = f'{module.__name__}.{attr}'
|
|
59
|
+
try:
|
|
60
|
+
return importlib.import_module(submodule)
|
|
61
|
+
except ModuleNotFoundError as error:
|
|
62
|
+
missing: str = error.name or ''
|
|
63
|
+
if missing != submodule and not submodule.startswith(f'{missing}.'):
|
|
64
|
+
# The submodule exists and one of its own imports is missing.
|
|
65
|
+
raise
|
|
66
|
+
|
|
67
|
+
raise ImportError(f'No module named {name}') from error
|
|
68
|
+
|
|
69
|
+
|
|
28
70
|
def import_global( # noqa: C901
|
|
29
71
|
name: str,
|
|
30
72
|
modules: list[str] | None = None,
|
|
@@ -41,7 +83,11 @@ def import_global( # noqa: C901
|
|
|
41
83
|
|
|
42
84
|
Args:
|
|
43
85
|
name (str): the name of the module to import, e.g. sys
|
|
44
|
-
modules (str): the
|
|
86
|
+
modules (list[str]): the names to import from the module, use None
|
|
87
|
+
for everything. An empty list also imports everything. A single
|
|
88
|
+
name needs a list as well, a bare string is read as a collection
|
|
89
|
+
of one-character names. Names that start with an underscore are
|
|
90
|
+
never imported.
|
|
45
91
|
exceptions (Exception): the exception to catch, e.g. ImportError
|
|
46
92
|
locals_: the `locals()` method (in case you need a different scope)
|
|
47
93
|
globals_: the `globals()` method (in case you need a different scope)
|
|
@@ -82,13 +128,8 @@ def import_global( # noqa: C901
|
|
|
82
128
|
|
|
83
129
|
# Make sure we get the right part of a dotted import (i.e.
|
|
84
130
|
# spam.eggs should return eggs, not spam)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
module = getattr(module, attr)
|
|
88
|
-
except AttributeError as e:
|
|
89
|
-
raise ImportError(
|
|
90
|
-
'No module named ' + '.'.join(name_parts)
|
|
91
|
-
) from e
|
|
131
|
+
for attr in name_parts[1:]:
|
|
132
|
+
module = _get_attribute(module, attr, '.'.join(name_parts))
|
|
92
133
|
|
|
93
134
|
# If no list of modules is given, autodetect from either __all__
|
|
94
135
|
# or a dir() of the module
|