python-utils 4.1.3__py3-none-any.whl → 4.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
python_utils/aio.py CHANGED
@@ -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)
python_utils/import_.py CHANGED
@@ -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:
python_utils/types.py CHANGED
@@ -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')
@@ -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
@@ -1,21 +1,21 @@
1
1
  python_utils/__about__.py,sha256=qmqR375psvJc8IPYmLhruuaiouHzhdHu7DozRZG34DM,1343
2
2
  python_utils/__init__.py,sha256=atYwTBHh2KNGdPSawwQKYCL9UolHho3L4WFxo901WDg,5958
3
3
  python_utils/_aliases.py,sha256=xf_HrYaAa9ZKZAdYYJwIen-OB3nFGI5TTUETH5_ayGY,1641
4
- python_utils/aio.py,sha256=64uhJ2jEF2vyTI7TQsgxWnTnrda9O23kKVFr6ebnuq0,4235
5
- python_utils/containers.py,sha256=kHpDgj_sAdoouL0b_CE6IHSd-qtz0KYMs_Nuiba-yEQ,37135
6
- python_utils/converters.py,sha256=gBOjkVYGwx1aBs7OtjFrMdQgZwI6wbOU_Rvy0u9OVFE,17273
4
+ python_utils/aio.py,sha256=C4R3BLDINMMucuDc9-kGdGdP70ayn2Ctvjx6Q6BxJZA,4846
5
+ python_utils/containers.py,sha256=cC-htd9QBu4hSoR5meB_10845XKQDIsoS-2mzyhW0hg,38906
6
+ python_utils/converters.py,sha256=Klk5wOYdKzshMqaeaP6MkUKmYSjjHl8xqAcyfkEighE,17927
7
7
  python_utils/decorators.py,sha256=UyVuV7Phf8kjAt38uA-F9MFH2o8zM4t-fmlgt3KL5cc,8225
8
8
  python_utils/exceptions.py,sha256=B5wljs3Id6eQ9eRaur-ZMPGGmhHNwh9F08Sfw6fuLX4,1230
9
9
  python_utils/formatters.py,sha256=rvbeP7H3h8Hll573PZkWsJPuHETjEHpWleb0tFck8MM,7031
10
- python_utils/generators.py,sha256=xYGEOzMrdxOmcm2SVMOVUQh9XNzpOpaMD13opzPCp2A,6670
11
- python_utils/import_.py,sha256=EnVSLs7Sk1CHrkpiKdXe8i-O6XjS5hXINpjdEohRT4A,5541
10
+ python_utils/generators.py,sha256=dw-x72fitGgdeqLS4_xyspS81ayqExvT77THHoZBA5I,8307
11
+ python_utils/import_.py,sha256=xabWnas9OnFs7ks52D7yqpeKg8SvkBdHZIfTmuk5yJ8,6308
12
12
  python_utils/logger.py,sha256=-QuF7Zfo7pxc4nTmAPc9Pm-oz5wsxXIX7NeCnYEPI6I,14043
13
13
  python_utils/loguru.py,sha256=qsap-aXByGXsd4KrYmtneTSUARVIdeVu9xgmrFPtqSI,2696
14
14
  python_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
15
  python_utils/terminal.py,sha256=GL3n2_rT98LBNmxy-amY-hezGKStCo-9_d_pR-TMgv8,6823
16
16
  python_utils/time.py,sha256=fZz00bpTeLXFFhbAo03LiMLWuzSJU4iPbyGSe7PYshU,16979
17
- python_utils/types.py,sha256=N1O2uFE2IuwVJk_vcwz8XNkf-taL-vJ5iNNG6FUXjTg,4336
18
- python_utils-4.1.3.dist-info/licenses/LICENSE,sha256=_Zrs9NZu-G6_aZT2g3Jv5_JREJQfhRNBC61EH6WDH-o,1501
19
- python_utils-4.1.3.dist-info/WHEEL,sha256=9IUYslYuG_asT4WlWvfk_K76nveuLvSTKDoLeL0cV-Q,80
20
- python_utils-4.1.3.dist-info/METADATA,sha256=2xDVZBIATXuKFY6WjvPP8rJRfMufLm28n5lG9dhasm0,15988
21
- python_utils-4.1.3.dist-info/RECORD,,
17
+ python_utils/types.py,sha256=VQLrKDRerOuhYxMhCaWWbUBnDXJhFx_G20gIfyolgug,4682
18
+ python_utils-4.2.0.dist-info/licenses/LICENSE,sha256=_Zrs9NZu-G6_aZT2g3Jv5_JREJQfhRNBC61EH6WDH-o,1501
19
+ python_utils-4.2.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
20
+ python_utils-4.2.0.dist-info/METADATA,sha256=b5rgvrejxzOa3qwhB3eernFS84IR-8IsYM2VBliBDqo,16039
21
+ python_utils-4.2.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.13.1
2
+ Generator: uv 0.13.7
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any