ddutils 0.2.0__tar.gz → 0.2.1__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.1
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)
@@ -321,9 +339,8 @@ A registry that keeps one object instance per scope and manages its lifetime.
321
339
 
322
340
  ### ScopedRegistry
323
341
 
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).
342
+ Keeps one object per scope. `scope_func` decides what the current scope is and returns its key; `None` means
343
+ "no current scope" and makes every method raise `RuntimeError`.
327
344
 
328
345
  `scope()` is the entry point into a scope: it creates the object on enter and removes it on exit, calling
329
346
  the optional destructor exactly once. Nested `scope()` blocks join the existing object and leave it alone.
@@ -331,7 +348,6 @@ Concurrent first accesses with the same key call `create_func` only once.
331
348
 
332
349
  ```python
333
350
  import asyncio
334
- from contextvars import ContextVar
335
351
 
336
352
  from ddutils.scoped_registry import ScopedRegistry
337
353
 
@@ -348,32 +364,53 @@ async def handle_request():
348
364
  await session.execute('select ...')
349
365
  async with session_registry.scope() as same_session: # nested block joins
350
366
  ...
367
+ ```
368
+
369
+ Lower-level methods are available when a context manager does not fit:
370
+
371
+ - `get()` returns the current object or `None`;
372
+ - `set(**kwargs)` returns the existing object or creates it. Whoever calls it takes over the cleanup that
373
+ `scope()` does on exit and must call `clear()` when the object is no longer needed, if it ever is: a
374
+ per-process client may live until the process ends;
375
+ - `clear()` removes the object of the current scope and calls the destructor.
376
+
377
+ #### Scopes kept in a ContextVar
378
+
379
+ A `scope_func` that declares a `token` parameter receives a freshly generated token when `scope()` is entered
380
+ and `None` on every other access. This is for a scope of one execution (an HTTP request, an actor run) rather
381
+ than one task: the function stores the token in a `ContextVar`, so coroutines spawned inside the block
382
+ (`gather`, `TaskGroup`, `create_task`) inherit it and share the object with the parent, while outside of
383
+ `scope()` there is no key and every method raises.
384
+
385
+ ```python
386
+ from contextvars import ContextVar
387
+
388
+ execution_key: ContextVar[str | None] = ContextVar('execution_key', default=None)
389
+
390
+
391
+ def execution_scope(token: str | None) -> str | None:
392
+ key = execution_key.get()
393
+ if key is None and token is not None: # entering scope() outside of an execution: start one
394
+ execution_key.set(token)
395
+ key = token
396
+ return key
351
397
 
352
398
 
353
399
  async def create_log_properties(**kwargs): ...
354
400
 
355
401
 
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)
402
+ log_registry = ScopedRegistry(create_func=create_log_properties, scope_func=execution_scope)
359
403
 
360
404
 
361
405
  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)
406
+ async with log_registry.scope(headers=headers) as log_properties:
407
+ await asyncio.gather(work(), work()) # both see the same log_properties via log_registry.get()
368
408
  ```
369
409
 
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`.
410
+ Reads never start a scope: a `get()` from a long-living context (a startup log in the server's main task)
411
+ that planted a key would be inherited by every request task, making all requests share one object. For the
412
+ same reason the key is not reset when the block ends, so `scope()` must be entered in a task that does not
413
+ outlive the block (a request handler, an actor run).
377
414
 
378
415
  ## Function Exception Extraction
379
416
 
@@ -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)
@@ -333,9 +321,8 @@ A registry that keeps one object instance per scope and manages its lifetime.
333
321
 
334
322
  ### ScopedRegistry
335
323
 
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).
324
+ Keeps one object per scope. `scope_func` decides what the current scope is and returns its key; `None` means
325
+ "no current scope" and makes every method raise `RuntimeError`.
339
326
 
340
327
  `scope()` is the entry point into a scope: it creates the object on enter and removes it on exit, calling
341
328
  the optional destructor exactly once. Nested `scope()` blocks join the existing object and leave it alone.
@@ -343,7 +330,6 @@ Concurrent first accesses with the same key call `create_func` only once.
343
330
 
344
331
  ```python
345
332
  import asyncio
346
- from contextvars import ContextVar
347
333
 
348
334
  from ddutils.scoped_registry import ScopedRegistry
349
335
 
@@ -360,32 +346,53 @@ async def handle_request():
360
346
  await session.execute('select ...')
361
347
  async with session_registry.scope() as same_session: # nested block joins
362
348
  ...
349
+ ```
350
+
351
+ Lower-level methods are available when a context manager does not fit:
352
+
353
+ - `get()` returns the current object or `None`;
354
+ - `set(**kwargs)` returns the existing object or creates it. Whoever calls it takes over the cleanup that
355
+ `scope()` does on exit and must call `clear()` when the object is no longer needed, if it ever is: a
356
+ per-process client may live until the process ends;
357
+ - `clear()` removes the object of the current scope and calls the destructor.
358
+
359
+ #### Scopes kept in a ContextVar
360
+
361
+ A `scope_func` that declares a `token` parameter receives a freshly generated token when `scope()` is entered
362
+ and `None` on every other access. This is for a scope of one execution (an HTTP request, an actor run) rather
363
+ than one task: the function stores the token in a `ContextVar`, so coroutines spawned inside the block
364
+ (`gather`, `TaskGroup`, `create_task`) inherit it and share the object with the parent, while outside of
365
+ `scope()` there is no key and every method raises.
366
+
367
+ ```python
368
+ from contextvars import ContextVar
369
+
370
+ execution_key: ContextVar[str | None] = ContextVar('execution_key', default=None)
371
+
372
+
373
+ def execution_scope(token: str | None) -> str | None:
374
+ key = execution_key.get()
375
+ if key is None and token is not None: # entering scope() outside of an execution: start one
376
+ execution_key.set(token)
377
+ key = token
378
+ return key
363
379
 
364
380
 
365
381
  async def create_log_properties(**kwargs): ...
366
382
 
367
383
 
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)
384
+ log_registry = ScopedRegistry(create_func=create_log_properties, scope_func=execution_scope)
371
385
 
372
386
 
373
387
  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)
388
+ async with log_registry.scope(headers=headers) as log_properties:
389
+ await asyncio.gather(work(), work()) # both see the same log_properties via log_registry.get()
380
390
  ```
381
391
 
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`.
392
+ Reads never start a scope: a `get()` from a long-living context (a startup log in the server's main task)
393
+ that planted a key would be inherited by every request task, making all requests share one object. For the
394
+ same reason the key is not reset when the block ends, so `scope()` must be entered in a task that does not
395
+ outlive the block (a request handler, an actor run).
389
396
 
390
397
  ## Function Exception Extraction
391
398
 
@@ -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.1"
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.1"
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