ddutils 0.1.5__tar.gz → 0.2.0__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.
- {ddutils-0.1.5 → ddutils-0.2.0}/PKG-INFO +61 -24
- {ddutils-0.1.5 → ddutils-0.2.0}/README.md +57 -11
- {ddutils-0.1.5 → ddutils-0.2.0}/ddutils/annotation_helpers.py +3 -10
- {ddutils-0.1.5 → ddutils-0.2.0}/ddutils/class_helpers.py +5 -4
- ddutils-0.2.0/ddutils/datetime_helpers.py +5 -0
- {ddutils-0.1.5 → ddutils-0.2.0}/ddutils/function_exceptions_extractor.py +41 -34
- ddutils-0.2.0/ddutils/function_helpers.py +33 -0
- {ddutils-0.1.5 → ddutils-0.2.0}/ddutils/module_getter.py +1 -2
- {ddutils-0.1.5 → ddutils-0.2.0}/ddutils/object_getter.py +2 -2
- {ddutils-0.1.5 → ddutils-0.2.0}/ddutils/safe_decorators.py +6 -5
- ddutils-0.2.0/ddutils/scoped_registry.py +85 -0
- ddutils-0.2.0/pyproject.toml +42 -0
- ddutils-0.2.0/pyproject.toml.orig +37 -0
- ddutils-0.1.5/ddutils/datetime_helpers.py +0 -13
- ddutils-0.1.5/ddutils/function_helpers.py +0 -57
- ddutils-0.1.5/ddutils/scoped_registry.py +0 -83
- ddutils-0.1.5/pyproject.toml +0 -31
- {ddutils-0.1.5 → ddutils-0.2.0}/LICENSE +0 -0
- {ddutils-0.1.5 → ddutils-0.2.0}/ddutils/__init__.py +0 -0
- {ddutils-0.1.5 → ddutils-0.2.0}/ddutils/convertors.py +0 -0
- {ddutils-0.1.5 → ddutils-0.2.0}/ddutils/py.typed +0 -0
- {ddutils-0.1.5 → ddutils-0.2.0}/ddutils/sequence_helpers.py +0 -0
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ddutils
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Domain Driven Utils Library
|
|
5
|
-
License: MIT
|
|
6
|
-
License-File: LICENSE
|
|
7
5
|
Keywords: python,ddutils
|
|
8
6
|
Author: davyddd
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.11, <3.15
|
|
18
10
|
Project-URL: Repository, https://github.com/davyddd/ddutils
|
|
19
11
|
Description-Content-Type: text/markdown
|
|
20
12
|
|
|
@@ -36,6 +28,21 @@ Install the library using pip:
|
|
|
36
28
|
pip install ddutils
|
|
37
29
|
```
|
|
38
30
|
|
|
31
|
+
## Development
|
|
32
|
+
|
|
33
|
+
The project runs entirely in Docker. Requires Docker and [Fabric](https://www.fabfile.org/) on the host:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
fab build # build the dev image
|
|
37
|
+
fab tests # run pytest
|
|
38
|
+
fab linters # run ruff (with --fix), ty and complexipy
|
|
39
|
+
fab shell # IPython inside the container
|
|
40
|
+
fab bash # bash inside the container
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
This project was generated from [dd-lib-stub](https://github.com/davyddd/dd-lib-stub);
|
|
44
|
+
run `uvx copier update` to pull in template updates.
|
|
45
|
+
|
|
39
46
|
## Type Annotation Helpers
|
|
40
47
|
|
|
41
48
|
Utilities for inspecting and working with Python type hints and generic types.
|
|
@@ -322,33 +329,64 @@ json_loads = get_object_by_path('json.loads')
|
|
|
322
329
|
|
|
323
330
|
## Scoped Registry
|
|
324
331
|
|
|
325
|
-
A registry that
|
|
332
|
+
A registry that keeps one object instance per scope and manages its lifetime.
|
|
326
333
|
|
|
327
334
|
### ScopedRegistry
|
|
328
335
|
|
|
329
|
-
|
|
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).
|
|
339
|
+
|
|
340
|
+
`scope()` is the entry point into a scope: it creates the object on enter and removes it on exit, calling
|
|
341
|
+
the optional destructor exactly once. Nested `scope()` blocks join the existing object and leave it alone.
|
|
342
|
+
Concurrent first accesses with the same key call `create_func` only once.
|
|
330
343
|
|
|
331
344
|
```python
|
|
345
|
+
import asyncio
|
|
346
|
+
from contextvars import ContextVar
|
|
347
|
+
|
|
332
348
|
from ddutils.scoped_registry import ScopedRegistry
|
|
333
349
|
|
|
334
350
|
|
|
335
|
-
def
|
|
351
|
+
async def create_session(): ...
|
|
336
352
|
|
|
337
353
|
|
|
338
|
-
|
|
354
|
+
# One session per task: a child task spawned with gather/create_task gets its own session
|
|
355
|
+
session_registry = ScopedRegistry(create_func=create_session, scope_func=asyncio.current_task, destructor_method_name='close')
|
|
339
356
|
|
|
340
357
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
)
|
|
358
|
+
async def handle_request():
|
|
359
|
+
async with session_registry.scope() as session: # created here, closed and removed on exit
|
|
360
|
+
await session.execute('select ...')
|
|
361
|
+
async with session_registry.scope() as same_session: # nested block joins
|
|
362
|
+
...
|
|
345
363
|
|
|
346
364
|
|
|
347
|
-
async def
|
|
348
|
-
|
|
349
|
-
|
|
365
|
+
async def create_log_properties(**kwargs): ...
|
|
366
|
+
|
|
367
|
+
|
|
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)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
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)
|
|
350
380
|
```
|
|
351
381
|
|
|
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`.
|
|
389
|
+
|
|
352
390
|
## Function Exception Extraction
|
|
353
391
|
|
|
354
392
|
AST-based introspection for extracting exceptions from function source code.
|
|
@@ -376,4 +414,3 @@ get_safe_element(items, 0) # 1
|
|
|
376
414
|
get_safe_element(items, 10) # None (no IndexError)
|
|
377
415
|
get_safe_element(items, -1) # 3
|
|
378
416
|
```
|
|
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,64 @@ json_loads = get_object_by_path('json.loads')
|
|
|
302
317
|
|
|
303
318
|
## Scoped Registry
|
|
304
319
|
|
|
305
|
-
A registry that
|
|
320
|
+
A registry that keeps one object instance per scope and manages its lifetime.
|
|
306
321
|
|
|
307
322
|
### ScopedRegistry
|
|
308
323
|
|
|
309
|
-
|
|
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).
|
|
327
|
+
|
|
328
|
+
`scope()` is the entry point into a scope: it creates the object on enter and removes it on exit, calling
|
|
329
|
+
the optional destructor exactly once. Nested `scope()` blocks join the existing object and leave it alone.
|
|
330
|
+
Concurrent first accesses with the same key call `create_func` only once.
|
|
310
331
|
|
|
311
332
|
```python
|
|
333
|
+
import asyncio
|
|
334
|
+
from contextvars import ContextVar
|
|
335
|
+
|
|
312
336
|
from ddutils.scoped_registry import ScopedRegistry
|
|
313
337
|
|
|
314
338
|
|
|
315
|
-
def
|
|
339
|
+
async def create_session(): ...
|
|
316
340
|
|
|
317
341
|
|
|
318
|
-
|
|
342
|
+
# One session per task: a child task spawned with gather/create_task gets its own session
|
|
343
|
+
session_registry = ScopedRegistry(create_func=create_session, scope_func=asyncio.current_task, destructor_method_name='close')
|
|
319
344
|
|
|
320
345
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
)
|
|
346
|
+
async def handle_request():
|
|
347
|
+
async with session_registry.scope() as session: # created here, closed and removed on exit
|
|
348
|
+
await session.execute('select ...')
|
|
349
|
+
async with session_registry.scope() as same_session: # nested block joins
|
|
350
|
+
...
|
|
325
351
|
|
|
326
352
|
|
|
327
|
-
async def
|
|
328
|
-
|
|
329
|
-
|
|
353
|
+
async def create_log_properties(**kwargs): ...
|
|
354
|
+
|
|
355
|
+
|
|
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)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
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)
|
|
330
368
|
```
|
|
331
369
|
|
|
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`.
|
|
377
|
+
|
|
332
378
|
## Function Exception Extraction
|
|
333
379
|
|
|
334
380
|
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
|
|
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) ->
|
|
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
|
|
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) ->
|
|
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:
|
|
56
|
+
def __get__(self, instance: Any, owner: type[Any]) -> Any:
|
|
56
57
|
"""
|
|
57
58
|
Retrieve the property value using the descriptor protocol.
|
|
58
59
|
|
|
@@ -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,
|
|
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:
|
|
24
|
-
args:
|
|
25
|
-
kwargs:
|
|
23
|
+
exception_class: type[Exception]
|
|
24
|
+
args: tuple[Any, ...]
|
|
25
|
+
kwargs: dict[str, Any]
|
|
26
26
|
|
|
27
|
-
def __init__(self, exception_class:
|
|
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) ->
|
|
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) ->
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
try:
|
|
94
|
+
exception_name = _get_node_name(node.exc)
|
|
95
|
+
except TypeError:
|
|
96
|
+
return None
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
+
exception_class = _resolve_exception_class(func, exception_name)
|
|
105
|
+
if exception_class is None:
|
|
106
|
+
return None
|
|
103
107
|
|
|
104
|
-
|
|
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
|
-
|
|
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:
|
|
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,15 +1,16 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
from collections.abc import Callable
|
|
2
3
|
from functools import wraps
|
|
3
|
-
from typing import Any
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
logger = logging.getLogger(__name__)
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
def safe_call(
|
|
9
|
-
func:
|
|
10
|
+
func: Callable | None = None,
|
|
10
11
|
capture_exception: bool = True,
|
|
11
|
-
default_result:
|
|
12
|
-
exceptions:
|
|
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:
|
|
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,85 @@
|
|
|
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
|
+
|
|
7
|
+
_T = TypeVar('_T')
|
|
8
|
+
_CreateFuncType = Callable[..., _T] | Callable[..., Awaitable[_T]]
|
|
9
|
+
_ScopeKey = Hashable
|
|
10
|
+
_ScopeFuncType = Callable[[], _ScopeKey]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ScopedRegistry(Generic[_T]):
|
|
14
|
+
"""One value per scope; the scope key comes from `scope_func` (e.g. `asyncio.current_task`, `os.getpid`)."""
|
|
15
|
+
|
|
16
|
+
__slots__ = ('create_func', 'scope_func', 'registry', 'destructor_method_name', '_lock', '__orig_class__')
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
create_func: _CreateFuncType,
|
|
21
|
+
scope_func: _ScopeFuncType,
|
|
22
|
+
destructor_method_name: str | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.create_func = create_func
|
|
25
|
+
self.scope_func = scope_func
|
|
26
|
+
self.registry: dict[_ScopeKey, _T] = {}
|
|
27
|
+
self.destructor_method_name = destructor_method_name
|
|
28
|
+
self._lock = asyncio.Lock()
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def generic_type(self) -> type[_T]:
|
|
32
|
+
orig_class = getattr(self, '__orig_class__', None)
|
|
33
|
+
try:
|
|
34
|
+
return get_args(orig_class)[0]
|
|
35
|
+
except IndexError as e:
|
|
36
|
+
raise TypeError('Cannot determine generic type parameter') from e
|
|
37
|
+
|
|
38
|
+
def _get_scope_key(self) -> _ScopeKey:
|
|
39
|
+
key = self.scope_func()
|
|
40
|
+
if key is None or not isinstance(key, Hashable):
|
|
41
|
+
raise RuntimeError(f'scope_func must return a hashable scope key, got {key!r}')
|
|
42
|
+
return key
|
|
43
|
+
|
|
44
|
+
def get(self) -> _T | None:
|
|
45
|
+
return self.registry.get(self._get_scope_key())
|
|
46
|
+
|
|
47
|
+
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()
|
|
50
|
+
if key in self.registry:
|
|
51
|
+
return self.registry[key], False
|
|
52
|
+
|
|
53
|
+
async with self._lock:
|
|
54
|
+
if key in self.registry:
|
|
55
|
+
return self.registry[key], False
|
|
56
|
+
result = self.create_func(**kwargs)
|
|
57
|
+
self.registry[key] = await result if inspect.isawaitable(result) else result
|
|
58
|
+
return self.registry[key], True
|
|
59
|
+
|
|
60
|
+
async def set(self, **kwargs: Any) -> _T:
|
|
61
|
+
"""Returns the value of the current scope, creating it with `create_func(**kwargs)` if needed."""
|
|
62
|
+
value, _ = await self._set(**kwargs)
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
async def clear(self) -> None:
|
|
66
|
+
"""Removes the value of the current scope and calls its destructor."""
|
|
67
|
+
instance = self.registry.pop(self._get_scope_key(), None)
|
|
68
|
+
destructor = getattr(instance, self.destructor_method_name or '', None)
|
|
69
|
+
if destructor is not None:
|
|
70
|
+
result = destructor()
|
|
71
|
+
if inspect.isawaitable(result):
|
|
72
|
+
await result
|
|
73
|
+
|
|
74
|
+
@asynccontextmanager
|
|
75
|
+
async def scope(self, **kwargs: Any) -> AsyncIterator[_T]:
|
|
76
|
+
"""
|
|
77
|
+
Enters the current scope: joins the existing value or creates one. Only the block that created the
|
|
78
|
+
value removes it (and calls the destructor) on exit; nested blocks join and leave it alone.
|
|
79
|
+
"""
|
|
80
|
+
value, created = await self._set(**kwargs)
|
|
81
|
+
try:
|
|
82
|
+
yield value
|
|
83
|
+
finally:
|
|
84
|
+
if created:
|
|
85
|
+
await self.clear()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ddutils"
|
|
3
|
+
description = "Domain Driven Utils Library"
|
|
4
|
+
version = "0.2.0"
|
|
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
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[[project.authors]]
|
|
16
|
+
name = "davyddd"
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Repository = "https://github.com/davyddd/ddutils"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"ipdb==0.13.13",
|
|
24
|
+
"ipython==9.16.1",
|
|
25
|
+
]
|
|
26
|
+
test = [
|
|
27
|
+
"pytest==9.0.3",
|
|
28
|
+
"pytest-cov==5.0.0",
|
|
29
|
+
"parameterized==0.9.0",
|
|
30
|
+
]
|
|
31
|
+
linter = [
|
|
32
|
+
"ruff==0.15.10",
|
|
33
|
+
"ty==0.0.15",
|
|
34
|
+
"complexipy==8.0.1",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.uv.build-backend]
|
|
38
|
+
module-root = ""
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
42
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ddutils"
|
|
3
|
+
description = "Domain Driven Utils Library"
|
|
4
|
+
version = "0.2.0"
|
|
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
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Repository = "https://github.com/davyddd/ddutils"
|
|
15
|
+
|
|
16
|
+
[dependency-groups]
|
|
17
|
+
dev = [
|
|
18
|
+
"ipdb==0.13.13",
|
|
19
|
+
"ipython==9.16.1",
|
|
20
|
+
]
|
|
21
|
+
test = [
|
|
22
|
+
"pytest==9.0.3",
|
|
23
|
+
"pytest-cov==5.0.0",
|
|
24
|
+
"parameterized==0.9.0",
|
|
25
|
+
]
|
|
26
|
+
linter = [
|
|
27
|
+
"ruff==0.15.10",
|
|
28
|
+
"ty==0.0.15",
|
|
29
|
+
"complexipy==8.0.1",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.uv.build-backend]
|
|
33
|
+
module-root = ""
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
37
|
+
build-backend = "uv_build"
|
|
@@ -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,83 +0,0 @@
|
|
|
1
|
-
import inspect
|
|
2
|
-
from collections.abc import Awaitable, Hashable
|
|
3
|
-
from typing import Any, Callable, Dict, Generic, Optional, TypeVar, Union
|
|
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'
|
|
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
|
-
def get(self) -> Optional[_T]:
|
|
27
|
-
try:
|
|
28
|
-
key = self.scope_func()
|
|
29
|
-
return self.registry[key]
|
|
30
|
-
except Exception: # noqa: BLE001
|
|
31
|
-
return None
|
|
32
|
-
|
|
33
|
-
def _set(self, **kwargs: Any) -> tuple[_ScopeType, Union[_T, Awaitable[_T]]]:
|
|
34
|
-
key = self.scope_func()
|
|
35
|
-
if key in self.registry:
|
|
36
|
-
return key, self.registry[key]
|
|
37
|
-
return key, self.create_func(**kwargs)
|
|
38
|
-
|
|
39
|
-
def sync_set(self, **kwargs: Any) -> _T:
|
|
40
|
-
key, result = self._set(**kwargs)
|
|
41
|
-
if inspect.isawaitable(result):
|
|
42
|
-
raise TypeError('create_func returned an awaitable; use async_set instead')
|
|
43
|
-
self.registry[key] = result
|
|
44
|
-
return result
|
|
45
|
-
|
|
46
|
-
async def async_set(self, **kwargs: Any) -> _T:
|
|
47
|
-
key, result = self._set(**kwargs)
|
|
48
|
-
self.registry[key] = await result if inspect.isawaitable(result) else result
|
|
49
|
-
return self.registry[key]
|
|
50
|
-
|
|
51
|
-
def _clear(self, *scopes: _ScopeType):
|
|
52
|
-
if not scopes and (scope := self.scope_func()):
|
|
53
|
-
scopes = (scope,)
|
|
54
|
-
|
|
55
|
-
if not (self.registry and scopes):
|
|
56
|
-
return
|
|
57
|
-
|
|
58
|
-
for scope in scopes:
|
|
59
|
-
if scope in self.registry:
|
|
60
|
-
instance = self.registry.pop(scope)
|
|
61
|
-
|
|
62
|
-
if isinstance(self.destructor_method_name, str):
|
|
63
|
-
destructor_method = getattr(instance, self.destructor_method_name, None)
|
|
64
|
-
if destructor_method:
|
|
65
|
-
yield destructor_method()
|
|
66
|
-
|
|
67
|
-
def sync_clear(self, *scopes: _ScopeType) -> None:
|
|
68
|
-
for result in self._clear(*scopes):
|
|
69
|
-
if inspect.isawaitable(result):
|
|
70
|
-
raise TypeError('destructor returned an awaitable; use async clear instead')
|
|
71
|
-
|
|
72
|
-
async def async_clear(self, *scopes: _ScopeType) -> None:
|
|
73
|
-
for result in self._clear(*scopes):
|
|
74
|
-
if inspect.isawaitable(result):
|
|
75
|
-
await result
|
|
76
|
-
|
|
77
|
-
# deprecated: use async_set / async_clear instead
|
|
78
|
-
|
|
79
|
-
async def __call__(self, **kwargs: Any) -> _T:
|
|
80
|
-
return await self.async_set(**kwargs)
|
|
81
|
-
|
|
82
|
-
async def clear(self, *scopes: _ScopeType) -> None:
|
|
83
|
-
await self.async_clear(*scopes)
|
ddutils-0.1.5/pyproject.toml
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
[tool.poetry]
|
|
2
|
-
name = "ddutils"
|
|
3
|
-
description = "Domain Driven Utils Library"
|
|
4
|
-
version = "0.1.5"
|
|
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
|
|
File without changes
|