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.
@@ -1,20 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ddutils
3
- Version: 0.1.5
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
- Requires-Python: >=3.9,<3.15
10
- Classifier: License :: OSI Approved :: MIT License
11
- Classifier: Programming Language :: Python :: 3
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 manages object instances within defined scopes.
332
+ A registry that keeps one object instance per scope and manages its lifetime.
326
333
 
327
334
  ### ScopedRegistry
328
335
 
329
- Manages object instances within scopes (like per request or per session).
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 get_current_request_id(): ...
351
+ async def create_session(): ...
336
352
 
337
353
 
338
- def create_database_connection(): ...
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
- db_registry = ScopedRegistry(
342
- create_func=create_database_connection,
343
- scope_func=get_current_request_id,
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 handle_request():
348
- db = await db_registry() # Returns existing or creates new db connection
349
- await db.execute('select ...')
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 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. 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 get_current_request_id(): ...
339
+ async def create_session(): ...
316
340
 
317
341
 
318
- def create_database_connection(): ...
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
- db_registry = ScopedRegistry(
322
- create_func=create_database_connection,
323
- scope_func=get_current_request_id,
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 handle_request():
328
- db = await db_registry() # Returns existing or creates new db connection
329
- await db.execute('select ...')
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 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,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,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,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)
@@ -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