ddutils 0.1.6__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,20 +1,18 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ddutils
3
- Version: 0.1.6
3
+ Version: 0.2.1
4
4
  Summary: Domain Driven Utils Library
5
- License: MIT
6
- License-File: LICENSE
7
5
  Keywords: python,ddutils
8
6
  Author: davyddd
9
- Requires-Python: >=3.9,<3.15
10
- Classifier: License :: OSI Approved :: MIT License
7
+ License-Expression: MIT
8
+ License-File: LICENSE
11
9
  Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.9
13
- Classifier: Programming Language :: Python :: 3.10
14
10
  Classifier: Programming Language :: Python :: 3.11
15
11
  Classifier: Programming Language :: Python :: 3.12
16
12
  Classifier: Programming Language :: Python :: 3.13
17
13
  Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.11, <3.15
18
16
  Project-URL: Repository, https://github.com/davyddd/ddutils
19
17
  Description-Content-Type: text/markdown
20
18
 
@@ -36,6 +34,21 @@ Install the library using pip:
36
34
  pip install ddutils
37
35
  ```
38
36
 
37
+ ## Development
38
+
39
+ The project runs entirely in Docker. Requires Docker and [Fabric](https://www.fabfile.org/) on the host:
40
+
41
+ ```bash
42
+ fab build # build the dev image
43
+ fab tests # run pytest
44
+ fab linters # run ruff (with --fix), ty and complexipy
45
+ fab shell # IPython inside the container
46
+ fab bash # bash inside the container
47
+ ```
48
+
49
+ This project was generated from [dd-lib-stub](https://github.com/davyddd/dd-lib-stub);
50
+ run `uvx copier update` to pull in template updates.
51
+
39
52
  ## Type Annotation Helpers
40
53
 
41
54
  Utilities for inspecting and working with Python type hints and generic types.
@@ -322,33 +335,83 @@ json_loads = get_object_by_path('json.loads')
322
335
 
323
336
  ## Scoped Registry
324
337
 
325
- A registry that manages object instances within defined scopes.
338
+ A registry that keeps one object instance per scope and manages its lifetime.
326
339
 
327
340
  ### ScopedRegistry
328
341
 
329
- Manages object instances within scopes (like per request or per session).
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`.
344
+
345
+ `scope()` is the entry point into a scope: it creates the object on enter and removes it on exit, calling
346
+ the optional destructor exactly once. Nested `scope()` blocks join the existing object and leave it alone.
347
+ Concurrent first accesses with the same key call `create_func` only once.
330
348
 
331
349
  ```python
350
+ import asyncio
351
+
332
352
  from ddutils.scoped_registry import ScopedRegistry
333
353
 
334
354
 
335
- def get_current_request_id(): ...
355
+ async def create_session(): ...
336
356
 
337
357
 
338
- def create_database_connection(): ...
358
+ # One session per task: a child task spawned with gather/create_task gets its own session
359
+ session_registry = ScopedRegistry(create_func=create_session, scope_func=asyncio.current_task, destructor_method_name='close')
339
360
 
340
361
 
341
- db_registry = ScopedRegistry(
342
- create_func=create_database_connection,
343
- scope_func=get_current_request_id,
344
- )
362
+ async def handle_request():
363
+ async with session_registry.scope() as session: # created here, closed and removed on exit
364
+ await session.execute('select ...')
365
+ async with session_registry.scope() as same_session: # nested block joins
366
+ ...
367
+ ```
345
368
 
369
+ Lower-level methods are available when a context manager does not fit:
346
370
 
347
- async def handle_request():
348
- db = await db_registry() # Returns existing or creates new db connection
349
- await db.execute('select ...')
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
397
+
398
+
399
+ async def create_log_properties(**kwargs): ...
400
+
401
+
402
+ log_registry = ScopedRegistry(create_func=create_log_properties, scope_func=execution_scope)
403
+
404
+
405
+ async def middleware(headers):
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()
350
408
  ```
351
409
 
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).
414
+
352
415
  ## Function Exception Extraction
353
416
 
354
417
  AST-based introspection for extracting exceptions from function source code.
@@ -376,4 +439,3 @@ get_safe_element(items, 0) # 1
376
439
  get_safe_element(items, 10) # None (no IndexError)
377
440
  get_safe_element(items, -1) # 3
378
441
  ```
379
-
@@ -16,6 +16,21 @@ Install the library using pip:
16
16
  pip install ddutils
17
17
  ```
18
18
 
19
+ ## Development
20
+
21
+ The project runs entirely in Docker. Requires Docker and [Fabric](https://www.fabfile.org/) on the host:
22
+
23
+ ```bash
24
+ fab build # build the dev image
25
+ fab tests # run pytest
26
+ fab linters # run ruff (with --fix), ty and complexipy
27
+ fab shell # IPython inside the container
28
+ fab bash # bash inside the container
29
+ ```
30
+
31
+ This project was generated from [dd-lib-stub](https://github.com/davyddd/dd-lib-stub);
32
+ run `uvx copier update` to pull in template updates.
33
+
19
34
  ## Type Annotation Helpers
20
35
 
21
36
  Utilities for inspecting and working with Python type hints and generic types.
@@ -302,33 +317,83 @@ json_loads = get_object_by_path('json.loads')
302
317
 
303
318
  ## Scoped Registry
304
319
 
305
- A registry that manages object instances within defined scopes.
320
+ A registry that keeps one object instance per scope and manages its lifetime.
306
321
 
307
322
  ### ScopedRegistry
308
323
 
309
- Manages object instances within scopes (like per request or per session).
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`.
326
+
327
+ `scope()` is the entry point into a scope: it creates the object on enter and removes it on exit, calling
328
+ the optional destructor exactly once. Nested `scope()` blocks join the existing object and leave it alone.
329
+ Concurrent first accesses with the same key call `create_func` only once.
310
330
 
311
331
  ```python
332
+ import asyncio
333
+
312
334
  from ddutils.scoped_registry import ScopedRegistry
313
335
 
314
336
 
315
- def get_current_request_id(): ...
337
+ async def create_session(): ...
338
+
339
+
340
+ # One session per task: a child task spawned with gather/create_task gets its own session
341
+ session_registry = ScopedRegistry(create_func=create_session, scope_func=asyncio.current_task, destructor_method_name='close')
316
342
 
317
343
 
318
- def create_database_connection(): ...
344
+ async def handle_request():
345
+ async with session_registry.scope() as session: # created here, closed and removed on exit
346
+ await session.execute('select ...')
347
+ async with session_registry.scope() as same_session: # nested block joins
348
+ ...
349
+ ```
319
350
 
351
+ Lower-level methods are available when a context manager does not fit:
320
352
 
321
- db_registry = ScopedRegistry(
322
- create_func=create_database_connection,
323
- scope_func=get_current_request_id,
324
- )
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.
325
358
 
359
+ #### Scopes kept in a ContextVar
326
360
 
327
- async def handle_request():
328
- db = await db_registry() # Returns existing or creates new db connection
329
- await db.execute('select ...')
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
379
+
380
+
381
+ async def create_log_properties(**kwargs): ...
382
+
383
+
384
+ log_registry = ScopedRegistry(create_func=create_log_properties, scope_func=execution_scope)
385
+
386
+
387
+ async def middleware(headers):
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()
330
390
  ```
331
391
 
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).
396
+
332
397
  ## Function Exception Extraction
333
398
 
334
399
  AST-based introspection for extracting exceptions from function source code.
@@ -1,14 +1,7 @@
1
1
  import inspect
2
- import sys
3
2
  from collections.abc import Sequence
4
- from typing import Any, Tuple, Union, get_args
5
-
6
- if sys.version_info >= (3, 10):
7
- from types import UnionType
8
- else:
9
-
10
- class UnionType: ...
11
-
3
+ from types import UnionType
4
+ from typing import Any, Union, get_args
12
5
 
13
6
  NON_COMPLEX_SEQUENCE_TYPES = (str, bytes, bytearray)
14
7
 
@@ -57,7 +50,7 @@ def get_complex_sequence_element_annotation(annotation: Any) -> Any:
57
50
  return element_annotation
58
51
 
59
52
 
60
- def get_dict_items_annotation(annotation: Any) -> Tuple[Any, Any]:
53
+ def get_dict_items_annotation(annotation: Any) -> tuple[Any, Any]:
61
54
  if hasattr(annotation, '__supertype__'):
62
55
  return get_dict_items_annotation(annotation.__supertype__)
63
56
  elif not is_subclass(annotation, dict):
@@ -1,10 +1,11 @@
1
1
  import inspect
2
- from typing import Any, Callable, Optional, Type, TypeVar
2
+ from collections.abc import Callable
3
+ from typing import Any, TypeVar
3
4
 
4
5
  _T = TypeVar('_T', bound=Any)
5
6
 
6
7
 
7
- def get_origin_class_of_method(cls: Any, method_name: str) -> Optional[Type]:
8
+ def get_origin_class_of_method(cls: Any, method_name: str) -> type | None:
8
9
  """
9
10
  Find the class in the inheritance hierarchy where a method is originally defined.
10
11
 
@@ -50,9 +51,9 @@ class classproperty: # noqa: N801
50
51
  """
51
52
  self.fget = fget
52
53
  self.__doc__ = fget.__doc__
53
- self.__name__ = fget.__name__
54
+ self.__name__ = getattr(fget, '__name__', type(fget).__name__)
54
55
 
55
- def __get__(self, instance: Any, owner: Type[Any]) -> Any:
56
+ def __get__(self, instance: Any, owner: type[Any]) -> Any:
56
57
  """
57
58
  Retrieve the property value using the descriptor protocol.
58
59
 
@@ -0,0 +1,5 @@
1
+ from datetime import UTC, datetime
2
+
3
+
4
+ def utc_now() -> datetime:
5
+ return datetime.now(tz=UTC)
@@ -2,9 +2,9 @@ import ast
2
2
  import builtins
3
3
  import inspect
4
4
  import textwrap
5
- from collections.abc import Generator
5
+ from collections.abc import Callable, Generator
6
6
  from importlib import import_module
7
- from typing import Any, Callable, Dict, NamedTuple, Optional, Tuple, Type
7
+ from typing import Any, NamedTuple
8
8
 
9
9
  from ddutils.module_getter import get_module
10
10
 
@@ -20,16 +20,16 @@ class Annotation(NamedTuple):
20
20
 
21
21
 
22
22
  class ExceptionInfo:
23
- exception_class: Type[Exception]
24
- args: Tuple[Any, ...]
25
- kwargs: Dict[str, Any]
23
+ exception_class: type[Exception]
24
+ args: tuple[Any, ...]
25
+ kwargs: dict[str, Any]
26
26
 
27
- def __init__(self, exception_class: Type[Exception], args: Tuple[Any, ...], kwargs: Dict[str, Any]):
27
+ def __init__(self, exception_class: type[Exception], args: tuple[Any, ...], kwargs: dict[str, Any]):
28
28
  self.exception_class = exception_class
29
29
  self.args = tuple(_v.value if isinstance(_v, ast.Constant) else UNDEFINED_VALUE for _v in args)
30
30
  self.kwargs = {_k: _v.value if isinstance(_v, ast.Constant) else UNDEFINED_VALUE for _k, _v in kwargs.items()}
31
31
 
32
- def get_kwargs(self) -> Dict[str, Any]:
32
+ def get_kwargs(self) -> dict[str, Any]:
33
33
  annotations = tuple(
34
34
  Annotation(argument, argument_info.default)
35
35
  for argument, argument_info in inspect.signature(self.exception_class.__init__).parameters.items()
@@ -52,7 +52,7 @@ class ExceptionInfo:
52
52
 
53
53
  return kwargs
54
54
 
55
- def get_exception_instance(self, dry_run: bool = True) -> Optional[Exception]:
55
+ def get_exception_instance(self, dry_run: bool = True) -> Exception | None:
56
56
  kwargs = {k: f'<{k}>' if v is UNDEFINED_VALUE else v for k, v in self.get_kwargs().items()}
57
57
  try:
58
58
  # There might be issues with strict typing because UNDEFINED_VALUE is always replaced with a string
@@ -75,37 +75,44 @@ def _get_node_name(node: ast.AST) -> str:
75
75
  raise TypeError(f'Unsupported node type: {type(node)}')
76
76
 
77
77
 
78
- def extract_function_exceptions(func: Callable) -> Generator[ExceptionInfo, None, None]:
79
- source = textwrap.dedent(inspect.getsource(func))
80
- tree = ast.parse(source)
78
+ def _resolve_exception_class(func: Callable, exception_name: str) -> type[Exception] | None:
79
+ *sub_modules, class_name = exception_name.split('.')
80
+ exception_module = get_module(import_module(func.__module__), sub_modules)
81
+
82
+ exception_class: type[Exception] | None = getattr(exception_module, class_name, None)
83
+ if exception_class is None and hasattr(builtins, exception_name):
84
+ exception_class = getattr(builtins, exception_name)
85
+
86
+ return exception_class
81
87
 
82
- for node in ast.walk(tree):
83
- if isinstance(node, ast.Raise):
84
- if node.exc is None:
85
- continue
86
88
 
87
- try:
88
- exception_name: str = _get_node_name(node.exc)
89
- exception_args: Tuple[Any, ...] = ()
90
- exception_kwargs: Dict[str, Any] = {}
91
- except TypeError:
92
- continue
89
+ def _extract_raise_exception(func: Callable, node: ast.Raise) -> ExceptionInfo | None:
90
+ if node.exc is None:
91
+ return None
93
92
 
94
- if isinstance(node.exc, ast.Call):
95
- exception_args = tuple(node.exc.args)
96
- exception_kwargs = {kw.arg: kw.value for kw in node.exc.keywords if isinstance(kw.arg, str)}
93
+ try:
94
+ exception_name = _get_node_name(node.exc)
95
+ except TypeError:
96
+ return None
97
97
 
98
- exception_name_slices = exception_name.split('.')
99
- class_name = exception_name_slices[-1]
100
- sub_modules = exception_name_slices[:-1]
98
+ exception_args: tuple[Any, ...] = ()
99
+ exception_kwargs: dict[str, Any] = {}
100
+ if isinstance(node.exc, ast.Call):
101
+ exception_args = tuple(node.exc.args)
102
+ exception_kwargs = {kw.arg: kw.value for kw in node.exc.keywords if isinstance(kw.arg, str)}
101
103
 
102
- exception_module = get_module(import_module(func.__module__), sub_modules)
104
+ exception_class = _resolve_exception_class(func, exception_name)
105
+ if exception_class is None:
106
+ return None
103
107
 
104
- exception_class: Optional[Type[Exception]] = getattr(exception_module, class_name, None)
105
- if exception_class is None and hasattr(builtins, exception_name):
106
- exception_class = getattr(builtins, exception_name)
108
+ return ExceptionInfo(exception_class=exception_class, args=exception_args, kwargs=exception_kwargs)
107
109
 
108
- if exception_class is None:
109
- continue
110
110
 
111
- yield ExceptionInfo(exception_class=exception_class, args=exception_args, kwargs=exception_kwargs)
111
+ def extract_function_exceptions(func: Callable) -> Generator[ExceptionInfo, None, None]:
112
+ source = textwrap.dedent(inspect.getsource(func))
113
+
114
+ for node in ast.walk(ast.parse(source)):
115
+ if isinstance(node, ast.Raise):
116
+ exception_info = _extract_raise_exception(func, node)
117
+ if exception_info is not None:
118
+ yield exception_info
@@ -0,0 +1,33 @@
1
+ from types import CellType, CodeType, FunctionType
2
+
3
+
4
+ def create_new_function(base_func: FunctionType, new_name: str | None = None) -> FunctionType:
5
+ base_code = base_func.__code__
6
+ func_name = new_name or base_code.co_name
7
+
8
+ new_code = CodeType(
9
+ base_code.co_argcount,
10
+ base_code.co_posonlyargcount,
11
+ base_code.co_kwonlyargcount,
12
+ base_code.co_nlocals,
13
+ base_code.co_stacksize,
14
+ base_code.co_flags,
15
+ base_code.co_code,
16
+ base_code.co_consts,
17
+ base_code.co_names,
18
+ base_code.co_varnames,
19
+ base_code.co_filename,
20
+ func_name,
21
+ func_name,
22
+ base_code.co_firstlineno,
23
+ base_code.co_linetable,
24
+ base_code.co_exceptiontable,
25
+ base_code.co_freevars,
26
+ base_code.co_cellvars,
27
+ )
28
+
29
+ closure = base_func.__closure__
30
+ if closure:
31
+ closure = tuple(CellType(cell.cell_contents) for cell in closure)
32
+
33
+ return FunctionType(new_code, base_func.__globals__, func_name, base_func.__defaults__, closure)
@@ -1,8 +1,7 @@
1
1
  from types import ModuleType
2
- from typing import List
3
2
 
4
3
 
5
- def get_module(module: ModuleType, sub_modules: List[str]) -> ModuleType:
4
+ def get_module(module: ModuleType, sub_modules: list[str]) -> ModuleType:
6
5
  if not module:
7
6
  raise ValueError('Argument `module` is required')
8
7
  elif not isinstance(sub_modules, list):
@@ -1,8 +1,8 @@
1
1
  import importlib
2
- from typing import Any, Optional
2
+ from typing import Any
3
3
 
4
4
 
5
- def get_object_by_path(path: Optional[str]) -> Any:
5
+ def get_object_by_path(path: str | None) -> Any:
6
6
  try:
7
7
  if not isinstance(path, str):
8
8
  raise ValueError
@@ -1,15 +1,16 @@
1
1
  import logging
2
+ from collections.abc import Callable
2
3
  from functools import wraps
3
- from typing import Any, Callable, Optional, Tuple, Type
4
+ from typing import Any
4
5
 
5
6
  logger = logging.getLogger(__name__)
6
7
 
7
8
 
8
9
  def safe_call(
9
- func: Optional[Callable] = None,
10
+ func: Callable | None = None,
10
11
  capture_exception: bool = True,
11
- default_result: Optional[Any] = None,
12
- exceptions: Tuple[Type[Exception], ...] = (Exception,),
12
+ default_result: Any | None = None,
13
+ exceptions: tuple[type[Exception], ...] = (Exception,),
13
14
  ) -> Callable:
14
15
  if func is None:
15
16
  return lambda _func: safe_call(
@@ -32,7 +33,7 @@ def safe_call(
32
33
 
33
34
 
34
35
  def retry_once_after_exception(
35
- func: Optional[Callable] = None, capture_exception: bool = True, exceptions: Tuple[Type[Exception], ...] = (Exception,)
36
+ func: Callable | None = None, capture_exception: bool = True, exceptions: tuple[type[Exception], ...] = (Exception,)
36
37
  ) -> Callable:
37
38
  if func is None:
38
39
  return lambda _func: retry_once_after_exception(func=_func, capture_exception=capture_exception, exceptions=exceptions)
@@ -0,0 +1,127 @@
1
+ import asyncio
2
+ import inspect
3
+ from collections.abc import AsyncIterator, Awaitable, Callable, Hashable
4
+ from contextlib import asynccontextmanager
5
+ from typing import Any, Generic, TypeVar, get_args
6
+ from uuid import uuid4
7
+
8
+ _T = TypeVar('_T')
9
+ _CreateFuncType = Callable[..., _T] | Callable[..., Awaitable[_T]]
10
+ _ScopeKey = Hashable
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'
15
+
16
+
17
+ class ScopedRegistry(Generic[_T]):
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
+ """
42
+
43
+ __slots__ = ('create_func', 'scope_func', 'registry', 'destructor_method_name', '_lock', '__orig_class__')
44
+
45
+ def __init__(
46
+ self,
47
+ create_func: _CreateFuncType,
48
+ scope_func: _ScopeFuncType,
49
+ destructor_method_name: str | None = None,
50
+ ) -> None:
51
+ self.create_func = create_func
52
+ self.scope_func = scope_func
53
+ self.registry: dict[_ScopeKey, _T] = {}
54
+ self.destructor_method_name = destructor_method_name
55
+ self._lock = asyncio.Lock()
56
+
57
+ @property
58
+ def generic_type(self) -> type[_T]:
59
+ orig_class = getattr(self, '__orig_class__', None)
60
+ try:
61
+ return get_args(orig_class)[0]
62
+ except IndexError as e:
63
+ raise TypeError('Cannot determine generic type parameter') from e
64
+
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):
76
+ raise RuntimeError(f'scope_func must return a hashable scope key, got {key!r}')
77
+ return key
78
+
79
+ def get(self) -> _T | None:
80
+ return self.registry.get(self._get_scope_key())
81
+
82
+ async def _set(self, **kwargs: Any) -> tuple[_T, bool]:
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)
85
+ if key in self.registry:
86
+ return self.registry[key], False
87
+
88
+ async with self._lock:
89
+ if key in self.registry:
90
+ return self.registry[key], False
91
+ result = self.create_func(**kwargs)
92
+ self.registry[key] = await result if inspect.isawaitable(result) else result
93
+ return self.registry[key], True
94
+
95
+ async def set(self, **kwargs: Any) -> _T:
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
+ """
103
+ value, _ = await self._set(**kwargs)
104
+ return value
105
+
106
+ async def clear(self) -> None:
107
+ """Removes the value of the current scope and calls its destructor."""
108
+ instance = self.registry.pop(self._get_scope_key(), None)
109
+ destructor = getattr(instance, self.destructor_method_name or '', None)
110
+ if destructor is not None:
111
+ result = destructor()
112
+ if inspect.isawaitable(result):
113
+ await result
114
+
115
+ @asynccontextmanager
116
+ async def scope(self, **kwargs: Any) -> AsyncIterator[_T]:
117
+ """
118
+ Enters the current scope: joins the existing value or creates one. Only the block that created the
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.
121
+ """
122
+ value, created = await self._set(**kwargs)
123
+ try:
124
+ yield value
125
+ finally:
126
+ if created:
127
+ await self.clear()
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "ddutils"
3
+ description = "Domain Driven Utils Library"
4
+ version = "0.2.1"
5
+ license = "MIT"
6
+ license-files = ["LICENSE"]
7
+ readme = "README.md"
8
+ requires-python = ">=3.11,<3.15"
9
+ keywords = [
10
+ "python",
11
+ "ddutils",
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
+ ]
21
+ dependencies = []
22
+
23
+ [[project.authors]]
24
+ name = "davyddd"
25
+
26
+ [project.urls]
27
+ Repository = "https://github.com/davyddd/ddutils"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "ipdb==0.13.13",
32
+ "ipython==9.16.1",
33
+ ]
34
+ test = [
35
+ "pytest==9.0.3",
36
+ "pytest-cov==5.0.0",
37
+ "parameterized==0.9.0",
38
+ ]
39
+ linter = [
40
+ "ruff==0.15.10",
41
+ "ty==0.0.15",
42
+ "complexipy==8.0.1",
43
+ ]
44
+
45
+ [tool.uv.build-backend]
46
+ module-root = ""
47
+
48
+ [build-system]
49
+ requires = ["uv_build>=0.12.0,<0.13.0"]
50
+ build-backend = "uv_build"
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "ddutils"
3
+ description = "Domain Driven Utils Library"
4
+ version = "0.2.1"
5
+ authors = [{ name = "davyddd" }]
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ readme = "README.md"
9
+ requires-python = ">=3.11,<3.15"
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
+ ]
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/davyddd/ddutils"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "ipdb==0.13.13",
28
+ "ipython==9.16.1",
29
+ ]
30
+ test = [
31
+ "pytest==9.0.3",
32
+ "pytest-cov==5.0.0",
33
+ "parameterized==0.9.0",
34
+ ]
35
+ linter = [
36
+ "ruff==0.15.10",
37
+ "ty==0.0.15",
38
+ "complexipy==8.0.1",
39
+ ]
40
+
41
+ [tool.uv.build-backend]
42
+ module-root = ""
43
+
44
+ [build-system]
45
+ requires = ["uv_build>=0.12.0,<0.13.0"]
46
+ build-backend = "uv_build"
@@ -1,13 +0,0 @@
1
- import sys
2
- from datetime import datetime
3
-
4
- if sys.version_info >= (3, 11):
5
- from datetime import UTC
6
- else:
7
- from datetime import timezone
8
-
9
- UTC = timezone.utc
10
-
11
-
12
- def utc_now() -> datetime:
13
- return datetime.now(tz=UTC)
@@ -1,57 +0,0 @@
1
- import sys
2
- from types import CellType, CodeType, FunctionType
3
- from typing import Callable, Optional
4
-
5
-
6
- def create_new_function(base_func: Callable, new_name: Optional[str] = None) -> Callable:
7
- base_code = base_func.__code__
8
- func_name = new_name or base_code.co_name
9
-
10
- if sys.version_info >= (3, 11):
11
- # for python 3.11 and newer
12
- new_code = CodeType(
13
- base_code.co_argcount,
14
- base_code.co_posonlyargcount,
15
- base_code.co_kwonlyargcount,
16
- base_code.co_nlocals,
17
- base_code.co_stacksize,
18
- base_code.co_flags,
19
- base_code.co_code,
20
- base_code.co_consts,
21
- base_code.co_names,
22
- base_code.co_varnames,
23
- base_code.co_filename,
24
- func_name,
25
- func_name,
26
- base_code.co_firstlineno,
27
- base_code.co_lnotab,
28
- base_code.co_exceptiontable,
29
- base_code.co_freevars,
30
- base_code.co_cellvars,
31
- )
32
- else:
33
- # for python 3.10 and older
34
- new_code = CodeType(
35
- base_code.co_argcount,
36
- base_code.co_posonlyargcount,
37
- base_code.co_kwonlyargcount,
38
- base_code.co_nlocals,
39
- base_code.co_stacksize,
40
- base_code.co_flags,
41
- base_code.co_code,
42
- base_code.co_consts,
43
- base_code.co_names,
44
- base_code.co_varnames,
45
- base_code.co_filename,
46
- func_name,
47
- base_code.co_firstlineno,
48
- base_code.co_lnotab,
49
- base_code.co_freevars,
50
- base_code.co_cellvars,
51
- )
52
-
53
- closure = base_func.__closure__
54
- if closure:
55
- closure = tuple(CellType(cell.cell_contents) for cell in closure)
56
-
57
- return FunctionType(new_code, base_func.__globals__, func_name, base_func.__defaults__, closure)
@@ -1,91 +0,0 @@
1
- import inspect
2
- from collections.abc import Awaitable, Hashable
3
- from typing import Any, Callable, Dict, Generic, Optional, Type, TypeVar, Union, get_args
4
-
5
- _T = TypeVar('_T', bound=Any)
6
- _CreateFuncType = Union[Callable[..., _T], Callable[..., Awaitable[_T]]]
7
- _ScopeType = Hashable
8
- _ScopeFuncType = Callable[[], _ScopeType]
9
- _RegistryType = Dict[_ScopeType, _T]
10
-
11
-
12
- class ScopedRegistry(Generic[_T]):
13
- __slots__ = 'create_func', 'scope_func', 'registry', 'destructor_method_name', '__orig_class__'
14
-
15
- create_func: _CreateFuncType
16
- scope_func: _ScopeFuncType
17
- registry: _RegistryType
18
- destructor_method_name: Optional[str]
19
-
20
- def __init__(self, create_func: _CreateFuncType, scope_func: _ScopeFuncType, destructor_method_name: Optional[str] = None):
21
- self.create_func = create_func
22
- self.scope_func = scope_func
23
- self.registry = {}
24
- self.destructor_method_name = destructor_method_name
25
-
26
- @property
27
- def generic_type(self) -> Type[_T]:
28
- orig_class = getattr(self, '__orig_class__', None)
29
- try:
30
- return get_args(orig_class)[0]
31
- except IndexError as e:
32
- raise TypeError('Cannot determine generic type parameter') from e
33
-
34
- def get(self) -> Optional[_T]:
35
- try:
36
- key = self.scope_func()
37
- return self.registry[key]
38
- except Exception: # noqa: BLE001
39
- return None
40
-
41
- def _set(self, **kwargs: Any) -> tuple[_ScopeType, Union[_T, Awaitable[_T]]]:
42
- key = self.scope_func()
43
- if key in self.registry:
44
- return key, self.registry[key]
45
- return key, self.create_func(**kwargs)
46
-
47
- def sync_set(self, **kwargs: Any) -> _T:
48
- key, result = self._set(**kwargs)
49
- if inspect.isawaitable(result):
50
- raise TypeError('create_func returned an awaitable; use async_set instead')
51
- self.registry[key] = result
52
- return result
53
-
54
- async def async_set(self, **kwargs: Any) -> _T:
55
- key, result = self._set(**kwargs)
56
- self.registry[key] = await result if inspect.isawaitable(result) else result
57
- return self.registry[key]
58
-
59
- def _clear(self, *scopes: _ScopeType):
60
- if not scopes and (scope := self.scope_func()):
61
- scopes = (scope,)
62
-
63
- if not (self.registry and scopes):
64
- return
65
-
66
- for scope in scopes:
67
- if scope in self.registry:
68
- instance = self.registry.pop(scope)
69
-
70
- if isinstance(self.destructor_method_name, str):
71
- destructor_method = getattr(instance, self.destructor_method_name, None)
72
- if destructor_method:
73
- yield destructor_method()
74
-
75
- def sync_clear(self, *scopes: _ScopeType) -> None:
76
- for result in self._clear(*scopes):
77
- if inspect.isawaitable(result):
78
- raise TypeError('destructor returned an awaitable; use async clear instead')
79
-
80
- async def async_clear(self, *scopes: _ScopeType) -> None:
81
- for result in self._clear(*scopes):
82
- if inspect.isawaitable(result):
83
- await result
84
-
85
- # deprecated: use async_set / async_clear instead
86
-
87
- async def __call__(self, **kwargs: Any) -> _T:
88
- return await self.async_set(**kwargs)
89
-
90
- async def clear(self, *scopes: _ScopeType) -> None:
91
- await self.async_clear(*scopes)
@@ -1,31 +0,0 @@
1
- [tool.poetry]
2
- name = "ddutils"
3
- description = "Domain Driven Utils Library"
4
- version = "0.1.6"
5
- authors = ["davyddd"]
6
- license = "MIT"
7
- repository = "https://github.com/davyddd/ddutils"
8
- readme = "README.md"
9
- keywords = ["python", "ddutils"]
10
- packages = [{include = "ddutils"}]
11
-
12
- [tool.poetry.dependencies]
13
- python = ">=3.9,<3.15"
14
-
15
- [tool.poetry.group.dev.dependencies]
16
- ipdb = "0.13.9"
17
- ipython = "8.12.3"
18
-
19
- [tool.poetry.group.test.dependencies]
20
- pytest = "8.1.1"
21
- pytest-cov = "5.0.0"
22
- parameterized = "0.9.0"
23
- typing-extensions = "^4.12.2"
24
-
25
- [tool.poetry.group.linter.dependencies]
26
- mypy = "1.18.2"
27
- ruff = "0.14.4"
28
-
29
- [build-system]
30
- requires = ["poetry-core>=1.0.0"]
31
- build-backend = "poetry.core.masonry.api"
File without changes
File without changes
File without changes
File without changes