python-utils 4.1.4__tar.gz → 4.2.0__tar.gz
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-4.1.4 → python_utils-4.2.0}/PKG-INFO +2 -1
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/test_aio.py +22 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/test_containers.py +134 -0
- python_utils-4.2.0/_python_utils_tests/test_converters.py +104 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/test_generators.py +145 -1
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/test_import.py +96 -0
- python_utils-4.2.0/_python_utils_tests/test_types.py +49 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/pyproject.toml +2 -1
- {python_utils-4.1.4 → python_utils-4.2.0}/pyproject.toml.orig +2 -1
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/aio.py +13 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/containers.py +49 -1
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/converters.py +12 -1
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/generators.py +42 -2
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/import_.py +21 -7
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/types.py +8 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/tox.ini +3 -2
- python_utils-4.1.4/_python_utils_tests/test_converters.py +0 -39
- python_utils-4.1.4/_python_utils_tests/test_types.py +0 -15
- {python_utils-4.1.4 → python_utils-4.2.0}/LICENSE +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/README.md +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/__init__.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/clock.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/test_aliases.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/test_decorators.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/test_formatters.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/test_import_footprint.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/test_lazy_imports.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/test_logged.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/test_logger.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/test_python_utils.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/_python_utils_tests/test_time.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/conftest.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/__about__.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/__init__.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/_aliases.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/decorators.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/exceptions.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/formatters.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/logger.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/loguru.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/py.typed +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/terminal.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.0}/python_utils/time.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-utils
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.2.0
|
|
4
4
|
Summary: Python Utils is a module with some convenient utilities not included with the standard Python install
|
|
5
5
|
Keywords: utils,utilities,helpers,typing,typed,async,lazy-imports
|
|
6
6
|
Author: Rick van Hattem
|
|
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
19
20
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
21
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
21
22
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
@@ -114,3 +114,25 @@ async def test_adict() -> None:
|
|
|
114
114
|
assert await aio.adict(async_gen()) == {1: 2, 3: 4, 5: 6}
|
|
115
115
|
assert await aio.adict(empty_gen) == {}
|
|
116
116
|
assert await aio.adict(empty_gen()) == {}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.asyncio
|
|
120
|
+
async def test_acontainer_custom_container_types() -> None:
|
|
121
|
+
"""Accept custom container factories without losing their return type."""
|
|
122
|
+
from collections.abc import AsyncIterator, Iterable
|
|
123
|
+
|
|
124
|
+
from typing_extensions import assert_type
|
|
125
|
+
|
|
126
|
+
async def numbers() -> AsyncIterator[int]:
|
|
127
|
+
yield 1
|
|
128
|
+
yield 2
|
|
129
|
+
|
|
130
|
+
def summarize(items: Iterable[int]) -> dict[str, int]:
|
|
131
|
+
return {'total': sum(items)}
|
|
132
|
+
|
|
133
|
+
frozen = await aio.acontainer(numbers(), frozenset)
|
|
134
|
+
assert_type(frozen, frozenset[int])
|
|
135
|
+
assert frozen == frozenset({1, 2})
|
|
136
|
+
summary = await aio.acontainer(numbers, summarize)
|
|
137
|
+
assert_type(summary, dict[str, int])
|
|
138
|
+
assert summary == {'total': 3}
|
|
@@ -1209,3 +1209,137 @@ def test_unique_list_failed_assignment_takes_membership_back() -> None:
|
|
|
1209
1209
|
assert 1 in values
|
|
1210
1210
|
values.append(3)
|
|
1211
1211
|
assert values == [1, 2, 3]
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
@pytest.mark.parametrize('index', [0, -1])
|
|
1215
|
+
@pytest.mark.parametrize('mode', ['ignore', 'raise'])
|
|
1216
|
+
def test_unique_list_nan_self_assignment(
|
|
1217
|
+
index: int, mode: containers.OnDuplicate
|
|
1218
|
+
) -> None:
|
|
1219
|
+
"""Assigning the same NaN object does not introduce a duplicate."""
|
|
1220
|
+
missing = float('nan')
|
|
1221
|
+
values: containers.UniqueList[float] = containers.UniqueList(
|
|
1222
|
+
missing, on_duplicate=mode
|
|
1223
|
+
)
|
|
1224
|
+
values[index] = missing
|
|
1225
|
+
assert len(values) == 1
|
|
1226
|
+
assert values[index] is missing
|
|
1227
|
+
assert missing in values
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def test_unique_list_nan_duplicate_in_another_slot() -> None:
|
|
1231
|
+
"""The same NaN object remains a duplicate in a different list slot."""
|
|
1232
|
+
missing = float('nan')
|
|
1233
|
+
values: containers.UniqueList[float] = containers.UniqueList(
|
|
1234
|
+
1.0, missing, on_duplicate='raise'
|
|
1235
|
+
)
|
|
1236
|
+
with pytest.raises(ValueError, match='Duplicate value'):
|
|
1237
|
+
values[0] = missing
|
|
1238
|
+
assert values[0] == 1.0
|
|
1239
|
+
assert values[1] is missing
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def test_lazy_casted_dict_get_casts_values_without_caching() -> None:
|
|
1243
|
+
"""Get uses the same key and value casts as indexed reads."""
|
|
1244
|
+
calls: list[str] = []
|
|
1245
|
+
|
|
1246
|
+
def cast(value: str) -> int:
|
|
1247
|
+
"""Record each lazy value conversion."""
|
|
1248
|
+
calls.append(value)
|
|
1249
|
+
return int(value)
|
|
1250
|
+
|
|
1251
|
+
values: containers.LazyCastedDict[int, int] = containers.LazyCastedDict(
|
|
1252
|
+
int, cast
|
|
1253
|
+
)
|
|
1254
|
+
values['1'] = '2'
|
|
1255
|
+
assert values.get('1') == 2
|
|
1256
|
+
assert values.get(1) == 2
|
|
1257
|
+
assert calls == ['2', '2']
|
|
1258
|
+
assert raw_items(values) == {1: '2'}
|
|
1259
|
+
assert values.get('3', 'fallback') == 'fallback'
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def test_lazy_casted_dict_get_defaults_without_value_cast() -> None:
|
|
1263
|
+
"""Return missing defaults unchanged and support disabled casts."""
|
|
1264
|
+
values: containers.LazyCastedDict[int, int] = containers.LazyCastedDict(
|
|
1265
|
+
int, int
|
|
1266
|
+
)
|
|
1267
|
+
assert values.get(1) is None
|
|
1268
|
+
fallback = object()
|
|
1269
|
+
assert values.get(1, fallback) is fallback
|
|
1270
|
+
plain: containers.LazyCastedDict[str, str] = containers.LazyCastedDict()
|
|
1271
|
+
plain['key'] = 'value'
|
|
1272
|
+
assert plain.get('key') == 'value'
|
|
1273
|
+
assert plain.get('missing') is None
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def test_lazy_casted_dict_get_does_not_hide_cast_errors() -> None:
|
|
1277
|
+
"""A KeyError raised by a value cast is not a missing-key result."""
|
|
1278
|
+
|
|
1279
|
+
def cast(value: str) -> int:
|
|
1280
|
+
"""Reject values with a distinguishable conversion error."""
|
|
1281
|
+
raise KeyError(value)
|
|
1282
|
+
|
|
1283
|
+
values: containers.LazyCastedDict[str, int] = containers.LazyCastedDict(
|
|
1284
|
+
None, cast
|
|
1285
|
+
)
|
|
1286
|
+
values['key'] = 'bad value'
|
|
1287
|
+
with pytest.raises(KeyError, match='bad value'):
|
|
1288
|
+
values.get('key', 0)
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def test_lazy_casted_dict_get_casts_key_once() -> None:
|
|
1292
|
+
"""A non-idempotent key cast runs once per get call."""
|
|
1293
|
+
key_cast = CountingCast()
|
|
1294
|
+
values: containers.LazyCastedDict[int, typing.Any] = (
|
|
1295
|
+
containers.LazyCastedDict(key_cast, int)
|
|
1296
|
+
)
|
|
1297
|
+
dict[int, typing.Any].__setitem__(values, 2, '3')
|
|
1298
|
+
assert values.get(1) == 3
|
|
1299
|
+
assert key_cast.calls == 1
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
@pytest.mark.parametrize('key', [None, '', 'spam', '1.5', float('inf')])
|
|
1303
|
+
def test_lazy_casted_dict_get_rejected_key_returns_default(
|
|
1304
|
+
key: typing.Any,
|
|
1305
|
+
) -> None:
|
|
1306
|
+
"""A key the key cast rejects is missing, as it was for ``dict.get``."""
|
|
1307
|
+
values: containers.LazyCastedDict[int, int] = containers.LazyCastedDict(
|
|
1308
|
+
int, int
|
|
1309
|
+
)
|
|
1310
|
+
values[3] = '4'
|
|
1311
|
+
assert values.get(key) is None
|
|
1312
|
+
assert values.get(key, 'missing') == 'missing'
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def test_lazy_casted_dict_get_skips_missing() -> None:
|
|
1316
|
+
"""A miss returns the default without calling ``__missing__``."""
|
|
1317
|
+
|
|
1318
|
+
class Defaulting(containers.LazyCastedDict[int, int]):
|
|
1319
|
+
"""Store a zero for every key read through indexing."""
|
|
1320
|
+
|
|
1321
|
+
def __missing__(self, key: int) -> str:
|
|
1322
|
+
"""Store and return a zero."""
|
|
1323
|
+
self[key] = '0'
|
|
1324
|
+
return '0'
|
|
1325
|
+
|
|
1326
|
+
values = Defaulting(int, int)
|
|
1327
|
+
assert values.get(5, 'missing') == 'missing'
|
|
1328
|
+
assert raw_items(values) == {}
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
class PositionalGet(containers.LazyCastedDict[int, int]):
|
|
1332
|
+
"""Override ``get`` with the positional-only signature of ``dict``."""
|
|
1333
|
+
|
|
1334
|
+
def get(
|
|
1335
|
+
self, key: typing.Any, default: typing.Any = None, /
|
|
1336
|
+
) -> typing.Any:
|
|
1337
|
+
"""Read through the parent implementation."""
|
|
1338
|
+
return super().get(key, default)
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def test_lazy_casted_dict_get_positional_override() -> None:
|
|
1342
|
+
"""A subclass may keep the positional-only ``dict.get`` signature."""
|
|
1343
|
+
values = PositionalGet(int, int)
|
|
1344
|
+
values['1'] = '2'
|
|
1345
|
+
assert values.get(1) == 2
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tests for the conversion helpers in ``python_utils.converters``."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from python_utils import converters
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize('regexp', [r'\d+', re.compile(r'\d+')])
|
|
12
|
+
def test_to_int_regexp_without_group(regexp: re.Pattern[str] | str) -> None:
|
|
13
|
+
"""Use the whole match when the pattern has no capture group."""
|
|
14
|
+
assert converters.to_int('abc123', regexp=regexp) == 123
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.parametrize('regexp', [r'\d+\.\d+', re.compile(r'\d+\.\d+')])
|
|
18
|
+
def test_to_float_regexp_without_group(
|
|
19
|
+
regexp: re.Pattern[str] | str,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Use the whole match when the pattern has no capture group."""
|
|
22
|
+
assert converters.to_float('abc1.5', regexp=regexp) == 1.5
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_regexp_with_groups_keeps_its_group() -> None:
|
|
26
|
+
"""Keep the last group for ``to_int`` and the first for ``to_float``."""
|
|
27
|
+
assert converters.to_int('a1b2', regexp=r'(\d)\D(\d)') == 2
|
|
28
|
+
assert converters.to_float('a1b2', regexp=r'(\d)\D(\d)') == 1.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.parametrize('value', [0.0001, 1e-9, 2**-11])
|
|
32
|
+
def test_scale_1024_small_number(value: float) -> None:
|
|
33
|
+
"""Never scale a number below one up to a negative power."""
|
|
34
|
+
assert converters.scale_1024(value, 9) == (value, 0)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.parametrize('power', range(1, 9))
|
|
38
|
+
def test_scale_1024_integer_prefix_boundaries(power: int) -> None:
|
|
39
|
+
"""Choose prefixes from the integer value before rounding it to float."""
|
|
40
|
+
boundary = 1024**power
|
|
41
|
+
for value, expected_power in (
|
|
42
|
+
(boundary - 1, power - 1),
|
|
43
|
+
(boundary, power),
|
|
44
|
+
(boundary + 1, power),
|
|
45
|
+
):
|
|
46
|
+
assert converters.scale_1024(value, 9) == (
|
|
47
|
+
float(value) / 1024**expected_power,
|
|
48
|
+
expected_power,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.parametrize('power', range(1, 9))
|
|
53
|
+
def test_scale_1024_float_prefix_boundaries(power: int) -> None:
|
|
54
|
+
"""Keep the float immediately below a prefix boundary in the lower unit."""
|
|
55
|
+
boundary = float(1024**power)
|
|
56
|
+
for value, expected_power in (
|
|
57
|
+
(math.nextafter(boundary, 0.0), power - 1),
|
|
58
|
+
(boundary, power),
|
|
59
|
+
(math.nextafter(boundary, math.inf), power),
|
|
60
|
+
):
|
|
61
|
+
assert converters.scale_1024(value, 9) == (
|
|
62
|
+
value / 1024**expected_power,
|
|
63
|
+
expected_power,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_scale_1024_prefix_limit() -> None:
|
|
68
|
+
"""Keep values in the largest available prefix even at higher powers."""
|
|
69
|
+
assert converters.scale_1024(1024**5, 3) == (1024.0**3, 2)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize('n_prefixes', [0, -1])
|
|
73
|
+
def test_scale_1024_without_prefixes(n_prefixes: int) -> None:
|
|
74
|
+
"""Never return a negative power when there are no prefixes."""
|
|
75
|
+
assert converters.scale_1024(2048, n_prefixes) == (2048.0, 0)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.parametrize('default', [0.0, -1.25])
|
|
79
|
+
def test_to_float_float_default(default: float) -> None:
|
|
80
|
+
"""Accept the documented float fallback in typed consumer code."""
|
|
81
|
+
assert converters.to_float('missing', default=default) is default
|
|
82
|
+
assert (
|
|
83
|
+
converters.to_float('missing', default=default, regexp=True) is default
|
|
84
|
+
)
|
|
85
|
+
assert converters.to_float('1.5', default=default) == 1.5
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _LessEqualOnly:
|
|
89
|
+
"""A numeric type with ``__le__`` and ``__float__``, no ``__lt__``."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, value: int) -> None:
|
|
92
|
+
self.value: int = value
|
|
93
|
+
|
|
94
|
+
def __le__(self, other: object) -> bool:
|
|
95
|
+
return self.value <= other # type: ignore[operator]
|
|
96
|
+
|
|
97
|
+
def __float__(self) -> float:
|
|
98
|
+
return float(self.value)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_scale_1024_accepts_number_without_lt() -> None:
|
|
102
|
+
"""Keep the 4.1.4 float path for a type without ``__lt__``."""
|
|
103
|
+
value: _LessEqualOnly = _LessEqualOnly(2048)
|
|
104
|
+
assert converters.scale_1024(value, 9) == (2.0, 1) # type: ignore[arg-type]
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import datetime
|
|
5
5
|
import gc
|
|
6
6
|
import sys
|
|
7
|
+
import warnings
|
|
7
8
|
from types import SimpleNamespace
|
|
8
9
|
|
|
9
10
|
import pytest
|
|
@@ -99,7 +100,7 @@ async def test_abatcher_timed_with_timeout() -> None:
|
|
|
99
100
|
|
|
100
101
|
|
|
101
102
|
@pytest.mark.asyncio
|
|
102
|
-
@pytest.mark.parametrize('batch_size', [None, 3])
|
|
103
|
+
@pytest.mark.parametrize('batch_size', [None, 0, -1, 3])
|
|
103
104
|
@pytest.mark.parametrize('interval', [10, datetime.timedelta(seconds=10)])
|
|
104
105
|
@pytest.mark.parametrize('arrival', [8.0, 10.0])
|
|
105
106
|
async def test_abatcher_partial_batch_keeps_deadline(
|
|
@@ -364,6 +365,149 @@ async def test_abatcher_with_future_returning_iterator() -> None:
|
|
|
364
365
|
assert batches == [[0, 1], [2, 3], [4]]
|
|
365
366
|
|
|
366
367
|
|
|
368
|
+
@pytest.mark.parametrize('batch_size', [0, -1])
|
|
369
|
+
@pytest.mark.parametrize('count', [0, 3])
|
|
370
|
+
def test_batcher_warns_for_nonpositive_size(
|
|
371
|
+
batch_size: int, count: int
|
|
372
|
+
) -> None:
|
|
373
|
+
"""Warn once while keeping the existing result and consumption order."""
|
|
374
|
+
consumed: list[int] = []
|
|
375
|
+
|
|
376
|
+
def source() -> types.Generator[int, None, None]:
|
|
377
|
+
for item in range(count):
|
|
378
|
+
consumed.append(item)
|
|
379
|
+
yield item
|
|
380
|
+
|
|
381
|
+
batcher = python_utils.batcher(source(), batch_size)
|
|
382
|
+
assert consumed == []
|
|
383
|
+
with pytest.warns(DeprecationWarning, match='ValueError.*5.0') as caught:
|
|
384
|
+
result = list(batcher)
|
|
385
|
+
|
|
386
|
+
assert result == ([list(range(count))] if count else [])
|
|
387
|
+
assert consumed == list(range(count))
|
|
388
|
+
assert len(caught) == 1
|
|
389
|
+
assert caught[0].filename == __file__
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@pytest.mark.parametrize('batch_size', [0, -1])
|
|
393
|
+
def test_batcher_warning_as_error_does_not_consume(batch_size: int) -> None:
|
|
394
|
+
"""Let callers opt into failure before taking the first source item."""
|
|
395
|
+
source = iter(range(3))
|
|
396
|
+
batcher = python_utils.batcher(source, batch_size)
|
|
397
|
+
with warnings.catch_warnings():
|
|
398
|
+
warnings.simplefilter('error', DeprecationWarning)
|
|
399
|
+
with pytest.raises(DeprecationWarning, match='batch_size'):
|
|
400
|
+
next(batcher)
|
|
401
|
+
assert list(source) == [0, 1, 2]
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@pytest.mark.asyncio
|
|
405
|
+
@pytest.mark.parametrize('batch_size', [0, -1])
|
|
406
|
+
@pytest.mark.parametrize('count', [0, 3])
|
|
407
|
+
@pytest.mark.parametrize('interval', [None, 0])
|
|
408
|
+
async def test_abatcher_warns_without_interval(
|
|
409
|
+
batch_size: int, count: int, interval: int | None
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Keep the old zero-size error and negative-size result in 4.x."""
|
|
412
|
+
consumed: list[int] = []
|
|
413
|
+
|
|
414
|
+
async def source() -> types.AsyncGenerator[int, None]:
|
|
415
|
+
for item in range(count):
|
|
416
|
+
consumed.append(item)
|
|
417
|
+
yield item
|
|
418
|
+
|
|
419
|
+
batcher = python_utils.abatcher(source(), batch_size, interval)
|
|
420
|
+
assert consumed == []
|
|
421
|
+
with pytest.warns(DeprecationWarning, match='ValueError.*5.0') as caught:
|
|
422
|
+
if batch_size == 0:
|
|
423
|
+
with pytest.raises(AssertionError, match='Must specify either'):
|
|
424
|
+
await batcher.__anext__()
|
|
425
|
+
assert consumed == []
|
|
426
|
+
else:
|
|
427
|
+
result = [batch async for batch in batcher]
|
|
428
|
+
assert result == ([list(range(count))] if count else [])
|
|
429
|
+
assert consumed == list(range(count))
|
|
430
|
+
|
|
431
|
+
assert len(caught) == 1
|
|
432
|
+
assert caught[0].filename == __file__
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@pytest.mark.asyncio
|
|
436
|
+
@pytest.mark.parametrize('batch_size', [0, -1])
|
|
437
|
+
async def test_abatcher_warning_as_error_does_not_consume(
|
|
438
|
+
batch_size: int,
|
|
439
|
+
) -> None:
|
|
440
|
+
"""Warn before scheduling even the first asynchronous source item."""
|
|
441
|
+
source = FutureIterator(3)
|
|
442
|
+
batcher = python_utils.abatcher(source, batch_size)
|
|
443
|
+
with warnings.catch_warnings():
|
|
444
|
+
warnings.simplefilter('error', DeprecationWarning)
|
|
445
|
+
with pytest.raises(DeprecationWarning, match='batch_size'):
|
|
446
|
+
await batcher.__anext__()
|
|
447
|
+
assert source.current == 0
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@pytest.mark.asyncio
|
|
451
|
+
@pytest.mark.parametrize('batch_size', [None, 0, -1])
|
|
452
|
+
@pytest.mark.parametrize('count', [0, 3])
|
|
453
|
+
async def test_abatcher_interval_only_sizes(
|
|
454
|
+
batch_size: int | None, count: int
|
|
455
|
+
) -> None:
|
|
456
|
+
"""None and nonpositive sizes impose no limit and issue no warning."""
|
|
457
|
+
source = FutureIterator(count)
|
|
458
|
+
result = [
|
|
459
|
+
batch async for batch in python_utils.abatcher(source, batch_size, 10)
|
|
460
|
+
]
|
|
461
|
+
assert result == ([list(range(count))] if count else [])
|
|
462
|
+
assert source.current == count
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def test_batcher_positive_size_is_lazy() -> None:
|
|
466
|
+
"""Positive sizes issue no warning and consume only the first batch."""
|
|
467
|
+
source = iter(range(3))
|
|
468
|
+
batcher = python_utils.batcher(source, 1)
|
|
469
|
+
assert next(batcher) == [0]
|
|
470
|
+
assert list(source) == [1, 2]
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@pytest.mark.parametrize(
|
|
474
|
+
'batch_size,expected',
|
|
475
|
+
[('2', [[0, 1, 2, 3, 4]]), (2 + 0j, [[0, 1], [2, 3], [4]])],
|
|
476
|
+
)
|
|
477
|
+
def test_batcher_noncomparable_size(
|
|
478
|
+
batch_size: types.Any, expected: list[list[int]]
|
|
479
|
+
) -> None:
|
|
480
|
+
"""A size that does not compare with 1 keeps its 4.x result."""
|
|
481
|
+
with warnings.catch_warnings():
|
|
482
|
+
warnings.simplefilter('error', DeprecationWarning)
|
|
483
|
+
assert list(python_utils.batcher(range(5), batch_size)) == expected
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
@pytest.mark.asyncio
|
|
487
|
+
async def test_abatcher_noncomparable_size_with_interval() -> None:
|
|
488
|
+
"""A size that does not compare with 1 leaves the interval in charge."""
|
|
489
|
+
size: types.Any = '2'
|
|
490
|
+
source = FutureIterator(5)
|
|
491
|
+
result = [batch async for batch in python_utils.abatcher(source, size, 10)]
|
|
492
|
+
assert result == [[0, 1, 2, 3, 4]]
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@pytest.mark.asyncio
|
|
496
|
+
async def test_abatcher_zero_size_with_interval_yields_empty_batch() -> None:
|
|
497
|
+
"""Keep the 4.x empty batch for an idle interval with a size of 0."""
|
|
498
|
+
|
|
499
|
+
async def generator() -> types.AsyncGenerator[int, None]:
|
|
500
|
+
"""Stay idle forever."""
|
|
501
|
+
await asyncio.Event().wait()
|
|
502
|
+
yield 0 # pragma: no cover
|
|
503
|
+
|
|
504
|
+
batcher = python_utils.abatcher(generator(), batch_size=0, interval=0.01)
|
|
505
|
+
try:
|
|
506
|
+
assert await asyncio.wait_for(batcher.__anext__(), 5) == []
|
|
507
|
+
finally:
|
|
508
|
+
await batcher.aclose()
|
|
509
|
+
|
|
510
|
+
|
|
367
511
|
def test_batcher() -> None:
|
|
368
512
|
"""Split an iterable into fixed-size batches."""
|
|
369
513
|
batch = []
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import collections.abc
|
|
4
4
|
import pathlib
|
|
5
5
|
import sys
|
|
6
|
+
from types import ModuleType
|
|
6
7
|
|
|
7
8
|
import pytest
|
|
8
9
|
|
|
@@ -203,3 +204,98 @@ def test_import_global_missing_below_detached_module(
|
|
|
203
204
|
|
|
204
205
|
assert type(error) is ImportError
|
|
205
206
|
assert str(error) == f'No module named {name}'
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@pytest.mark.parametrize(
|
|
210
|
+
'names', [None, ['virtual', 'none', '_private', 'missing']]
|
|
211
|
+
)
|
|
212
|
+
def test_import_global_lazy_module_exports(
|
|
213
|
+
monkeypatch: pytest.MonkeyPatch, names: list[str] | None
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Read declared and explicit exports through module __getattr__."""
|
|
216
|
+
module_name = 'python_utils_lazy_export_fixture'
|
|
217
|
+
module = ModuleType(module_name)
|
|
218
|
+
calls: dict[str, int] = {}
|
|
219
|
+
|
|
220
|
+
def lookup(name: str) -> types.Any:
|
|
221
|
+
"""Provide public names that are deliberately absent from dir()."""
|
|
222
|
+
calls[name] = calls.get(name, 0) + 1
|
|
223
|
+
if name == 'virtual':
|
|
224
|
+
return 42
|
|
225
|
+
if name == 'none':
|
|
226
|
+
return None
|
|
227
|
+
raise AttributeError(name)
|
|
228
|
+
|
|
229
|
+
module.__dict__['__getattr__'] = lookup
|
|
230
|
+
module.__dict__['__all__'] = ['virtual', 'none', '_private', 'missing']
|
|
231
|
+
monkeypatch.setitem(sys.modules, module_name, module)
|
|
232
|
+
scope: dict[str, types.Any] = {}
|
|
233
|
+
import_.import_global(module_name, names, locals_={}, globals_=scope)
|
|
234
|
+
assert scope == {'virtual': 42, 'none': None}
|
|
235
|
+
assert calls['virtual'] == calls['none'] == 1
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@pytest.mark.parametrize('error', [ImportError, RuntimeError])
|
|
239
|
+
def test_import_global_skips_failing_lazy_exports(
|
|
240
|
+
monkeypatch: pytest.MonkeyPatch, error: type[Exception]
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Skip a lazy export that fails to resolve, as 4.x did."""
|
|
243
|
+
module_name = 'python_utils_failing_lazy_fixture'
|
|
244
|
+
module = ModuleType(module_name)
|
|
245
|
+
|
|
246
|
+
def lookup(name: str) -> types.Any:
|
|
247
|
+
"""Serve one working and one failing lazy export."""
|
|
248
|
+
if name == 'working':
|
|
249
|
+
return 1
|
|
250
|
+
if name == 'broken':
|
|
251
|
+
raise error('optional dependency missing')
|
|
252
|
+
raise AttributeError(name)
|
|
253
|
+
|
|
254
|
+
module.__dict__['__getattr__'] = lookup
|
|
255
|
+
module.__dict__['__all__'] = ['eager', 'working', 'broken']
|
|
256
|
+
module.__dict__['eager'] = 0
|
|
257
|
+
monkeypatch.setitem(sys.modules, module_name, module)
|
|
258
|
+
scope: dict[str, types.Any] = {}
|
|
259
|
+
result = import_.import_global(module_name, locals_={}, globals_=scope)
|
|
260
|
+
assert result is None
|
|
261
|
+
assert scope == {'eager': 0, 'working': 1}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_import_global_ignores_names_that_are_not_strings(
|
|
265
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
266
|
+
) -> None:
|
|
267
|
+
"""Skip entries of ``modules`` and ``__all__`` that are not names."""
|
|
268
|
+
module_name = 'python_utils_odd_all_fixture'
|
|
269
|
+
module = ModuleType(module_name)
|
|
270
|
+
module.__dict__['__all__'] = ['eggs', 1]
|
|
271
|
+
module.__dict__['eggs'] = 1
|
|
272
|
+
monkeypatch.setitem(sys.modules, module_name, module)
|
|
273
|
+
scope: dict[str, types.Any] = {}
|
|
274
|
+
import_.import_global(module_name, locals_={}, globals_=scope)
|
|
275
|
+
assert scope == {'eggs': 1}
|
|
276
|
+
|
|
277
|
+
names: types.Any = ['sep', 1, b'sep']
|
|
278
|
+
scope = {}
|
|
279
|
+
import_.import_global('os', names, locals_={}, globals_=scope)
|
|
280
|
+
assert list(scope) == ['sep']
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_import_global_requested_name_needs_an_export(
|
|
284
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Leave a catch-all ``__getattr__`` alone for undeclared names."""
|
|
287
|
+
module_name = 'python_utils_catch_all_fixture'
|
|
288
|
+
module = ModuleType(module_name)
|
|
289
|
+
calls: list[str] = []
|
|
290
|
+
|
|
291
|
+
def lookup(name: str) -> str:
|
|
292
|
+
"""Answer every name, as a lazy submodule loader would."""
|
|
293
|
+
calls.append(name)
|
|
294
|
+
return f'auto-{name}'
|
|
295
|
+
|
|
296
|
+
module.__dict__['__getattr__'] = lookup
|
|
297
|
+
monkeypatch.setitem(sys.modules, module_name, module)
|
|
298
|
+
scope: dict[str, types.Any] = {}
|
|
299
|
+
import_.import_global(module_name, ['eggs'], locals_={}, globals_=scope)
|
|
300
|
+
assert scope == {}
|
|
301
|
+
assert 'eggs' not in calls
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Regression tests for the public ``python_utils.types`` facade."""
|
|
2
|
+
|
|
3
|
+
import collections
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from python_utils import types
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_types_all_entries_are_unique() -> None:
|
|
10
|
+
"""Export every public name once."""
|
|
11
|
+
counts: collections.Counter[str] = collections.Counter(types.__all__)
|
|
12
|
+
duplicates: list[str] = sorted(
|
|
13
|
+
name for name, count in counts.items() if count > 1
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
assert duplicates == []
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_types_all_entries_exist() -> None:
|
|
20
|
+
"""Resolve every name in ``__all__`` so star imports keep working."""
|
|
21
|
+
missing: list[str] = [
|
|
22
|
+
name for name in types.__all__ if not hasattr(types, name)
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
assert missing == []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_types_star_import() -> None:
|
|
29
|
+
"""Import the public facade with ``from python_utils.types import *``."""
|
|
30
|
+
namespace: dict[str, object] = {}
|
|
31
|
+
exec('from python_utils.types import *', namespace) # noqa: S102
|
|
32
|
+
|
|
33
|
+
assert set(types.__all__) <= namespace.keys()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_types_all_keeps_removed_typing_names_in_place() -> None:
|
|
37
|
+
"""Keep the 4.1.4 order of ``__all__`` where the names still exist."""
|
|
38
|
+
removed_in_315: list[str] = ['ByteString', 'no_type_check_decorator']
|
|
39
|
+
if sys.version_info >= (3, 15):
|
|
40
|
+
assert not set(removed_in_315) & set(types.__all__)
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
# Each name sits in its alphabetical slot, not appended at the end.
|
|
44
|
+
assert types.__all__.index('ByteString') == (
|
|
45
|
+
types.__all__.index('BuiltinMethodType') + 1
|
|
46
|
+
)
|
|
47
|
+
assert types.__all__.index('no_type_check_decorator') == (
|
|
48
|
+
types.__all__.index('no_type_check') + 1
|
|
49
|
+
)
|
|
@@ -125,7 +125,7 @@ source-include = [
|
|
|
125
125
|
|
|
126
126
|
[project]
|
|
127
127
|
name = "python-utils"
|
|
128
|
-
version = "4.
|
|
128
|
+
version = "4.2.0"
|
|
129
129
|
description = "Python Utils is a module with some convenient utilities not included with the standard Python install"
|
|
130
130
|
readme = "README.md"
|
|
131
131
|
requires-python = ">=3.10"
|
|
@@ -150,6 +150,7 @@ classifiers = [
|
|
|
150
150
|
"Programming Language :: Python :: 3.12",
|
|
151
151
|
"Programming Language :: Python :: 3.13",
|
|
152
152
|
"Programming Language :: Python :: 3.14",
|
|
153
|
+
"Programming Language :: Python :: 3.15",
|
|
153
154
|
"Programming Language :: Python :: 3 :: Only",
|
|
154
155
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
155
156
|
"Programming Language :: Python :: Implementation :: PyPy",
|
|
@@ -12,7 +12,7 @@ source-include = ['_python_utils_tests/**/*.py', 'conftest.py', 'tox.ini']
|
|
|
12
12
|
|
|
13
13
|
[project]
|
|
14
14
|
name = 'python-utils'
|
|
15
|
-
version = "4.
|
|
15
|
+
version = "4.2.0" # static; the single source of truth. Bump via `uv version`.
|
|
16
16
|
description = 'Python Utils is a module with some convenient utilities not included with the standard Python install'
|
|
17
17
|
readme = 'README.md'
|
|
18
18
|
requires-python = '>=3.10'
|
|
@@ -38,6 +38,7 @@ classifiers = [
|
|
|
38
38
|
'Programming Language :: Python :: 3.12',
|
|
39
39
|
'Programming Language :: Python :: 3.13',
|
|
40
40
|
'Programming Language :: Python :: 3.14',
|
|
41
|
+
'Programming Language :: Python :: 3.15',
|
|
41
42
|
'Programming Language :: Python :: 3 :: Only',
|
|
42
43
|
'Programming Language :: Python :: Implementation :: CPython',
|
|
43
44
|
'Programming Language :: Python :: Implementation :: PyPy',
|
|
@@ -18,6 +18,8 @@ _T = typing.TypeVar('_T')
|
|
|
18
18
|
_K = typing.TypeVar('_K')
|
|
19
19
|
#: Value type when collecting an async iterable of pairs into a dict.
|
|
20
20
|
_V = typing.TypeVar('_V')
|
|
21
|
+
#: Concrete collection returned by a custom container factory.
|
|
22
|
+
_C = typing.TypeVar('_C', bound=collections.abc.Collection[typing.Any])
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
async def acount(
|
|
@@ -83,6 +85,15 @@ async def acontainer(
|
|
|
83
85
|
"""Overload: collect the async iterable into a ``set``."""
|
|
84
86
|
|
|
85
87
|
|
|
88
|
+
@typing.overload
|
|
89
|
+
async def acontainer(
|
|
90
|
+
iterable: collections.abc.AsyncIterable[_T]
|
|
91
|
+
| collections.abc.Callable[..., collections.abc.AsyncIterable[_T]],
|
|
92
|
+
container: collections.abc.Callable[[collections.abc.Iterable[_T]], _C],
|
|
93
|
+
) -> _C:
|
|
94
|
+
"""Overload: preserve the result type of a custom container factory."""
|
|
95
|
+
|
|
96
|
+
|
|
86
97
|
async def acontainer(
|
|
87
98
|
iterable: collections.abc.AsyncIterable[_T]
|
|
88
99
|
| collections.abc.Callable[..., collections.abc.AsyncIterable[_T]],
|
|
@@ -96,6 +107,8 @@ async def acontainer(
|
|
|
96
107
|
So instead of doing `[item async for item in iterable]` you can do
|
|
97
108
|
`await acontainer(iterable)`.
|
|
98
109
|
|
|
110
|
+
``container`` may be any factory that accepts the collected items, such
|
|
111
|
+
as ``frozenset`` or a custom function. Its concrete return type is kept.
|
|
99
112
|
"""
|
|
100
113
|
iterable_: collections.abc.AsyncIterable[_T]
|
|
101
114
|
if callable(iterable):
|
|
@@ -73,6 +73,9 @@ if typing.TYPE_CHECKING:
|
|
|
73
73
|
KT = typing.TypeVar('KT')
|
|
74
74
|
#: A type alias for a type that can be used as a value in a dictionary.
|
|
75
75
|
VT = typing.TypeVar('VT')
|
|
76
|
+
GD = typing.TypeVar('GD')
|
|
77
|
+
#: Marks a key that ``LazyCastedDict.get`` did not find.
|
|
78
|
+
_MISSING: typing.Final[object] = object()
|
|
76
79
|
#: A type alias for a dictionary with keys of type KT and values of type VT.
|
|
77
80
|
DT = dict[KT, VT]
|
|
78
81
|
#: A type alias for the casted type of a dictionary key.
|
|
@@ -453,6 +456,8 @@ class LazyCastedDict(CastedDictBase[KT, VT]):
|
|
|
453
456
|
[(1, 2), (3, 4), (5, 6), (7, 8)]
|
|
454
457
|
>>> d[3]
|
|
455
458
|
4
|
|
459
|
+
>>> d.get('3')
|
|
460
|
+
4
|
|
456
461
|
|
|
457
462
|
# Casts are optional and can be disabled by passing None as the cast
|
|
458
463
|
>>> d = LazyCastedDict()
|
|
@@ -507,6 +512,40 @@ class LazyCastedDict(CastedDictBase[KT, VT]):
|
|
|
507
512
|
|
|
508
513
|
return value
|
|
509
514
|
|
|
515
|
+
@typing.overload
|
|
516
|
+
def get(self, key: typing.Any, default: None = None, /) -> VT | None:
|
|
517
|
+
"""Return a cast value or ``None`` when the key is absent."""
|
|
518
|
+
|
|
519
|
+
@typing.overload
|
|
520
|
+
def get(self, key: typing.Any, default: VT, /) -> VT:
|
|
521
|
+
"""Return a cast value or a default of the same type."""
|
|
522
|
+
|
|
523
|
+
@typing.overload
|
|
524
|
+
def get(self, key: typing.Any, default: GD, /) -> VT | GD:
|
|
525
|
+
"""Return a cast value or an arbitrary default unchanged."""
|
|
526
|
+
|
|
527
|
+
def get(
|
|
528
|
+
self, key: typing.Any, default: typing.Any = None, /
|
|
529
|
+
) -> typing.Any:
|
|
530
|
+
"""Read a lazily cast value, returning ``default`` for missing keys.
|
|
531
|
+
|
|
532
|
+
Value conversion errors propagate just as they do for indexed reads.
|
|
533
|
+
A key that the key cast rejects cannot have been stored, so it is
|
|
534
|
+
missing. A missing default is not converted or stored, and
|
|
535
|
+
``__missing__`` is not called, as with ``dict.get``.
|
|
536
|
+
"""
|
|
537
|
+
if self._key_cast is not None:
|
|
538
|
+
try:
|
|
539
|
+
key = self._key_cast(key)
|
|
540
|
+
except (TypeError, ValueError, ArithmeticError):
|
|
541
|
+
return super().get(key, default)
|
|
542
|
+
value = super().get(key, _MISSING)
|
|
543
|
+
if value is _MISSING:
|
|
544
|
+
return default
|
|
545
|
+
if self._value_cast is not None:
|
|
546
|
+
value = self._value_cast(value)
|
|
547
|
+
return value
|
|
548
|
+
|
|
510
549
|
def items( # type: ignore[override]
|
|
511
550
|
self,
|
|
512
551
|
) -> collections.abc.Generator[tuple[KT, VT], None, None]:
|
|
@@ -554,6 +593,15 @@ class UniqueList(list[HT]):
|
|
|
554
593
|
>>> l
|
|
555
594
|
[5, 10, 2, 3, 4]
|
|
556
595
|
|
|
596
|
+
Assigning an item to itself keeps it, including a NaN that does not
|
|
597
|
+
compare equal to itself:
|
|
598
|
+
|
|
599
|
+
>>> missing = float('nan')
|
|
600
|
+
>>> samples = UniqueList(missing, on_duplicate='raise')
|
|
601
|
+
>>> samples[0] = missing
|
|
602
|
+
>>> len(samples)
|
|
603
|
+
1
|
|
604
|
+
|
|
557
605
|
A value that was removed or replaced can be added again:
|
|
558
606
|
|
|
559
607
|
>>> l = UniqueList(1, 2, 3)
|
|
@@ -952,7 +1000,7 @@ class UniqueList(list[HT]):
|
|
|
952
1000
|
"""
|
|
953
1001
|
old_value: HT = self[index]
|
|
954
1002
|
known: bool = value in self._set
|
|
955
|
-
if known and value != old_value:
|
|
1003
|
+
if known and value is not old_value and value != old_value:
|
|
956
1004
|
if self.on_duplicate == 'raise':
|
|
957
1005
|
raise ValueError(f'Duplicate value: {value}')
|
|
958
1006
|
else:
|
|
@@ -139,7 +139,7 @@ def to_int(
|
|
|
139
139
|
|
|
140
140
|
def to_float(
|
|
141
141
|
input_: str,
|
|
142
|
-
default:
|
|
142
|
+
default: _aliases.Number = 0,
|
|
143
143
|
exception: _aliases.ExceptionsType = (ValueError, TypeError),
|
|
144
144
|
regexp: _RegexpType = None,
|
|
145
145
|
) -> _aliases.Number:
|
|
@@ -308,6 +308,9 @@ def scale_1024(
|
|
|
308
308
|
|
|
309
309
|
Use to format numbers of bytes to KiB, MiB, etc.
|
|
310
310
|
|
|
311
|
+
A prefix is used only when the input reaches its power of 1024. For
|
|
312
|
+
integer inputs, the prefix is chosen before converting the value to float.
|
|
313
|
+
|
|
311
314
|
>>> scale_1024(310, 3)
|
|
312
315
|
(310.0, 0)
|
|
313
316
|
>>> scale_1024(2048, 3)
|
|
@@ -325,6 +328,14 @@ def scale_1024(
|
|
|
325
328
|
# Never below zero: a number under 1 has a negative logarithm, and a
|
|
326
329
|
# negative power would index the prefixes from the wrong end.
|
|
327
330
|
power = max(min(int(math.log(x, 2) / 10), n_prefixes - 1), 0)
|
|
331
|
+
# A logarithm can round a value just below a prefix boundary up to
|
|
332
|
+
# that boundary. Compare with the exact integer power before the
|
|
333
|
+
# input itself is rounded to float. Only an int needs the exact
|
|
334
|
+
# comparison: anything else goes through float(), as 4.1.4 did, so
|
|
335
|
+
# a numeric type without `__lt__` keeps working.
|
|
336
|
+
exact: int | float = x if isinstance(x, int) else float(x)
|
|
337
|
+
if power and exact < 2 ** (10 * power):
|
|
338
|
+
power -= 1
|
|
328
339
|
scaled = float(x) / (2 ** (10 * power))
|
|
329
340
|
return scaled, power
|
|
330
341
|
|
|
@@ -15,6 +15,7 @@ import asyncio
|
|
|
15
15
|
import collections.abc
|
|
16
16
|
import time
|
|
17
17
|
import typing
|
|
18
|
+
import warnings
|
|
18
19
|
|
|
19
20
|
import python_utils
|
|
20
21
|
from python_utils import _aliases
|
|
@@ -23,6 +24,29 @@ from python_utils import _aliases
|
|
|
23
24
|
_T = typing.TypeVar('_T')
|
|
24
25
|
|
|
25
26
|
|
|
27
|
+
def _warn_nonpositive_batch_size(
|
|
28
|
+
batch_size: int | None, interval: _aliases.delta_type | None = None
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Warn for a size below 1 that is used without an interval.
|
|
31
|
+
|
|
32
|
+
The size itself is left alone, so 4.x keeps its results: with an
|
|
33
|
+
interval, a size of 0 still yields an empty batch for an idle interval.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
nonpositive: bool = batch_size is not None and batch_size < 1
|
|
37
|
+
except TypeError:
|
|
38
|
+
# A size that does not compare with 1 never matched a batch length
|
|
39
|
+
# either, so it keeps its 4.x result.
|
|
40
|
+
return
|
|
41
|
+
if nonpositive and not interval:
|
|
42
|
+
warnings.warn(
|
|
43
|
+
'batch_size must be greater than zero; sizes below 1 are '
|
|
44
|
+
'deprecated and will raise ValueError in python-utils 5.0',
|
|
45
|
+
DeprecationWarning,
|
|
46
|
+
stacklevel=3,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
26
50
|
async def _cancel(pending: set[asyncio.Future[_T]]) -> None:
|
|
27
51
|
"""Cancel the items that are still on their way and wait for them.
|
|
28
52
|
|
|
@@ -78,14 +102,22 @@ async def abatcher(
|
|
|
78
102
|
Args:
|
|
79
103
|
generator: The async generator or iterator to batch.
|
|
80
104
|
batch_size (typing.Optional[int], optional): The number of items per
|
|
81
|
-
batch. Defaults to None.
|
|
105
|
+
batch. Defaults to None. With an interval, None or a value below
|
|
106
|
+
1 means no size limit. Without an interval, sizes below 1 are
|
|
107
|
+
deprecated and will raise ValueError in version 5.0.
|
|
82
108
|
interval (typing.Optional[_aliases.delta_type], optional): The time
|
|
83
109
|
interval to wait before yielding a batch. Defaults to None.
|
|
84
110
|
|
|
85
111
|
Yields:
|
|
86
112
|
collections.abc.AsyncGenerator[list[_T], None]: A generator that yields
|
|
87
113
|
batches of items.
|
|
114
|
+
|
|
115
|
+
Warns:
|
|
116
|
+
DeprecationWarning: If batch_size is below 1 without an interval.
|
|
117
|
+
The existing behavior is preserved until version 5.0.
|
|
88
118
|
"""
|
|
119
|
+
_warn_nonpositive_batch_size(batch_size, interval)
|
|
120
|
+
|
|
89
121
|
batch: list[_T] = []
|
|
90
122
|
|
|
91
123
|
assert batch_size or interval, 'Must specify either batch_size or interval'
|
|
@@ -170,12 +202,20 @@ def batcher(
|
|
|
170
202
|
Args:
|
|
171
203
|
iterable (collections.abc.Iterable[_T]): The iterable to batch.
|
|
172
204
|
batch_size (int, optional): The number of items per batch. Defaults
|
|
173
|
-
to 10.
|
|
205
|
+
to 10. Sizes below 1 are deprecated and will raise ValueError in
|
|
206
|
+
version 5.0. Until then, they yield all items in a single batch,
|
|
207
|
+
or no batches for an empty iterable.
|
|
174
208
|
|
|
175
209
|
Yields:
|
|
176
210
|
collections.abc.Generator[list[_T], None, None]: A generator that
|
|
177
211
|
yields batches of items.
|
|
212
|
+
|
|
213
|
+
Warns:
|
|
214
|
+
DeprecationWarning: If batch_size is below 1. The warning is issued
|
|
215
|
+
when iteration begins, before consuming any source items.
|
|
178
216
|
"""
|
|
217
|
+
_warn_nonpositive_batch_size(batch_size)
|
|
218
|
+
|
|
179
219
|
batch: list[_T] = []
|
|
180
220
|
for item in iterable:
|
|
181
221
|
batch.append(item)
|
|
@@ -87,7 +87,8 @@ def import_global( # noqa: C901
|
|
|
87
87
|
for everything. An empty list also imports everything. A single
|
|
88
88
|
name needs a list as well, a bare string is read as a collection
|
|
89
89
|
of one-character names. Names that start with an underscore are
|
|
90
|
-
never imported.
|
|
90
|
+
never imported. Declared or explicitly requested names may be
|
|
91
|
+
provided lazily by the module, even if absent from ``dir()``.
|
|
91
92
|
exceptions (Exception): the exception to catch, e.g. ImportError
|
|
92
93
|
locals_: the `locals()` method (in case you need a different scope)
|
|
93
94
|
globals_: the `globals()` method (in case you need a different scope)
|
|
@@ -96,7 +97,7 @@ def import_global( # noqa: C901
|
|
|
96
97
|
"""
|
|
97
98
|
frame = None
|
|
98
99
|
name_parts: list[str] = name.split('.')
|
|
99
|
-
modules_set: set[
|
|
100
|
+
modules_set: set[object] = set()
|
|
100
101
|
try:
|
|
101
102
|
# If locals_ or globals_ are not given, autodetect them by inspecting
|
|
102
103
|
# the current stack
|
|
@@ -136,12 +137,25 @@ def import_global( # noqa: C901
|
|
|
136
137
|
if not modules:
|
|
137
138
|
modules_set = set(getattr(module, '__all__', dir(module)))
|
|
138
139
|
else:
|
|
139
|
-
modules_set = set(modules)
|
|
140
|
-
|
|
141
|
-
# Add all items in modules to the global scope
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
modules_set = set(modules)
|
|
141
|
+
|
|
142
|
+
# Add all items in modules to the global scope. A name that dir()
|
|
143
|
+
# does not list can still be a lazy export of the module's
|
|
144
|
+
# __getattr__. It is resolved only when __all__ declares it, and
|
|
145
|
+
# skipped when that fails, as it was before lazy exports.
|
|
146
|
+
listed = set(dir(module))
|
|
147
|
+
exported: set[object] = set(getattr(module, '__all__', ()))
|
|
148
|
+
for k in modules_set:
|
|
149
|
+
if not isinstance(k, str) or not k or k[0] == '_':
|
|
150
|
+
continue
|
|
151
|
+
if k in listed:
|
|
144
152
|
globals_[k] = getattr(module, k)
|
|
153
|
+
elif k in exported:
|
|
154
|
+
try:
|
|
155
|
+
globals_[k] = getattr(module, k)
|
|
156
|
+
except Exception:
|
|
157
|
+
# A failing lazy export was never imported before.
|
|
158
|
+
continue
|
|
145
159
|
except exceptions as e:
|
|
146
160
|
return e
|
|
147
161
|
finally:
|
|
@@ -16,6 +16,7 @@ The module also configures Pyright to ignore wildcard import warnings.
|
|
|
16
16
|
# `from python_utils.types import datetime, decimal` worked in 3.x.
|
|
17
17
|
import datetime as datetime
|
|
18
18
|
import decimal as decimal
|
|
19
|
+
import sys
|
|
19
20
|
from re import Match, Pattern
|
|
20
21
|
from types import * # pragma: no cover # noqa: F403
|
|
21
22
|
from typing import * # pragma: no cover # noqa: F403
|
|
@@ -175,3 +176,10 @@ __all__ = [
|
|
|
175
176
|
'runtime_checkable',
|
|
176
177
|
'timestamp_type',
|
|
177
178
|
]
|
|
179
|
+
|
|
180
|
+
# Python 3.15 removed these from `typing`. Keeping them in `__all__` there
|
|
181
|
+
# would make `from python_utils.types import *` raise `AttributeError`. They
|
|
182
|
+
# stay at their 4.1.4 positions on older versions.
|
|
183
|
+
if sys.version_info >= (3, 15): # pragma: no cover
|
|
184
|
+
__all__.remove('ByteString')
|
|
185
|
+
__all__.remove('no_type_check_decorator')
|
|
@@ -6,7 +6,7 @@ requires =
|
|
|
6
6
|
# PyPy) isn't installed; CI provides each interpreter via tox-gh-actions.
|
|
7
7
|
skip_missing_interpreters = true
|
|
8
8
|
env_list =
|
|
9
|
-
py{310,311,312,313,314}
|
|
9
|
+
py{310,311,312,313,314,315}
|
|
10
10
|
pypy{310,311}
|
|
11
11
|
lint
|
|
12
12
|
mypy
|
|
@@ -16,7 +16,7 @@ env_list =
|
|
|
16
16
|
docs
|
|
17
17
|
coverage
|
|
18
18
|
labels =
|
|
19
|
-
test = py{310,311,312,313,314}, pypy{310,311}
|
|
19
|
+
test = py{310,311,312,313,314,315}, pypy{310,311}
|
|
20
20
|
check = lint, mypy, pyright, pyrefly, codespell
|
|
21
21
|
|
|
22
22
|
[gh-actions]
|
|
@@ -26,6 +26,7 @@ python =
|
|
|
26
26
|
3.12: py312
|
|
27
27
|
3.13: py313
|
|
28
28
|
3.14: py314
|
|
29
|
+
3.15: py315
|
|
29
30
|
pypy-3.10: pypy310
|
|
30
31
|
pypy-3.11: pypy311
|
|
31
32
|
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
"""Tests for the conversion helpers in ``python_utils.converters``."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from python_utils import converters
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@pytest.mark.parametrize('regexp', [r'\d+', re.compile(r'\d+')])
|
|
11
|
-
def test_to_int_regexp_without_group(regexp: re.Pattern[str] | str) -> None:
|
|
12
|
-
"""Use the whole match when the pattern has no capture group."""
|
|
13
|
-
assert converters.to_int('abc123', regexp=regexp) == 123
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@pytest.mark.parametrize('regexp', [r'\d+\.\d+', re.compile(r'\d+\.\d+')])
|
|
17
|
-
def test_to_float_regexp_without_group(
|
|
18
|
-
regexp: re.Pattern[str] | str,
|
|
19
|
-
) -> None:
|
|
20
|
-
"""Use the whole match when the pattern has no capture group."""
|
|
21
|
-
assert converters.to_float('abc1.5', regexp=regexp) == 1.5
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def test_regexp_with_groups_keeps_its_group() -> None:
|
|
25
|
-
"""Keep the last group for ``to_int`` and the first for ``to_float``."""
|
|
26
|
-
assert converters.to_int('a1b2', regexp=r'(\d)\D(\d)') == 2
|
|
27
|
-
assert converters.to_float('a1b2', regexp=r'(\d)\D(\d)') == 1.0
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@pytest.mark.parametrize('value', [0.0001, 1e-9, 2**-11])
|
|
31
|
-
def test_scale_1024_small_number(value: float) -> None:
|
|
32
|
-
"""Never scale a number below one up to a negative power."""
|
|
33
|
-
assert converters.scale_1024(value, 9) == (value, 0)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@pytest.mark.parametrize('n_prefixes', [0, -1])
|
|
37
|
-
def test_scale_1024_without_prefixes(n_prefixes: int) -> None:
|
|
38
|
-
"""Never return a negative power when there are no prefixes."""
|
|
39
|
-
assert converters.scale_1024(2048, n_prefixes) == (2048.0, 0)
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
"""Regression tests for the public ``python_utils.types`` facade."""
|
|
2
|
-
|
|
3
|
-
import collections
|
|
4
|
-
|
|
5
|
-
from python_utils import types
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def test_types_all_entries_are_unique() -> None:
|
|
9
|
-
"""Export every public name once."""
|
|
10
|
-
counts: collections.Counter[str] = collections.Counter(types.__all__)
|
|
11
|
-
duplicates: list[str] = sorted(
|
|
12
|
-
name for name, count in counts.items() if count > 1
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
assert duplicates == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|