python-utils 4.1.3__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.
Files changed (44) hide show
  1. {python_utils-4.1.3 → python_utils-4.2.0}/PKG-INFO +2 -1
  2. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/test_aio.py +22 -0
  3. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/test_containers.py +134 -0
  4. python_utils-4.2.0/_python_utils_tests/test_converters.py +104 -0
  5. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/test_generators.py +145 -1
  6. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/test_import.py +96 -0
  7. python_utils-4.2.0/_python_utils_tests/test_types.py +49 -0
  8. {python_utils-4.1.3 → python_utils-4.2.0}/pyproject.toml +3 -2
  9. {python_utils-4.1.3 → python_utils-4.2.0}/pyproject.toml.orig +3 -2
  10. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/aio.py +13 -0
  11. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/containers.py +49 -1
  12. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/converters.py +12 -1
  13. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/generators.py +42 -2
  14. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/import_.py +21 -7
  15. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/types.py +8 -0
  16. {python_utils-4.1.3 → python_utils-4.2.0}/tox.ini +3 -2
  17. python_utils-4.1.3/_python_utils_tests/requirements.txt +0 -1
  18. python_utils-4.1.3/_python_utils_tests/test_converters.py +0 -39
  19. python_utils-4.1.3/_python_utils_tests/test_types.py +0 -15
  20. {python_utils-4.1.3 → python_utils-4.2.0}/LICENSE +0 -0
  21. {python_utils-4.1.3 → python_utils-4.2.0}/README.md +0 -0
  22. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/__init__.py +0 -0
  23. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/clock.py +0 -0
  24. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/test_aliases.py +0 -0
  25. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/test_decorators.py +0 -0
  26. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/test_formatters.py +0 -0
  27. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/test_import_footprint.py +0 -0
  28. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/test_lazy_imports.py +0 -0
  29. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/test_logged.py +0 -0
  30. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/test_logger.py +0 -0
  31. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/test_python_utils.py +0 -0
  32. {python_utils-4.1.3 → python_utils-4.2.0}/_python_utils_tests/test_time.py +0 -0
  33. {python_utils-4.1.3 → python_utils-4.2.0}/conftest.py +0 -0
  34. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/__about__.py +0 -0
  35. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/__init__.py +0 -0
  36. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/_aliases.py +0 -0
  37. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/decorators.py +0 -0
  38. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/exceptions.py +0 -0
  39. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/formatters.py +0 -0
  40. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/logger.py +0 -0
  41. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/loguru.py +0 -0
  42. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/py.typed +0 -0
  43. {python_utils-4.1.3 → python_utils-4.2.0}/python_utils/terminal.py +0 -0
  44. {python_utils-4.1.3 → 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.1.3
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
+ )
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["uv_build>=0.11,<0.13"]
2
+ requires = ["uv_build>=0.11,<1"]
3
3
  build-backend = "uv_build"
4
4
 
5
5
  [tool.mypy]
@@ -125,7 +125,7 @@ source-include = [
125
125
 
126
126
  [project]
127
127
  name = "python-utils"
128
- version = "4.1.3"
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",
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ['uv_build>=0.11,<0.13']
2
+ requires = ['uv_build>=0.11,<1']
3
3
  build-backend = 'uv_build'
4
4
 
5
5
  [tool.uv.build-backend]
@@ -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.1.3" # static; the single source of truth. Bump via `uv version`.
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: int = 0,
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[str] = 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).intersection(dir(module))
140
-
141
- # Add all items in modules to the global scope
142
- for k in set(dir(module)).intersection(modules_set):
143
- if k and k[0] != '_':
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 +0,0 @@
1
- -e .[tests]
@@ -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