python-utils 4.0.1__py3-none-any.whl → 4.1.1__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.
@@ -50,7 +50,13 @@ def to_int(
50
50
  When a (regexp) object (has a search method) is given, that will be used.
51
51
  WHen a string is given, re.compile will be run over it first
52
52
 
53
- The last group of the regexp will be used as value
53
+ The last group of the regexp will be used as value. A regexp without a
54
+ group uses the whole match.
55
+
56
+ With ``regexp=True`` only the first run of digits is used. A sign, a
57
+ decimal point and an exponent are not part of it, so the result can differ
58
+ from the same call without a regexp. A regexp can only search a ``str``.
59
+ Any other input gives the default.
54
60
 
55
61
  >>> to_int('abc')
56
62
  0
@@ -94,6 +100,14 @@ def to_int(
94
100
  1234
95
101
  >>> to_int('abc', default=1)
96
102
  1
103
+ >>> to_int('abc123', regexp=r'\d+')
104
+ 123
105
+ >>> to_int('-5'), to_int('-5', regexp=True)
106
+ (-5, 5)
107
+ >>> to_int('1e3'), to_int('1e3', regexp=True)
108
+ (0, 1)
109
+ >>> to_int(123), to_int(123, regexp=True)
110
+ (123, 0)
97
111
  >>> to_int('abc', regexp=123)
98
112
  Traceback (most recent call last):
99
113
  ...
@@ -110,7 +124,10 @@ def to_int(
110
124
 
111
125
  try:
112
126
  if regexp and input_ and (match := regexp.search(input_)):
113
- input_ = match.groups()[-1]
127
+ # A pattern without a capture group has no last group, so the
128
+ # whole match is the value.
129
+ groups: tuple[str | None, ...] = match.groups()
130
+ input_ = groups[-1] if groups else match.group(0)
114
131
 
115
132
  if input_ is None:
116
133
  return default
@@ -127,10 +144,12 @@ def to_float(
127
144
  regexp: _RegexpType = None,
128
145
  ) -> _aliases.Number:
129
146
  r"""
130
- Convert the given `input_` to an integer or return default.
147
+ Convert the given `input_` to a float or return default.
131
148
 
132
149
  When trying to convert the exceptions given in the exception parameter
133
- are automatically caught and the default will be returned.
150
+ are automatically caught and the default will be returned. The default is
151
+ the int ``0``, so a failed conversion returns an ``int`` unless a float
152
+ is passed as default.
134
153
 
135
154
  The regexp parameter allows for a regular expression to find the digits
136
155
  in a string.
@@ -138,8 +157,31 @@ def to_float(
138
157
  When a (regexp) object (has a search method) is given, that will be used.
139
158
  When a string is given, re.compile will be run over it first
140
159
 
141
- The last group of the regexp will be used as value
160
+ The first group of the regexp will be used as value. A regexp without a
161
+ group uses the whole match.
142
162
 
163
+ With ``regexp=True`` only the first run of digits is used, with the
164
+ decimal part that follows it. A sign, a leading decimal point and an
165
+ exponent are not part of it, so the result can differ from the same call
166
+ without a regexp. A regexp can only search a ``str``. Any other input
167
+ gives the default.
168
+
169
+ >>> to_float('abc')
170
+ 0
171
+ >>> to_float('abc', default=0.0)
172
+ 0.0
173
+ >>> to_float('abc1.5', regexp=r'\d+\.\d+')
174
+ 1.5
175
+ >>> to_float('a1b2', regexp=r'(\d)\D(\d)')
176
+ 1.0
177
+ >>> to_float('-1.5'), to_float('-1.5', regexp=True)
178
+ (-1.5, 1.5)
179
+ >>> to_float('.5'), to_float('.5', regexp=True)
180
+ (0.5, 5.0)
181
+ >>> to_float('1e3'), to_float('1e3', regexp=True)
182
+ (1000.0, 1.0)
183
+ >>> to_float(1.5), to_float(1.5, regexp=True)
184
+ (1.5, 0)
143
185
  >>> '%.2f' % to_float('abc')
144
186
  '0.00'
145
187
  >>> '%.2f' % to_float('1')
@@ -188,7 +230,9 @@ def to_float(
188
230
 
189
231
  try:
190
232
  if regexp and (match := regexp.search(input_)):
191
- input_ = match.group(1)
233
+ # A pattern without a capture group has no first group, so the
234
+ # whole match is the value.
235
+ input_ = match.group(1) if match.groups() else match.group(0)
192
236
  return float(input_)
193
237
  except exception:
194
238
  return default
@@ -231,7 +275,7 @@ def to_str(
231
275
  ) -> bytes:
232
276
  """Convert objects to string, encodes to the given encoding.
233
277
 
234
- :rtype: str
278
+ :rtype: bytes
235
279
 
236
280
  >>> to_str('a')
237
281
  b'a'
@@ -242,9 +286,9 @@ def to_str(
242
286
  >>> class Foo(object):
243
287
  ... __str__ = lambda s: 'a'
244
288
  >>> to_str(Foo())
245
- 'a'
289
+ b'a'
246
290
  >>> to_str(Foo)
247
- "<class 'python_utils.converters.Foo'>"
291
+ b"<class 'python_utils.converters.Foo'>"
248
292
  """
249
293
  if not isinstance(input_, bytes):
250
294
  if not hasattr(input_, 'encode'):
@@ -278,7 +322,9 @@ def scale_1024(
278
322
  if x <= 0:
279
323
  power = 0
280
324
  else:
281
- power = min(int(math.log(x, 2) / 10), n_prefixes - 1)
325
+ # Never below zero: a number under 1 has a negative logarithm, and a
326
+ # negative power would index the prefixes from the wrong end.
327
+ power = max(min(int(math.log(x, 2) / 10), n_prefixes - 1), 0)
282
328
  scaled = float(x) / (2 ** (10 * power))
283
329
  return scaled, power
284
330
 
@@ -386,11 +432,20 @@ def remap( # pyright: ignore[reportInconsistentOverload]
386
432
 
387
433
  If floating point remaps need to be done my suggestion is to pass at least
388
434
  one parameter as a `decimal.Decimal`. This will ensure that the output
389
- from this function is accurate. I left passing `floats` for backwards
390
- compatibility and there is no conversion done from float to
391
- `decimal.Decimal` unless one of the passed parameters has a type of
392
- `decimal.Decimal`. This will ensure that any existing code that uses this
393
- function will work exactly how it has in the past.
435
+ from this function is accurate, as long as every value with a fraction is
436
+ a `decimal.Decimal` itself. A `float` is converted exactly, with its
437
+ binary rounding error included:
438
+
439
+ >>> remap(0.1, 0, 1, 0, decimal.Decimal(10))
440
+ Decimal('1.000000000000000055511151231')
441
+ >>> remap(decimal.Decimal('0.1'), 0, 1, 0, 10)
442
+ Decimal('1.0')
443
+
444
+ I left passing `floats` for backwards compatibility and there is no
445
+ conversion done from float to `decimal.Decimal` unless one of the passed
446
+ parameters has a type of `decimal.Decimal`. This will ensure that any
447
+ existing code that uses this function will work exactly how it has in the
448
+ past.
394
449
 
395
450
  Some edge cases to test
396
451
  >>> remap(1, 0, 0, 1, 2)
@@ -24,6 +24,11 @@ import typing
24
24
  _T = typing.TypeVar('_T')
25
25
  _P = typing.ParamSpec('_P')
26
26
 
27
+ #: Logger of this module. The module-level `logging.debug()` writes to the
28
+ #: root logger and installs a handler on it when it has none, which is the
29
+ #: application's decision to make.
30
+ _logger: logging.Logger = logging.getLogger(__name__)
31
+
27
32
 
28
33
  def set_attributes(
29
34
  **kwargs: typing.Any,
@@ -74,6 +79,11 @@ def listify(
74
79
  """
75
80
  Convert any generator to a list or other type of collection.
76
81
 
82
+ A function that returns `None` counts as empty. With `allow_empty` that
83
+ gives an empty collection, and with `allow_empty=False` it raises a
84
+ `TypeError`. The `None` is all that `allow_empty` looks at: a generator
85
+ that yields nothing gives an empty collection either way.
86
+
77
87
  >>> @listify()
78
88
  ... def generator():
79
89
  ... yield 1
@@ -99,6 +109,13 @@ def listify(
99
109
  ...
100
110
  TypeError: ... `allow_empty` is `False`
101
111
 
112
+ >>> @listify(allow_empty=False)
113
+ ... def generator_without_items():
114
+ ... yield from ()
115
+
116
+ >>> generator_without_items()
117
+ []
118
+
102
119
  >>> @listify(collection=set)
103
120
  ... def set_generator():
104
121
  ... yield 1
@@ -124,6 +141,7 @@ def listify(
124
141
  ) -> collections.abc.Callable[..., collections.abc.Collection[_T]]:
125
142
  """Materialize ``function``'s result into ``collection``."""
126
143
 
144
+ @functools.wraps(function)
127
145
  def __listify(
128
146
  *args: typing.Any, **kwargs: typing.Any
129
147
  ) -> collections.abc.Collection[_T]:
@@ -178,7 +196,7 @@ def sample(
178
196
  if random.random() < sample_rate:
179
197
  return function(*args, **kwargs)
180
198
  else:
181
- logging.debug(
199
+ _logger.debug(
182
200
  'Skipped execution of %r(%r, %r) due to sampling',
183
201
  function,
184
202
  args,
@@ -224,13 +242,21 @@ def wraps_classmethod(
224
242
  wrapper = functools.update_wrapper(
225
243
  wrapper,
226
244
  wrapped,
245
+ # The annotations are handled below. Python 3.14 lists
246
+ # `__annotate__` here where older versions list
247
+ # `__annotations__`, so both names are left out.
227
248
  assigned=tuple(
228
249
  a
229
250
  for a in functools.WRAPPER_ASSIGNMENTS
230
- if a != '__annotations__'
251
+ if a not in ('__annotations__', '__annotate__')
231
252
  ),
232
253
  )
233
- if annotations := getattr(wrapped, '__annotations__', {}):
254
+ # A copy, because the dictionary belongs to `wrapped` and that one
255
+ # keeps its `self`.
256
+ annotations: dict[str, typing.Any] = dict(
257
+ getattr(wrapped, '__annotations__', {})
258
+ )
259
+ if annotations:
234
260
  # Drop `self`: the wrapper is a classmethod, so it takes no `self`.
235
261
  annotations.pop('self', None)
236
262
  wrapper.__annotations__ = annotations
@@ -21,7 +21,8 @@ def camel_to_underscore(name: str) -> str:
21
21
 
22
22
  If there are existing underscores they will be collapsed with the
23
23
  to-be-added underscores. Multiple consecutive capital letters will not be
24
- split except for the last one.
24
+ split except for the last one. A plural ``s`` that ends a word stays with
25
+ the acronym before it.
25
26
 
26
27
  >>> camel_to_underscore('SpamEggsAndBacon')
27
28
  'spam_eggs_and_bacon'
@@ -42,11 +43,16 @@ def camel_to_underscore(name: str) -> str:
42
43
  # Uppercase and the previous character isn't upper/underscore?
43
44
  # Add the underscore
44
45
  output.append('_')
45
- elif i > 3 and not c.isupper():
46
- # Will return the last 3 letters to check if we are changing
47
- # case
48
- previous = name[i - 3 : i]
49
- if previous.isalpha() and previous.isupper():
46
+ elif i > 1 and c.islower():
47
+ # Two preceding capitals are enough to mark the boundary:
48
+ # the final capital belongs to the following word. Only a
49
+ # lowercase letter ends an acronym; an underscore or digit
50
+ # after it leaves the acronym whole.
51
+ previous = name[i - 2 : i]
52
+ # A lone ``s`` that ends the word makes the acronym plural
53
+ # (``IDs``, ``URLs``) and does not start a new word.
54
+ plural: bool = c == 's' and not name[i + 1 : i + 2].islower()
55
+ if previous.isalpha() and previous.isupper() and not plural:
50
56
  output.insert(len(output) - 1, '_')
51
57
 
52
58
  output.append(c.lower())
@@ -140,16 +146,32 @@ def timesince(
140
146
  '1 hour and 2 minutes ago'
141
147
  """
142
148
  if isinstance(dt, datetime.timedelta):
143
- diff = dt
149
+ # A negative timedelta has negative days and positive seconds, so
150
+ # only its size can be described.
151
+ diff = abs(dt)
144
152
  else:
145
- now = datetime.datetime.now()
153
+ # An aware datetime can only be compared with an aware current time.
154
+ # For a naive datetime `tzinfo` is `None`, which gives local time.
155
+ # A date has no `tzinfo`, and no time to subtract either. It gets
156
+ # the same `TypeError` from the subtraction as it always did.
157
+ now = datetime.datetime.now(getattr(dt, 'tzinfo', None))
146
158
  diff = abs(now - dt)
147
159
 
160
+ # Every unit takes its share from what the larger units left over, so a
161
+ # day is never counted twice.
162
+ years: int
163
+ months: int
164
+ weeks: int
165
+ days: int
166
+ years, days = divmod(diff.days, 365)
167
+ months, days = divmod(days, 30)
168
+ weeks, days = divmod(days, 7)
169
+
148
170
  periods = (
149
- (diff.days / 365, 'year', 'years'),
150
- (diff.days % 365 / 30, 'month', 'months'),
151
- (diff.days % 30 / 7, 'week', 'weeks'),
152
- (diff.days % 7, 'day', 'days'),
171
+ (years, 'year', 'years'),
172
+ (months, 'month', 'months'),
173
+ (weeks, 'week', 'weeks'),
174
+ (days, 'day', 'days'),
153
175
  (diff.seconds / 3600, 'hour', 'hours'),
154
176
  (diff.seconds % 3600 / 60, 'minute', 'minutes'),
155
177
  (diff.seconds % 60, 'second', 'seconds'),
@@ -23,6 +23,41 @@ from python_utils import _aliases
23
23
  _T = typing.TypeVar('_T')
24
24
 
25
25
 
26
+ async def _cancel(pending: set[asyncio.Future[_T]]) -> None:
27
+ """Cancel the items that are still on their way and wait for them.
28
+
29
+ Args:
30
+ pending: The unfinished futures to cancel. An empty set is fine.
31
+ """
32
+ if not pending:
33
+ # Nothing is on its way. This also covers a batcher that is closed
34
+ # by the garbage collector after its event loop is gone.
35
+ return
36
+
37
+ for future in pending:
38
+ future.cancel()
39
+
40
+ # Waiting lets the cancellation reach the generator. A cancelled item is
41
+ # of no use to anyone. An error that the generator raises while it is
42
+ # being cancelled is reported to the event loop, because raising it here
43
+ # would replace the cancellation of the consumer.
44
+ outcomes: list[_T | BaseException] = await asyncio.gather(
45
+ *pending, return_exceptions=True
46
+ )
47
+ for outcome in outcomes:
48
+ # A generator that returns when it is cancelled ends its item with
49
+ # `StopAsyncIteration`, which is no failure.
50
+ if isinstance(outcome, Exception) and not isinstance(
51
+ outcome, StopAsyncIteration
52
+ ):
53
+ asyncio.get_running_loop().call_exception_handler(
54
+ {
55
+ 'message': 'abatcher source failed while being cancelled',
56
+ 'exception': outcome,
57
+ }
58
+ )
59
+
60
+
26
61
  async def abatcher(
27
62
  generator: collections.abc.AsyncGenerator[_T, None]
28
63
  | collections.abc.AsyncIterator[_T],
@@ -33,6 +68,10 @@ async def abatcher(
33
68
  Asyncio generator wrapper that returns items with a given batch size or
34
69
  interval (whichever is reached first).
35
70
 
71
+ Stopping early while the next item is still on its way cancels the
72
+ request for it. A source that is an async generator ends at that point.
73
+ A source with nothing on its way stays usable.
74
+
36
75
  Args:
37
76
  generator: The async generator or iterator to batch.
38
77
  batch_size (typing.Optional[int], optional): The number of items per
@@ -58,45 +97,54 @@ async def abatcher(
58
97
 
59
98
  next_yield: float = time.perf_counter() + interval_s
60
99
 
61
- done: set[asyncio.Task[_T]]
62
- pending: set[asyncio.Task[_T]] = set()
63
-
64
- while True:
65
- try:
66
- done, pending = await asyncio.wait(
67
- pending
68
- or [
69
- asyncio.create_task(
70
- typing.cast(
71
- collections.abc.Coroutine[None, None, _T],
72
- generator.__anext__(),
73
- )
74
- ),
75
- ],
76
- timeout=interval_s,
77
- return_when=asyncio.FIRST_COMPLETED,
78
- )
100
+ done: set[asyncio.Future[_T]]
101
+ pending: set[asyncio.Future[_T]] = set()
79
102
 
80
- if done:
81
- batch.extend(result.result() for result in done)
103
+ try:
104
+ while True:
105
+ try:
106
+ if not pending:
107
+ # Store the task before waiting for it. A cancellation
108
+ # during the wait still finds it in the `finally` below.
109
+ # `ensure_future` takes every awaitable, where
110
+ # `create_task` insists on a coroutine.
111
+ pending = {asyncio.ensure_future(generator.__anext__())}
82
112
 
83
- except StopAsyncIteration:
84
- if batch:
85
- yield batch
113
+ done, pending = await asyncio.wait(
114
+ pending,
115
+ timeout=interval_s,
116
+ return_when=asyncio.FIRST_COMPLETED,
117
+ )
86
118
 
87
- break
119
+ if done:
120
+ batch.extend(result.result() for result in done)
88
121
 
89
- if batch_size is not None and len(batch) == batch_size:
90
- yield batch
91
- batch = []
122
+ except StopAsyncIteration:
123
+ if batch:
124
+ yield batch
92
125
 
93
- if interval and batch and time.perf_counter() > next_yield:
94
- yield batch
95
- batch = []
96
- # Always set the next yield time to the current time. If the
97
- # loop is running slow due to blocking functions we do not
98
- # want to burst too much
99
- next_yield = time.perf_counter() + interval_s
126
+ break
127
+
128
+ if batch_size is not None and len(batch) == batch_size:
129
+ yield batch
130
+ batch = []
131
+ # A full batch starts a new interval as well. Otherwise the
132
+ # next item is flushed on its own once the old one ran out.
133
+ next_yield = time.perf_counter() + interval_s
134
+
135
+ if interval and batch and time.perf_counter() > next_yield:
136
+ yield batch
137
+ batch = []
138
+ # Always set the next yield time to the current time. If the
139
+ # loop is running slow due to blocking functions we do not
140
+ # want to burst too much
141
+ next_yield = time.perf_counter() + interval_s
142
+ finally:
143
+ # The consumer can stop while the next item is still on its way: an
144
+ # early `break`, `aclose()` or a cancellation. Without this the task
145
+ # keeps running, takes the next item from the generator and nobody
146
+ # ever receives it.
147
+ await _cancel(pending)
100
148
 
101
149
 
102
150
  def batcher(
python_utils/import_.py CHANGED
@@ -12,6 +12,8 @@ Functions:
12
12
  relative imports and custom exception handling.
13
13
  """
14
14
 
15
+ import importlib
16
+ import types
15
17
  import typing
16
18
 
17
19
  from python_utils import _aliases
@@ -25,6 +27,46 @@ class DummyError(Exception):
25
27
  DummyException = DummyError
26
28
 
27
29
 
30
+ def _get_attribute(module: typing.Any, attr: str, name: str) -> typing.Any:
31
+ """Return ``module.attr``, importing it as a submodule when needed.
32
+
33
+ A submodule only becomes an attribute of its parent once something has
34
+ imported it.
35
+
36
+ Args:
37
+ module: The module, or other object, to take the attribute from.
38
+ attr: The name of the attribute or submodule.
39
+ name: The full dotted name that is being imported, for the error.
40
+
41
+ Returns:
42
+ The attribute or the imported submodule.
43
+
44
+ Raises:
45
+ ImportError: When ``module`` has no such attribute or submodule. An
46
+ error that the submodule raises while it is imported is passed
47
+ on as it is, a missing dependency included.
48
+ """
49
+ try:
50
+ return getattr(module, attr)
51
+ except AttributeError as error:
52
+ if not isinstance(module, types.ModuleType):
53
+ # The same error as for a missing module, as it always was.
54
+ raise ImportError( # noqa: TRY004
55
+ f'No module named {name}'
56
+ ) from error
57
+
58
+ submodule: str = f'{module.__name__}.{attr}'
59
+ try:
60
+ return importlib.import_module(submodule)
61
+ except ModuleNotFoundError as error:
62
+ missing: str = error.name or ''
63
+ if missing != submodule and not submodule.startswith(f'{missing}.'):
64
+ # The submodule exists and one of its own imports is missing.
65
+ raise
66
+
67
+ raise ImportError(f'No module named {name}') from error
68
+
69
+
28
70
  def import_global( # noqa: C901
29
71
  name: str,
30
72
  modules: list[str] | None = None,
@@ -41,7 +83,11 @@ def import_global( # noqa: C901
41
83
 
42
84
  Args:
43
85
  name (str): the name of the module to import, e.g. sys
44
- modules (str): the modules to import, use None for everything
86
+ modules (list[str]): the names to import from the module, use None
87
+ for everything. An empty list also imports everything. A single
88
+ name needs a list as well, a bare string is read as a collection
89
+ of one-character names. Names that start with an underscore are
90
+ never imported.
45
91
  exceptions (Exception): the exception to catch, e.g. ImportError
46
92
  locals_: the `locals()` method (in case you need a different scope)
47
93
  globals_: the `globals()` method (in case you need a different scope)
@@ -82,13 +128,8 @@ def import_global( # noqa: C901
82
128
 
83
129
  # Make sure we get the right part of a dotted import (i.e.
84
130
  # spam.eggs should return eggs, not spam)
85
- try:
86
- for attr in name_parts[1:]:
87
- module = getattr(module, attr)
88
- except AttributeError as e:
89
- raise ImportError(
90
- 'No module named ' + '.'.join(name_parts)
91
- ) from e
131
+ for attr in name_parts[1:]:
132
+ module = _get_attribute(module, attr, '.'.join(name_parts))
92
133
 
93
134
  # If no list of modules is given, autodetect from either __all__
94
135
  # or a dir() of the module