python-utils 4.1.4__tar.gz → 4.2.1__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.1}/PKG-INFO +3 -1
- {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_aio.py +22 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_containers.py +134 -0
- python_utils-4.2.1/_python_utils_tests/test_converters.py +104 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_generators.py +145 -1
- python_utils-4.2.1/_python_utils_tests/test_import.py +404 -0
- python_utils-4.2.1/_python_utils_tests/test_types.py +49 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/pyproject.toml +4 -1
- {python_utils-4.1.4 → python_utils-4.2.1}/pyproject.toml.orig +6 -1
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/aio.py +13 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/containers.py +49 -1
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/converters.py +12 -1
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/generators.py +42 -2
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/import_.py +58 -24
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/types.py +8 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/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_import.py +0 -205
- python_utils-4.1.4/_python_utils_tests/test_types.py +0 -15
- {python_utils-4.1.4 → python_utils-4.2.1}/LICENSE +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/README.md +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/__init__.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/clock.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_aliases.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_decorators.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_formatters.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_import_footprint.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_lazy_imports.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_logged.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_logger.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_python_utils.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_time.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/conftest.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/__about__.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/__init__.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/_aliases.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/decorators.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/exceptions.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/formatters.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/logger.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/loguru.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/py.typed +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/terminal.py +0 -0
- {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/time.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-utils
|
|
3
|
-
Version: 4.1
|
|
3
|
+
Version: 4.2.1
|
|
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
|
|
@@ -36,6 +37,7 @@ Project-URL: Documentation, https://python-utils.readthedocs.io/
|
|
|
36
37
|
Project-URL: Repository, https://github.com/WoLpH/python-utils
|
|
37
38
|
Project-URL: Changelog, https://github.com/WoLpH/python-utils/releases
|
|
38
39
|
Project-URL: Funding, https://github.com/sponsors/wolph
|
|
40
|
+
Project-URL: Issues, https://github.com/wolph/python-utils/issues
|
|
39
41
|
Provides-Extra: docs
|
|
40
42
|
Provides-Extra: loguru
|
|
41
43
|
Provides-Extra: tests
|
|
@@ -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 = []
|