ddutils 0.2.0__tar.gz → 0.2.2__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.
@@ -1,3 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: ddutils
3
+ Version: 0.2.2
4
+ Summary: Domain Driven Utils Library
5
+ Keywords: python,ddutils
6
+ Author: davyddd
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.11, <3.15
16
+ Project-URL: Repository, https://github.com/davyddd/ddutils
17
+ Description-Content-Type: text/markdown
18
+
1
19
  # DDUtils
2
20
 
3
21
  [![pypi](https://img.shields.io/pypi/v/ddutils.svg)](https://pypi.python.org/pypi/ddutils)
@@ -173,6 +191,31 @@ get_origin_class_of_method(C, 'method') # <class 'C'>
173
191
  get_origin_class_of_method(B, 'method') # <class 'A'>
174
192
  ```
175
193
 
194
+ ### get_generic_base_argument
195
+
196
+ Returns the type argument a class bound to a generic base when subclassing it. `None` when the base was not
197
+ subscripted, the `TypeVar` itself when the subclass is still generic.
198
+
199
+ ```python
200
+ from typing import Generic, TypeVar
201
+
202
+ from ddutils.class_helpers import get_generic_base_argument
203
+
204
+ T = TypeVar('T')
205
+
206
+
207
+ class Repository(Generic[T]):
208
+ pass
209
+
210
+
211
+ class UserRepository(Repository[User]):
212
+ pass
213
+
214
+
215
+ get_generic_base_argument(UserRepository, Repository) # <class 'User'>
216
+ get_generic_base_argument(Repository, Repository) # None
217
+ ```
218
+
176
219
  ## Function Helpers
177
220
 
178
221
  Tools for creating and modifying Python functions at runtime.
@@ -321,9 +364,8 @@ A registry that keeps one object instance per scope and manages its lifetime.
321
364
 
322
365
  ### ScopedRegistry
323
366
 
324
- Keeps one object per scope. The scope key comes from `scope_func`, for example `asyncio.current_task`
325
- (one object per task), `os.getpid` (one per process) or a `ContextVar` getter (one per request, inherited
326
- by the tasks spawned inside it).
367
+ Keeps one object per scope. `scope_func` decides what the current scope is and returns its key; `None` means
368
+ "no current scope" and makes every method raise `RuntimeError`.
327
369
 
328
370
  `scope()` is the entry point into a scope: it creates the object on enter and removes it on exit, calling
329
371
  the optional destructor exactly once. Nested `scope()` blocks join the existing object and leave it alone.
@@ -331,7 +373,6 @@ Concurrent first accesses with the same key call `create_func` only once.
331
373
 
332
374
  ```python
333
375
  import asyncio
334
- from contextvars import ContextVar
335
376
 
336
377
  from ddutils.scoped_registry import ScopedRegistry
337
378
 
@@ -348,32 +389,53 @@ async def handle_request():
348
389
  await session.execute('select ...')
349
390
  async with session_registry.scope() as same_session: # nested block joins
350
391
  ...
392
+ ```
393
+
394
+ Lower-level methods are available when a context manager does not fit:
395
+
396
+ - `get()` returns the current object or `None`;
397
+ - `set(**kwargs)` returns the existing object or creates it. Whoever calls it takes over the cleanup that
398
+ `scope()` does on exit and must call `clear()` when the object is no longer needed, if it ever is: a
399
+ per-process client may live until the process ends;
400
+ - `clear()` removes the object of the current scope and calls the destructor.
401
+
402
+ #### Scopes kept in a ContextVar
403
+
404
+ A `scope_func` that declares a `token` parameter receives a freshly generated token when `scope()` is entered
405
+ and `None` on every other access. This is for a scope of one execution (an HTTP request, an actor run) rather
406
+ than one task: the function stores the token in a `ContextVar`, so coroutines spawned inside the block
407
+ (`gather`, `TaskGroup`, `create_task`) inherit it and share the object with the parent, while outside of
408
+ `scope()` there is no key and every method raises.
409
+
410
+ ```python
411
+ from contextvars import ContextVar
412
+
413
+ execution_key: ContextVar[str | None] = ContextVar('execution_key', default=None)
414
+
415
+
416
+ def execution_scope(token: str | None) -> str | None:
417
+ key = execution_key.get()
418
+ if key is None and token is not None: # entering scope() outside of an execution: start one
419
+ execution_key.set(token)
420
+ key = token
421
+ return key
351
422
 
352
423
 
353
424
  async def create_log_properties(**kwargs): ...
354
425
 
355
426
 
356
- # One object per request: the key lives in a ContextVar set by the middleware, child tasks inherit it
357
- request_key: ContextVar[object | None] = ContextVar('request_key', default=None)
358
- log_registry = ScopedRegistry(create_func=create_log_properties, scope_func=request_key.get)
427
+ log_registry = ScopedRegistry(create_func=create_log_properties, scope_func=execution_scope)
359
428
 
360
429
 
361
430
  async def middleware(headers):
362
- token = request_key.set(object())
363
- try:
364
- async with log_registry.scope(headers=headers) as log_properties:
365
- await asyncio.gather(work(), work()) # both see the same log_properties via log_registry.get()
366
- finally:
367
- request_key.reset(token)
431
+ async with log_registry.scope(headers=headers) as log_properties:
432
+ await asyncio.gather(work(), work()) # both see the same log_properties via log_registry.get()
368
433
  ```
369
434
 
370
- Lower-level methods are available when a context manager does not fit:
371
-
372
- - `get()` returns the current object or `None`;
373
- - `set(**kwargs)` returns the existing object or creates it;
374
- - `clear()` removes the object of the current scope and calls the destructor.
375
-
376
- `scope_func` must return a hashable key; if it returns `None` or an unhashable value, every method raises `RuntimeError`.
435
+ Reads never start a scope: a `get()` from a long-living context (a startup log in the server's main task)
436
+ that planted a key would be inherited by every request task, making all requests share one object. For the
437
+ same reason the key is not reset when the block ends, so `scope()` must be entered in a task that does not
438
+ outlive the block (a request handler, an actor run).
377
439
 
378
440
  ## Function Exception Extraction
379
441
 
@@ -1,15 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: ddutils
3
- Version: 0.2.0
4
- Summary: Domain Driven Utils Library
5
- Keywords: python,ddutils
6
- Author: davyddd
7
- License-Expression: MIT
8
- License-File: LICENSE
9
- Requires-Python: >=3.11, <3.15
10
- Project-URL: Repository, https://github.com/davyddd/ddutils
11
- Description-Content-Type: text/markdown
12
-
13
1
  # DDUtils
14
2
 
15
3
  [![pypi](https://img.shields.io/pypi/v/ddutils.svg)](https://pypi.python.org/pypi/ddutils)
@@ -185,6 +173,31 @@ get_origin_class_of_method(C, 'method') # <class 'C'>
185
173
  get_origin_class_of_method(B, 'method') # <class 'A'>
186
174
  ```
187
175
 
176
+ ### get_generic_base_argument
177
+
178
+ Returns the type argument a class bound to a generic base when subclassing it. `None` when the base was not
179
+ subscripted, the `TypeVar` itself when the subclass is still generic.
180
+
181
+ ```python
182
+ from typing import Generic, TypeVar
183
+
184
+ from ddutils.class_helpers import get_generic_base_argument
185
+
186
+ T = TypeVar('T')
187
+
188
+
189
+ class Repository(Generic[T]):
190
+ pass
191
+
192
+
193
+ class UserRepository(Repository[User]):
194
+ pass
195
+
196
+
197
+ get_generic_base_argument(UserRepository, Repository) # <class 'User'>
198
+ get_generic_base_argument(Repository, Repository) # None
199
+ ```
200
+
188
201
  ## Function Helpers
189
202
 
190
203
  Tools for creating and modifying Python functions at runtime.
@@ -333,9 +346,8 @@ A registry that keeps one object instance per scope and manages its lifetime.
333
346
 
334
347
  ### ScopedRegistry
335
348
 
336
- Keeps one object per scope. The scope key comes from `scope_func`, for example `asyncio.current_task`
337
- (one object per task), `os.getpid` (one per process) or a `ContextVar` getter (one per request, inherited
338
- by the tasks spawned inside it).
349
+ Keeps one object per scope. `scope_func` decides what the current scope is and returns its key; `None` means
350
+ "no current scope" and makes every method raise `RuntimeError`.
339
351
 
340
352
  `scope()` is the entry point into a scope: it creates the object on enter and removes it on exit, calling
341
353
  the optional destructor exactly once. Nested `scope()` blocks join the existing object and leave it alone.
@@ -343,7 +355,6 @@ Concurrent first accesses with the same key call `create_func` only once.
343
355
 
344
356
  ```python
345
357
  import asyncio
346
- from contextvars import ContextVar
347
358
 
348
359
  from ddutils.scoped_registry import ScopedRegistry
349
360
 
@@ -360,32 +371,53 @@ async def handle_request():
360
371
  await session.execute('select ...')
361
372
  async with session_registry.scope() as same_session: # nested block joins
362
373
  ...
374
+ ```
375
+
376
+ Lower-level methods are available when a context manager does not fit:
377
+
378
+ - `get()` returns the current object or `None`;
379
+ - `set(**kwargs)` returns the existing object or creates it. Whoever calls it takes over the cleanup that
380
+ `scope()` does on exit and must call `clear()` when the object is no longer needed, if it ever is: a
381
+ per-process client may live until the process ends;
382
+ - `clear()` removes the object of the current scope and calls the destructor.
383
+
384
+ #### Scopes kept in a ContextVar
385
+
386
+ A `scope_func` that declares a `token` parameter receives a freshly generated token when `scope()` is entered
387
+ and `None` on every other access. This is for a scope of one execution (an HTTP request, an actor run) rather
388
+ than one task: the function stores the token in a `ContextVar`, so coroutines spawned inside the block
389
+ (`gather`, `TaskGroup`, `create_task`) inherit it and share the object with the parent, while outside of
390
+ `scope()` there is no key and every method raises.
391
+
392
+ ```python
393
+ from contextvars import ContextVar
394
+
395
+ execution_key: ContextVar[str | None] = ContextVar('execution_key', default=None)
396
+
397
+
398
+ def execution_scope(token: str | None) -> str | None:
399
+ key = execution_key.get()
400
+ if key is None and token is not None: # entering scope() outside of an execution: start one
401
+ execution_key.set(token)
402
+ key = token
403
+ return key
363
404
 
364
405
 
365
406
  async def create_log_properties(**kwargs): ...
366
407
 
367
408
 
368
- # One object per request: the key lives in a ContextVar set by the middleware, child tasks inherit it
369
- request_key: ContextVar[object | None] = ContextVar('request_key', default=None)
370
- log_registry = ScopedRegistry(create_func=create_log_properties, scope_func=request_key.get)
409
+ log_registry = ScopedRegistry(create_func=create_log_properties, scope_func=execution_scope)
371
410
 
372
411
 
373
412
  async def middleware(headers):
374
- token = request_key.set(object())
375
- try:
376
- async with log_registry.scope(headers=headers) as log_properties:
377
- await asyncio.gather(work(), work()) # both see the same log_properties via log_registry.get()
378
- finally:
379
- request_key.reset(token)
413
+ async with log_registry.scope(headers=headers) as log_properties:
414
+ await asyncio.gather(work(), work()) # both see the same log_properties via log_registry.get()
380
415
  ```
381
416
 
382
- Lower-level methods are available when a context manager does not fit:
383
-
384
- - `get()` returns the current object or `None`;
385
- - `set(**kwargs)` returns the existing object or creates it;
386
- - `clear()` removes the object of the current scope and calls the destructor.
387
-
388
- `scope_func` must return a hashable key; if it returns `None` or an unhashable value, every method raises `RuntimeError`.
417
+ Reads never start a scope: a `get()` from a long-living context (a startup log in the server's main task)
418
+ that planted a key would be inherited by every request task, making all requests share one object. For the
419
+ same reason the key is not reset when the block ends, so `scope()` must be entered in a task that does not
420
+ outlive the block (a request handler, an actor run).
389
421
 
390
422
  ## Function Exception Extraction
391
423
 
@@ -1,6 +1,6 @@
1
1
  import inspect
2
2
  from collections.abc import Callable
3
- from typing import Any, TypeVar
3
+ from typing import Any, TypeVar, get_args, get_origin
4
4
 
5
5
  _T = TypeVar('_T', bound=Any)
6
6
 
@@ -25,6 +25,26 @@ def get_origin_class_of_method(cls: Any, method_name: str) -> type | None:
25
25
  return None
26
26
 
27
27
 
28
+ def get_generic_base_argument(cls: type, generic_base: type, position: int = 0) -> Any | None:
29
+ """
30
+ Return the type argument that `cls` bound to `generic_base` when subclassing it.
31
+
32
+ For `class ProfileCache(GenericCache[Profile])` this is `Profile`. Returns `None` when `cls` does not
33
+ subscript `generic_base` (`class Cache(GenericCache)`), and a `TypeVar` when the subclass is still
34
+ generic (`class Base[T](GenericCache[T])`), so callers can reject both cases in `__init_subclass__`.
35
+
36
+ Args:
37
+ cls: The class to inspect.
38
+ generic_base: The generic base class whose argument to look up.
39
+ position: Index of the argument for bases with several type parameters.
40
+ """
41
+ for base in getattr(cls, '__orig_bases__', ()):
42
+ if get_origin(base) is generic_base:
43
+ arguments = get_args(base)
44
+ return arguments[position] if position < len(arguments) else None
45
+ return None
46
+
47
+
28
48
  class classproperty: # noqa: N801
29
49
  """
30
50
  A descriptor that defines a read-only property at the class level.
@@ -3,15 +3,42 @@ import inspect
3
3
  from collections.abc import AsyncIterator, Awaitable, Callable, Hashable
4
4
  from contextlib import asynccontextmanager
5
5
  from typing import Any, Generic, TypeVar, get_args
6
+ from uuid import uuid4
6
7
 
7
8
  _T = TypeVar('_T')
8
9
  _CreateFuncType = Callable[..., _T] | Callable[..., Awaitable[_T]]
9
10
  _ScopeKey = Hashable
10
- _ScopeFuncType = Callable[[], _ScopeKey]
11
+ _ScopeFuncType = Callable[..., _ScopeKey | None]
12
+ # A `scope_func` that declares this parameter receives a freshly generated key when `scope()` is entered
13
+ # and `None` on every other access; functions without it are called with no arguments.
14
+ SCOPE_TOKEN_PARAMETER = 'token'
11
15
 
12
16
 
13
17
  class ScopedRegistry(Generic[_T]):
14
- """One value per scope; the scope key comes from `scope_func` (e.g. `asyncio.current_task`, `os.getpid`)."""
18
+ """
19
+ One value per scope.
20
+
21
+ `scope_func` decides what the current scope is and returns its key; `None` means "no current scope" and
22
+ makes the registry raise `RuntimeError`. Plain key functions take no arguments:
23
+
24
+ asyncio.current_task # one value per task, a child task does not see the parent's value
25
+ os.getpid # one value per process
26
+
27
+ A `scope_func` that declares a `token` parameter is meant for scopes kept in a `ContextVar`: on entering
28
+ `scope()` (or on `set()`) it receives a freshly generated token that it may store as the key of a new scope,
29
+ and on every other access (`get`, `clear`, a nested `scope()`) it receives `None` and must only read:
30
+
31
+ def execution_scope(token): # one value per execution (HTTP request, actor run),
32
+ key = execution_key.get() # inherited by the tasks spawned inside the block
33
+ if key is None and token is not None:
34
+ execution_key.set(token)
35
+ key = token
36
+ return key
37
+
38
+ A scope started this way is not reset when the block ends, so `scope()` must be entered in a task that does
39
+ not outlive the block (a request handler, an actor run); a key left in a long-living task would be
40
+ inherited by every task spawned from it afterwards.
41
+ """
15
42
 
16
43
  __slots__ = ('create_func', 'scope_func', 'registry', 'destructor_method_name', '_lock', '__orig_class__')
17
44
 
@@ -35,9 +62,17 @@ class ScopedRegistry(Generic[_T]):
35
62
  except IndexError as e:
36
63
  raise TypeError('Cannot determine generic type parameter') from e
37
64
 
38
- def _get_scope_key(self) -> _ScopeKey:
39
- key = self.scope_func()
40
- if key is None or not isinstance(key, Hashable):
65
+ def _get_scope_func_kwargs(self, token: str | None) -> dict[str, Any]:
66
+ kwargs = {}
67
+ if SCOPE_TOKEN_PARAMETER in inspect.signature(self.scope_func).parameters:
68
+ kwargs['token'] = token
69
+ return kwargs
70
+
71
+ def _get_scope_key(self, token: str | None = None) -> _ScopeKey:
72
+ key = self.scope_func(**self._get_scope_func_kwargs(token))
73
+ if key is None:
74
+ raise RuntimeError('There is no current scope')
75
+ if not isinstance(key, Hashable):
41
76
  raise RuntimeError(f'scope_func must return a hashable scope key, got {key!r}')
42
77
  return key
43
78
 
@@ -45,8 +80,8 @@ class ScopedRegistry(Generic[_T]):
45
80
  return self.registry.get(self._get_scope_key())
46
81
 
47
82
  async def _set(self, **kwargs: Any) -> tuple[_T, bool]:
48
- """Returns the value of the current scope and whether this call created it."""
49
- key = self._get_scope_key()
83
+ """Returns the value of the current scope and whether this call created it; may start a new scope."""
84
+ key = self._get_scope_key(uuid4().hex)
50
85
  if key in self.registry:
51
86
  return self.registry[key], False
52
87
 
@@ -58,7 +93,13 @@ class ScopedRegistry(Generic[_T]):
58
93
  return self.registry[key], True
59
94
 
60
95
  async def set(self, **kwargs: Any) -> _T:
61
- """Returns the value of the current scope, creating it with `create_func(**kwargs)` if needed."""
96
+ """
97
+ Returns the value of the current scope, creating it with `create_func(**kwargs)` if needed.
98
+
99
+ Prefer `scope()`: it removes what it created on exit. Whoever calls `set()` directly takes over that
100
+ responsibility and must call `clear()` when the value is no longer needed (if it ever is: a per-process
101
+ client may live until the process ends). A `scope_func` with a `token` parameter may start a new scope here.
102
+ """
62
103
  value, _ = await self._set(**kwargs)
63
104
  return value
64
105
 
@@ -76,6 +117,7 @@ class ScopedRegistry(Generic[_T]):
76
117
  """
77
118
  Enters the current scope: joins the existing value or creates one. Only the block that created the
78
119
  value removes it (and calls the destructor) on exit; nested blocks join and leave it alone.
120
+ A `scope_func` with a `token` parameter is offered a fresh token here and may start a new scope with it.
79
121
  """
80
122
  value, created = await self._set(**kwargs)
81
123
  try:
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "ddutils"
3
3
  description = "Domain Driven Utils Library"
4
- version = "0.2.0"
4
+ version = "0.2.2"
5
5
  license = "MIT"
6
6
  license-files = ["LICENSE"]
7
7
  readme = "README.md"
@@ -10,6 +10,14 @@ keywords = [
10
10
  "python",
11
11
  "ddutils",
12
12
  ]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Typing :: Typed",
20
+ ]
13
21
  dependencies = []
14
22
 
15
23
  [[project.authors]]
@@ -1,13 +1,22 @@
1
1
  [project]
2
2
  name = "ddutils"
3
3
  description = "Domain Driven Utils Library"
4
- version = "0.2.0"
4
+ version = "0.2.2"
5
5
  authors = [{ name = "davyddd" }]
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.11,<3.15"
10
10
  keywords = ["python", "ddutils"]
11
+ # uv_build does not derive these from requires-python (poetry did); PyPI and the pyversions badge read them
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Typing :: Typed",
19
+ ]
11
20
  dependencies = []
12
21
 
13
22
  [project.urls]
File without changes
File without changes
File without changes
File without changes