python-utils 2.5.6__py3-none-any.whl → 4.0.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.
@@ -1,7 +1,34 @@
1
+ """
2
+ This module provides various utility decorators for Python functions
3
+ and methods.
1
4
 
5
+ The decorators include:
2
6
 
3
- def set_attributes(**kwargs):
4
- '''Decorator to set attributes on functions and classes
7
+ 1. `set_attributes`: Sets attributes on functions and classes.
8
+ 2. `listify`: Converts any generator to a list or other collection.
9
+ 3. `sample`: Limits calls to a function based on a sample rate.
10
+ 4. `wraps_classmethod`: Wraps classmethods with type info from a
11
+ regular method.
12
+
13
+ Each decorator is designed to enhance the functionality of Python
14
+ functions and methods in a simple and reusable manner.
15
+ """
16
+
17
+ import collections.abc
18
+ import contextlib
19
+ import functools
20
+ import logging
21
+ import random
22
+ import typing
23
+
24
+ _T = typing.TypeVar('_T')
25
+ _P = typing.ParamSpec('_P')
26
+
27
+
28
+ def set_attributes(
29
+ **kwargs: typing.Any,
30
+ ) -> collections.abc.Callable[..., typing.Any]:
31
+ """Decorator to set attributes on functions and classes.
5
32
 
6
33
  A common usage for this pattern is the Django Admin where
7
34
  functions can get an optional short_description. To illustrate:
@@ -13,18 +40,201 @@ def set_attributes(**kwargs):
13
40
 
14
41
  >>> @set_attributes(short_description='Name')
15
42
  ... def upper_case_name(self, obj):
16
- ... return ("%s %s" % (obj.first_name, obj.last_name)).upper()
43
+ ... return ('%s %s' % (obj.first_name, obj.last_name)).upper()
17
44
 
18
45
  The standard Django version:
19
46
 
20
47
  >>> def upper_case_name(obj):
21
- ... return ("%s %s" % (obj.first_name, obj.last_name)).upper()
48
+ ... return ('%s %s' % (obj.first_name, obj.last_name)).upper()
22
49
 
23
50
  >>> upper_case_name.short_description = 'Name'
24
51
 
25
- '''
26
- def _set_attributes(function):
52
+ """
53
+
54
+ def _set_attributes(
55
+ function: collections.abc.Callable[_P, _T],
56
+ ) -> collections.abc.Callable[_P, _T]:
57
+ """Attach the captured ``kwargs`` as attributes on ``function``."""
27
58
  for key, value in kwargs.items():
28
59
  setattr(function, key, value)
29
60
  return function
61
+
30
62
  return _set_attributes
63
+
64
+
65
+ def listify(
66
+ collection: collections.abc.Callable[
67
+ [collections.abc.Iterable[_T]], collections.abc.Collection[_T]
68
+ ] = list,
69
+ allow_empty: bool = True,
70
+ ) -> collections.abc.Callable[
71
+ [collections.abc.Callable[..., collections.abc.Iterable[_T] | None]],
72
+ collections.abc.Callable[..., collections.abc.Collection[_T]],
73
+ ]:
74
+ """
75
+ Convert any generator to a list or other type of collection.
76
+
77
+ >>> @listify()
78
+ ... def generator():
79
+ ... yield 1
80
+ ... yield 2
81
+ ... yield 3
82
+
83
+ >>> generator()
84
+ [1, 2, 3]
85
+
86
+ >>> @listify()
87
+ ... def empty_generator():
88
+ ... pass
89
+
90
+ >>> empty_generator()
91
+ []
92
+
93
+ >>> @listify(allow_empty=False)
94
+ ... def empty_generator_not_allowed():
95
+ ... pass
96
+
97
+ >>> empty_generator_not_allowed() # doctest: +ELLIPSIS
98
+ Traceback (most recent call last):
99
+ ...
100
+ TypeError: ... `allow_empty` is `False`
101
+
102
+ >>> @listify(collection=set)
103
+ ... def set_generator():
104
+ ... yield 1
105
+ ... yield 1
106
+ ... yield 2
107
+
108
+ >>> set_generator()
109
+ {1, 2}
110
+
111
+ >>> @listify(collection=dict)
112
+ ... def dict_generator():
113
+ ... yield 'a', 1
114
+ ... yield 'b', 2
115
+
116
+ >>> dict_generator()
117
+ {'a': 1, 'b': 2}
118
+ """
119
+
120
+ def _listify(
121
+ function: collections.abc.Callable[
122
+ ..., collections.abc.Iterable[_T] | None
123
+ ],
124
+ ) -> collections.abc.Callable[..., collections.abc.Collection[_T]]:
125
+ """Materialize ``function``'s result into ``collection``."""
126
+
127
+ def __listify(
128
+ *args: typing.Any, **kwargs: typing.Any
129
+ ) -> collections.abc.Collection[_T]:
130
+ """Call ``function`` and gather its result into ``collection``."""
131
+ result: collections.abc.Iterable[_T] | None = function(
132
+ *args, **kwargs
133
+ )
134
+ if result is None:
135
+ if allow_empty:
136
+ return collection(iter(()))
137
+ else:
138
+ raise TypeError(
139
+ f'{function} returned `None` and `allow_empty` '
140
+ 'is `False`'
141
+ )
142
+ else:
143
+ return collection(result)
144
+
145
+ return __listify
146
+
147
+ return _listify
148
+
149
+
150
+ def sample(
151
+ sample_rate: float,
152
+ ) -> collections.abc.Callable[
153
+ [collections.abc.Callable[_P, _T]],
154
+ collections.abc.Callable[_P, _T | None],
155
+ ]:
156
+ """
157
+ Limit calls to a function based on given sample rate.
158
+ Number of calls to the function will be roughly equal to
159
+ sample_rate percentage.
160
+
161
+ Usage:
162
+
163
+ >>> @sample(0.5)
164
+ ... def demo_function(*args, **kwargs):
165
+ ... return 1
166
+
167
+ Calls to *demo_function* will be limited to 50% approximately.
168
+ """
169
+
170
+ def _sample(
171
+ function: collections.abc.Callable[_P, _T],
172
+ ) -> collections.abc.Callable[_P, _T | None]:
173
+ """Wrap ``function`` so it only runs on a sampled fraction of calls."""
174
+
175
+ @functools.wraps(function)
176
+ def __sample(*args: _P.args, **kwargs: _P.kwargs) -> _T | None:
177
+ """Run ``function`` with probability ``sample_rate``, else skip."""
178
+ if random.random() < sample_rate:
179
+ return function(*args, **kwargs)
180
+ else:
181
+ logging.debug(
182
+ 'Skipped execution of %r(%r, %r) due to sampling',
183
+ function,
184
+ args,
185
+ kwargs,
186
+ )
187
+ return None
188
+
189
+ return __sample
190
+
191
+ return _sample
192
+
193
+
194
+ def wraps_classmethod(
195
+ wrapped: collections.abc.Callable[typing.Concatenate[typing.Any, _P], _T],
196
+ ) -> collections.abc.Callable[
197
+ [
198
+ collections.abc.Callable[typing.Concatenate[typing.Any, _P], _T],
199
+ ],
200
+ collections.abc.Callable[typing.Concatenate[typing.Any, _P], _T],
201
+ ]:
202
+ """Like ``functools.wraps``, but for wrapping classmethods.
203
+
204
+ Copies the wrapped method's metadata (name, docstring and annotations) onto
205
+ the wrapper, so a classmethod wrapper carries the type information of the
206
+ regular method it stands in for.
207
+
208
+ Args:
209
+ wrapped: The method whose metadata should be copied onto the wrapper.
210
+
211
+ Returns:
212
+ A decorator that updates its wrapper with ``wrapped``'s metadata.
213
+ """
214
+
215
+ def _wraps_classmethod(
216
+ wrapper: collections.abc.Callable[
217
+ typing.Concatenate[typing.Any, _P], _T
218
+ ],
219
+ ) -> collections.abc.Callable[typing.Concatenate[typing.Any, _P], _T]:
220
+ """Copy ``wrapped``'s metadata onto ``wrapper`` and return it."""
221
+ # For some reason `functools.update_wrapper` fails on some test
222
+ # runs but not while running actual code
223
+ with contextlib.suppress(AttributeError):
224
+ wrapper = functools.update_wrapper(
225
+ wrapper,
226
+ wrapped,
227
+ assigned=tuple(
228
+ a
229
+ for a in functools.WRAPPER_ASSIGNMENTS
230
+ if a != '__annotations__'
231
+ ),
232
+ )
233
+ if annotations := getattr(wrapped, '__annotations__', {}):
234
+ # Drop `self`: the wrapper is a classmethod, so it takes no `self`.
235
+ annotations.pop('self', None)
236
+ wrapper.__annotations__ = annotations
237
+
238
+ return wrapper
239
+
240
+ return _wraps_classmethod
@@ -0,0 +1,47 @@
1
+ """
2
+ This module provides utility functions for raising and reraising exceptions.
3
+
4
+ Functions::
5
+
6
+ raise_exception(exception_class, *args, **kwargs):
7
+ Returns a function that raises an exception of the given type with
8
+ the given arguments.
9
+
10
+ reraise(*args, **kwargs):
11
+ Reraises the current exception.
12
+ """
13
+
14
+ import collections.abc
15
+ import typing
16
+
17
+
18
+ def raise_exception(
19
+ exception_class: type[Exception],
20
+ *args: typing.Any,
21
+ **kwargs: typing.Any,
22
+ ) -> collections.abc.Callable[..., None]:
23
+ """
24
+ Returns a function that raises an exception of the given type with the
25
+ given arguments.
26
+
27
+ >>> raise_exception(ValueError, 'spam')('eggs')
28
+ Traceback (most recent call last):
29
+ ...
30
+ ValueError: spam
31
+ """
32
+
33
+ def raise_(*args_: typing.Any, **kwargs_: typing.Any) -> typing.Any:
34
+ """Raise ``exception_class`` with the captured args."""
35
+ raise exception_class(*args, **kwargs)
36
+
37
+ return raise_
38
+
39
+
40
+ def reraise(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
41
+ """
42
+ Reraises the current exception.
43
+
44
+ This function seems useless, but it can be useful when you need to pass
45
+ a callable to another function that raises an exception.
46
+ """
47
+ raise
@@ -1,8 +1,23 @@
1
+ """
2
+ This module provides utility functions for formatting strings and dates.
3
+
4
+ Functions::
5
+
6
+ camel_to_underscore: Convert camel case naming to underscore/snake case.
7
+ apply_recursive: Apply a function to all keys in a scope recursively.
8
+ timesince: Return a string representing 'time since', e.g. 3 days ago.
9
+ """
10
+
11
+ # pyright: reportUnnecessaryIsInstance=false
12
+ import collections.abc
1
13
  import datetime
14
+ import typing
2
15
 
16
+ from python_utils import _aliases
3
17
 
4
- def camel_to_underscore(name):
5
- '''Convert camel case style naming to underscore style naming
18
+
19
+ def camel_to_underscore(name: str) -> str:
20
+ """Convert camel case style naming to underscore/snake case style naming.
6
21
 
7
22
  If there are existing underscores they will be collapsed with the
8
23
  to-be-added underscores. Multiple consecutive capital letters will not be
@@ -18,8 +33,8 @@ def camel_to_underscore(name):
18
33
  '__spam_and_bacon__'
19
34
  >>> camel_to_underscore('__SpamANDBacon__')
20
35
  '__spam_and_bacon__'
21
- '''
22
- output = []
36
+ """
37
+ output: list[str] = []
23
38
  for i, c in enumerate(name):
24
39
  if i > 0:
25
40
  pc = name[i - 1]
@@ -30,7 +45,7 @@ def camel_to_underscore(name):
30
45
  elif i > 3 and not c.isupper():
31
46
  # Will return the last 3 letters to check if we are changing
32
47
  # case
33
- previous = name[i - 3:i]
48
+ previous = name[i - 3 : i]
34
49
  if previous.isalpha() and previous.isupper():
35
50
  output.insert(len(output) - 1, '_')
36
51
 
@@ -39,8 +54,50 @@ def camel_to_underscore(name):
39
54
  return ''.join(output)
40
55
 
41
56
 
42
- def timesince(dt, default='just now'):
43
- '''
57
+ def apply_recursive(
58
+ function: collections.abc.Callable[[str], str],
59
+ data: _aliases.OptionalScope = None,
60
+ **kwargs: typing.Any,
61
+ ) -> _aliases.OptionalScope:
62
+ """
63
+ Apply a function to all keys in a scope recursively.
64
+
65
+ >>> apply_recursive(camel_to_underscore, {'SpamEggsAndBacon': 'spam'})
66
+ {'spam_eggs_and_bacon': 'spam'}
67
+ >>> apply_recursive(
68
+ ... camel_to_underscore,
69
+ ... {
70
+ ... 'SpamEggsAndBacon': {
71
+ ... 'SpamEggsAndBacon': 'spam',
72
+ ... }
73
+ ... },
74
+ ... )
75
+ {'spam_eggs_and_bacon': {'spam_eggs_and_bacon': 'spam'}}
76
+
77
+ >>> a = {'a_b_c': 123, 'def': {'DeF': 456}}
78
+ >>> b = apply_recursive(camel_to_underscore, a)
79
+ >>> b
80
+ {'a_b_c': 123, 'def': {'de_f': 456}}
81
+
82
+ >>> apply_recursive(camel_to_underscore, None)
83
+ """
84
+ if data is None:
85
+ return None
86
+
87
+ elif isinstance(data, dict):
88
+ return {
89
+ function(key): apply_recursive(function, value, **kwargs)
90
+ for key, value in data.items()
91
+ }
92
+ else:
93
+ return data
94
+
95
+
96
+ def timesince(
97
+ dt: datetime.datetime | datetime.timedelta,
98
+ default: str = 'just now',
99
+ ) -> str:
100
+ """
44
101
  Returns string representing 'time since' e.g.
45
102
  3 days ago, 5 hours ago etc.
46
103
 
@@ -81,7 +138,7 @@ def timesince(dt, default='just now'):
81
138
  '1 hour and 2 minutes ago'
82
139
  >>> timesince(datetime.timedelta(seconds=3721))
83
140
  '1 hour and 2 minutes ago'
84
- '''
141
+ """
85
142
  if isinstance(dt, datetime.timedelta):
86
143
  diff = dt
87
144
  else:
@@ -98,16 +155,15 @@ def timesince(dt, default='just now'):
98
155
  (diff.seconds % 60, 'second', 'seconds'),
99
156
  )
100
157
 
101
- output = []
158
+ output: list[str] = []
102
159
  for period, singular, plural in periods:
103
- if int(period):
104
- if int(period) == 1:
105
- output.append('%d %s' % (period, singular))
106
- else:
107
- output.append('%d %s' % (period, plural))
160
+ int_period = int(period)
161
+ if int_period == 1:
162
+ output.append(f'{int_period} {singular}')
163
+ elif int_period:
164
+ output.append(f'{int_period} {plural}')
108
165
 
109
166
  if output:
110
- return '%s ago' % ' and '.join(output[:2])
167
+ return f'{" and ".join(output[:2])} ago'
111
168
 
112
169
  return default
113
-
@@ -0,0 +1,126 @@
1
+ """
2
+ This module provides generator utilities for batching items from
3
+ iterables and async iterables.
4
+
5
+ Functions:
6
+ abatcher(generator, batch_size=None, interval=None):
7
+ Asyncio generator wrapper that returns items with a given batch
8
+ size or interval (whichever is reached first).
9
+
10
+ batcher(iterable, batch_size=10):
11
+ Generator wrapper that returns items with a given batch size.
12
+ """
13
+
14
+ import asyncio
15
+ import collections.abc
16
+ import time
17
+ import typing
18
+
19
+ import python_utils
20
+ from python_utils import _aliases
21
+
22
+ #: Element type of the iterables being batched.
23
+ _T = typing.TypeVar('_T')
24
+
25
+
26
+ async def abatcher(
27
+ generator: collections.abc.AsyncGenerator[_T, None]
28
+ | collections.abc.AsyncIterator[_T],
29
+ batch_size: int | None = None,
30
+ interval: _aliases.delta_type | None = None,
31
+ ) -> collections.abc.AsyncGenerator[list[_T], None]:
32
+ """
33
+ Asyncio generator wrapper that returns items with a given batch size or
34
+ interval (whichever is reached first).
35
+
36
+ Args:
37
+ generator: The async generator or iterator to batch.
38
+ batch_size (typing.Optional[int], optional): The number of items per
39
+ batch. Defaults to None.
40
+ interval (typing.Optional[_aliases.delta_type], optional): The time
41
+ interval to wait before yielding a batch. Defaults to None.
42
+
43
+ Yields:
44
+ collections.abc.AsyncGenerator[list[_T], None]: A generator that yields
45
+ batches of items.
46
+ """
47
+ batch: list[_T] = []
48
+
49
+ assert batch_size or interval, 'Must specify either batch_size or interval'
50
+
51
+ # If interval is specified, use it to determine when to yield the batch
52
+ # Alternatively set a really long timeout to keep the code simpler
53
+ if interval:
54
+ interval_s = python_utils.delta_to_seconds(interval)
55
+ else:
56
+ # Set the timeout to 10 years
57
+ interval_s = 60 * 60 * 24 * 365 * 10.0
58
+
59
+ next_yield: float = time.perf_counter() + interval_s
60
+
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
+ )
79
+
80
+ if done:
81
+ batch.extend(result.result() for result in done)
82
+
83
+ except StopAsyncIteration:
84
+ if batch:
85
+ yield batch
86
+
87
+ break
88
+
89
+ if batch_size is not None and len(batch) == batch_size:
90
+ yield batch
91
+ batch = []
92
+
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
100
+
101
+
102
+ def batcher(
103
+ iterable: collections.abc.Iterable[_T],
104
+ batch_size: int = 10,
105
+ ) -> collections.abc.Generator[list[_T], None, None]:
106
+ """
107
+ Generator wrapper that returns items with a given batch size.
108
+
109
+ Args:
110
+ iterable (collections.abc.Iterable[_T]): The iterable to batch.
111
+ batch_size (int, optional): The number of items per batch. Defaults
112
+ to 10.
113
+
114
+ Yields:
115
+ collections.abc.Generator[list[_T], None, None]: A generator that
116
+ yields batches of items.
117
+ """
118
+ batch: list[_T] = []
119
+ for item in iterable:
120
+ batch.append(item)
121
+ if len(batch) == batch_size:
122
+ yield batch
123
+ batch = []
124
+
125
+ if batch:
126
+ yield batch
python_utils/import_.py CHANGED
@@ -1,32 +1,62 @@
1
+ """
2
+ This module provides utilities for importing modules and handling exceptions.
1
3
 
2
- class DummyException(Exception):
3
- pass
4
+ Classes:
5
+ DummyError(Exception):
6
+ A custom exception class used as a default for exception handling.
4
7
 
5
-
6
- def import_global(
7
- name, modules=None, exceptions=DummyException, locals_=None,
8
+ Functions:
9
+ import_global(name, modules=None, exceptions=DummyError, locals_=None,
8
10
  globals_=None, level=-1):
9
- '''Import the requested items into the global scope
11
+ Imports the requested items into the global scope, with support for
12
+ relative imports and custom exception handling.
13
+ """
14
+
15
+ import typing
16
+
17
+ from python_utils import _aliases
18
+
19
+
20
+ class DummyError(Exception):
21
+ """A custom exception class used as a default for exception handling."""
22
+
23
+
24
+ #: Backwards-compatible legacy alias for ``DummyError``.
25
+ DummyException = DummyError
26
+
27
+
28
+ def import_global( # noqa: C901
29
+ name: str,
30
+ modules: list[str] | None = None,
31
+ exceptions: _aliases.ExceptionsType = DummyError,
32
+ locals_: _aliases.OptionalScope = None,
33
+ globals_: _aliases.OptionalScope = None,
34
+ level: int = -1,
35
+ ) -> typing.Any: # sourcery skip: hoist-if-from-if
36
+ """Import the requested items into the global scope.
10
37
 
11
38
  WARNING! this method _will_ overwrite your global scope
12
- If you have a variable named "path" and you call import_global('sys')
13
- it will be overwritten with sys.path
39
+ If you have a variable named `path` and you call `import_global('sys')`
40
+ it will be overwritten with `sys.path`
14
41
 
15
42
  Args:
16
43
  name (str): the name of the module to import, e.g. sys
17
44
  modules (str): the modules to import, use None for everything
18
- exception (Exception): the exception to catch, e.g. ImportError
19
- `locals_`: the `locals()` method (in case you need a different scope)
20
- `globals_`: the `globals()` method (in case you need a different scope)
45
+ exceptions (Exception): the exception to catch, e.g. ImportError
46
+ locals_: the `locals()` method (in case you need a different scope)
47
+ globals_: the `globals()` method (in case you need a different scope)
21
48
  level (int): the level to import from, this can be used for
22
49
  relative imports
23
- '''
50
+ """
24
51
  frame = None
52
+ name_parts: list[str] = name.split('.')
53
+ modules_set: set[str] = set()
25
54
  try:
26
55
  # If locals_ or globals_ are not given, autodetect them by inspecting
27
56
  # the current stack
28
57
  if locals_ is None or globals_ is None:
29
58
  import inspect
59
+
30
60
  frame = inspect.stack()[1][0]
31
61
 
32
62
  if locals_ is None:
@@ -36,44 +66,52 @@ def import_global(
36
66
  globals_ = frame.f_globals
37
67
 
38
68
  try:
39
- name = name.split('.')
40
-
41
69
  # Relative imports are supported (from .spam import eggs)
42
- if not name[0]:
43
- name = name[1:]
70
+ if not name_parts[0]:
71
+ name_parts = name_parts[1:]
44
72
  level = 1
45
73
 
46
74
  # raise IOError((name, level))
47
75
  module = __import__(
48
- name=name[0] or '.',
76
+ name=name_parts[0] or '.',
49
77
  globals=globals_,
50
78
  locals=locals_,
51
- fromlist=name[1:],
79
+ fromlist=name_parts[1:],
52
80
  level=max(level, 0),
53
81
  )
54
82
 
55
83
  # Make sure we get the right part of a dotted import (i.e.
56
84
  # spam.eggs should return eggs, not spam)
57
85
  try:
58
- for attr in name[1:]:
86
+ for attr in name_parts[1:]:
59
87
  module = getattr(module, attr)
60
- except AttributeError:
61
- raise ImportError('No module named ' + '.'.join(name))
88
+ except AttributeError as e:
89
+ raise ImportError(
90
+ 'No module named ' + '.'.join(name_parts)
91
+ ) from e
62
92
 
63
93
  # If no list of modules is given, autodetect from either __all__
64
94
  # or a dir() of the module
65
95
  if not modules:
66
- modules = getattr(module, '__all__', dir(module))
96
+ modules_set = set(getattr(module, '__all__', dir(module)))
67
97
  else:
68
- modules = set(modules).intersection(dir(module))
98
+ modules_set = set(modules).intersection(dir(module))
69
99
 
70
100
  # Add all items in modules to the global scope
71
- for k in set(dir(module)).intersection(modules):
101
+ for k in set(dir(module)).intersection(modules_set):
72
102
  if k and k[0] != '_':
73
103
  globals_[k] = getattr(module, k)
74
104
  except exceptions as e:
75
105
  return e
76
106
  finally:
77
107
  # Clean up, just to be sure
78
- del name, modules, exceptions, locals_, globals_, frame
79
-
108
+ del ( # pyrefly: ignore[unsupported-delete]
109
+ name,
110
+ name_parts,
111
+ modules,
112
+ modules_set,
113
+ exceptions,
114
+ locals_,
115
+ globals_,
116
+ frame,
117
+ )