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.
Files changed (44) hide show
  1. {python_utils-4.1.4 → python_utils-4.2.1}/PKG-INFO +3 -1
  2. {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_aio.py +22 -0
  3. {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_containers.py +134 -0
  4. python_utils-4.2.1/_python_utils_tests/test_converters.py +104 -0
  5. {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_generators.py +145 -1
  6. python_utils-4.2.1/_python_utils_tests/test_import.py +404 -0
  7. python_utils-4.2.1/_python_utils_tests/test_types.py +49 -0
  8. {python_utils-4.1.4 → python_utils-4.2.1}/pyproject.toml +4 -1
  9. {python_utils-4.1.4 → python_utils-4.2.1}/pyproject.toml.orig +6 -1
  10. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/aio.py +13 -0
  11. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/containers.py +49 -1
  12. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/converters.py +12 -1
  13. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/generators.py +42 -2
  14. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/import_.py +58 -24
  15. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/types.py +8 -0
  16. {python_utils-4.1.4 → python_utils-4.2.1}/tox.ini +3 -2
  17. python_utils-4.1.4/_python_utils_tests/test_converters.py +0 -39
  18. python_utils-4.1.4/_python_utils_tests/test_import.py +0 -205
  19. python_utils-4.1.4/_python_utils_tests/test_types.py +0 -15
  20. {python_utils-4.1.4 → python_utils-4.2.1}/LICENSE +0 -0
  21. {python_utils-4.1.4 → python_utils-4.2.1}/README.md +0 -0
  22. {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/__init__.py +0 -0
  23. {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/clock.py +0 -0
  24. {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_aliases.py +0 -0
  25. {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_decorators.py +0 -0
  26. {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_formatters.py +0 -0
  27. {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_import_footprint.py +0 -0
  28. {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_lazy_imports.py +0 -0
  29. {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_logged.py +0 -0
  30. {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_logger.py +0 -0
  31. {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_python_utils.py +0 -0
  32. {python_utils-4.1.4 → python_utils-4.2.1}/_python_utils_tests/test_time.py +0 -0
  33. {python_utils-4.1.4 → python_utils-4.2.1}/conftest.py +0 -0
  34. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/__about__.py +0 -0
  35. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/__init__.py +0 -0
  36. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/_aliases.py +0 -0
  37. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/decorators.py +0 -0
  38. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/exceptions.py +0 -0
  39. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/formatters.py +0 -0
  40. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/logger.py +0 -0
  41. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/loguru.py +0 -0
  42. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/py.typed +0 -0
  43. {python_utils-4.1.4 → python_utils-4.2.1}/python_utils/terminal.py +0 -0
  44. {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.4
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 = []