model-lib 0.99.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.
- model_lib-0.99.1/.gitignore +40 -0
- model_lib-0.99.1/LICENSE +21 -0
- model_lib-0.99.1/PKG-INFO +20 -0
- model_lib-0.99.1/README.md +1 -0
- model_lib-0.99.1/model_lib/__init__.py +5 -0
- model_lib-0.99.1/model_lib/base_settings.py +48 -0
- model_lib-0.99.1/model_lib/constants.py +30 -0
- model_lib-0.99.1/model_lib/dump_functions.py +31 -0
- model_lib-0.99.1/model_lib/errors.py +63 -0
- model_lib-0.99.1/model_lib/metadata/__init__.py +36 -0
- model_lib-0.99.1/model_lib/metadata/context_dict.py +205 -0
- model_lib-0.99.1/model_lib/metadata/metadata.py +60 -0
- model_lib-0.99.1/model_lib/metadata/metadata_dump.py +97 -0
- model_lib-0.99.1/model_lib/metadata/metadata_fields.py +29 -0
- model_lib-0.99.1/model_lib/model_base.py +91 -0
- model_lib-0.99.1/model_lib/model_dump.py +114 -0
- model_lib-0.99.1/model_lib/py.typed +0 -0
- model_lib-0.99.1/model_lib/pydantic_utils.py +119 -0
- model_lib-0.99.1/model_lib/serialize/__init__.py +43 -0
- model_lib-0.99.1/model_lib/serialize/base_64.py +73 -0
- model_lib-0.99.1/model_lib/serialize/dump.py +129 -0
- model_lib-0.99.1/model_lib/serialize/json_serialize.py +37 -0
- model_lib-0.99.1/model_lib/serialize/parse.py +180 -0
- model_lib-0.99.1/model_lib/serialize/toml_serialize.py +92 -0
- model_lib-0.99.1/model_lib/serialize/yaml_serialize.py +346 -0
- model_lib-0.99.1/model_lib/static_settings.py +45 -0
- model_lib-0.99.1/pyproject.toml +99 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# path-sync copy -n python-template
|
|
2
|
+
|
|
3
|
+
# === DO_NOT_EDIT: path-sync gitignore ===
|
|
4
|
+
# Python bytecode
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.pyc
|
|
7
|
+
*.pyo
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
|
|
13
|
+
# Build artifacts
|
|
14
|
+
dist/
|
|
15
|
+
build/
|
|
16
|
+
*.egg-info/
|
|
17
|
+
|
|
18
|
+
# Coverage
|
|
19
|
+
.coverage
|
|
20
|
+
htmlcov/
|
|
21
|
+
coverage.xml
|
|
22
|
+
|
|
23
|
+
# Cache directories
|
|
24
|
+
.ruff_cache/
|
|
25
|
+
.pytest_cache/
|
|
26
|
+
.mypy_cache/
|
|
27
|
+
|
|
28
|
+
# IDE
|
|
29
|
+
.idea/
|
|
30
|
+
*.iml
|
|
31
|
+
.vscode/
|
|
32
|
+
|
|
33
|
+
# pkg-ext dev mode files
|
|
34
|
+
.groups-dev.yaml
|
|
35
|
+
CHANGELOG-dev.md
|
|
36
|
+
*.api-dev.yaml
|
|
37
|
+
|
|
38
|
+
# MkDocs build output
|
|
39
|
+
site/
|
|
40
|
+
# === OK_EDIT: path-sync gitignore ===
|
model_lib-0.99.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Espen Albert
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: model-lib
|
|
3
|
+
Version: 0.99.1
|
|
4
|
+
Summary: Pydantic model utilities for serialization and settings
|
|
5
|
+
Author-email: EspenAlbert <espen.albert1@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Requires-Dist: pydantic-settings>=2.8
|
|
13
|
+
Requires-Dist: pydantic>=2.11
|
|
14
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
15
|
+
Requires-Dist: zero-3rdparty
|
|
16
|
+
Provides-Extra: toml
|
|
17
|
+
Requires-Dist: tomli-w~=1.1.0; extra == 'toml'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# model-lib
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# model-lib
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
from pydantic.fields import FieldInfo
|
|
11
|
+
from zero_3rdparty.run_env import running_in_container_environment
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def env_value_as_str(value: Any) -> str | None:
|
|
17
|
+
if value is None:
|
|
18
|
+
return None
|
|
19
|
+
if isinstance(value, Path | bool | float | int):
|
|
20
|
+
value = str(value)
|
|
21
|
+
if isinstance(value, list | dict):
|
|
22
|
+
value = json.dumps(value)
|
|
23
|
+
assert isinstance(value, str), f"env value must be str, was {value!r}"
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def container_or_default(container_default: T, cls_default: T) -> FieldInfo:
|
|
31
|
+
return Field(default_factory=DockerOrClsDefDefault(docker_default=container_default, cls_default=cls_default)) # type: ignore
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class DockerOrClsDefDefault(Generic[T]):
|
|
36
|
+
docker_default: T
|
|
37
|
+
cls_default: T
|
|
38
|
+
|
|
39
|
+
def __call__(self) -> T:
|
|
40
|
+
if running_in_container_environment():
|
|
41
|
+
return self.docker_default
|
|
42
|
+
return self.cls_default
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def port_info(number: int, *, url_prefix: str, protocol: str) -> int:
|
|
46
|
+
"""Used for pants plugin "artifacts" to know which url_prefix and protocol
|
|
47
|
+
to use in Chart."""
|
|
48
|
+
return Field(default=number)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Callable, TypeVar, Union
|
|
3
|
+
|
|
4
|
+
from zero_3rdparty.enum_utils import StrEnum
|
|
5
|
+
|
|
6
|
+
RegisteredPayloadT = TypeVar("RegisteredPayloadT")
|
|
7
|
+
PayloadT = Union[RegisteredPayloadT, str, bytes, Path, dict, list]
|
|
8
|
+
ModelRawT = Union[dict, list]
|
|
9
|
+
METADATA_DUMP_KEY = "metadata"
|
|
10
|
+
MODEL_DUMP_KEY = "model"
|
|
11
|
+
METADATA_MODEL_NAME_FIELD = "model_name"
|
|
12
|
+
METADATA_MODEL_NAME_BACKUP_FIELD = "model_name_backup"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FileFormat(StrEnum):
|
|
16
|
+
json = "json"
|
|
17
|
+
pretty_json = "pretty_json"
|
|
18
|
+
json_pretty = "json_pretty"
|
|
19
|
+
yaml = "yaml"
|
|
20
|
+
yml = "yml"
|
|
21
|
+
# only recommended to use with pydantic2
|
|
22
|
+
json_pydantic = "json_pydantic"
|
|
23
|
+
pydantic_json = "pydantic_json"
|
|
24
|
+
# pip install tomlkit
|
|
25
|
+
toml = "toml"
|
|
26
|
+
toml_compact = "toml_compact"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
FileFormatT = Union[FileFormat, str]
|
|
30
|
+
PayloadParser = Callable[[RegisteredPayloadT, FileFormatT], ModelRawT]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
from pydantic import BaseModel, RootModel, model_serializer
|
|
5
|
+
from zero_3rdparty.iter_utils import ignore_falsy
|
|
6
|
+
|
|
7
|
+
from model_lib.model_dump import register_dumper
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def base_model_dumper(model: BaseModel):
|
|
11
|
+
if isinstance(model, RootModel):
|
|
12
|
+
return model.root
|
|
13
|
+
fields = model.model_fields # type: ignore
|
|
14
|
+
return {key: value for key, value in model if key in fields}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IgnoreFalsy(BaseModel):
|
|
18
|
+
@model_serializer(mode="wrap")
|
|
19
|
+
def _ignore_falsy(
|
|
20
|
+
self,
|
|
21
|
+
nxt: pydantic.SerializerFunctionWrapHandler,
|
|
22
|
+
):
|
|
23
|
+
serialized = nxt(self)
|
|
24
|
+
no_falsy = ignore_falsy(**serialized) # type: ignore
|
|
25
|
+
return self.dump_dict_modifier(no_falsy)
|
|
26
|
+
|
|
27
|
+
def dump_dict_modifier(self, payload: dict) -> dict:
|
|
28
|
+
return payload
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
register_dumper(BaseModel, base_model_dumper)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Type, TypeVar
|
|
4
|
+
|
|
5
|
+
from zero_3rdparty.error import BaseError
|
|
6
|
+
|
|
7
|
+
from model_lib.constants import FileFormat, PayloadT
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UnknownModelError(BaseError):
|
|
13
|
+
def __init__(self, model_name: str):
|
|
14
|
+
self.model_name: str = model_name
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClsNameAlreadyExist(BaseError):
|
|
18
|
+
def __init__(self, new_cls_path: str, old_cls_path: str):
|
|
19
|
+
self.new_cls_path = new_cls_path
|
|
20
|
+
self.old_cls_path = old_cls_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NoDumper(Exception):
|
|
24
|
+
def __init__(self, instance_type: Type[T]):
|
|
25
|
+
self.instance_type = instance_type
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DumperExist(Exception):
|
|
29
|
+
def __init__(self, existing_type: Type, new_type: Type):
|
|
30
|
+
self.existing_type = existing_type
|
|
31
|
+
self.new_type = new_type
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EnvVarParsingFailure(BaseError):
|
|
35
|
+
def __init__(self, errors: list[Any]):
|
|
36
|
+
self.errors = errors
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ParseError(BaseError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PayloadParserNotFoundError(ParseError):
|
|
44
|
+
def __init__(self, payload: object, format: FileFormat):
|
|
45
|
+
self.payload = payload
|
|
46
|
+
self.format = format
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PayloadError(ParseError):
|
|
50
|
+
def __init__(self, payload: PayloadT, message: str, metadata: dict | None = None):
|
|
51
|
+
self.message = message
|
|
52
|
+
self.payload = payload
|
|
53
|
+
self.metadata = metadata
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DumpConfigureError(BaseError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PayloadParserAlreadyExistError(DumpConfigureError):
|
|
61
|
+
def __init__(self, old_call_name: str, new_call_name: str):
|
|
62
|
+
self.old_call_name = old_call_name
|
|
63
|
+
self.new_call_name = new_call_name
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""isort:skip_file."""
|
|
2
|
+
|
|
3
|
+
from model_lib.metadata.metadata import (
|
|
4
|
+
current_metadata,
|
|
5
|
+
metadata_from_context_dict,
|
|
6
|
+
pop_metadata,
|
|
7
|
+
read_metadata,
|
|
8
|
+
read_metadata_or_none,
|
|
9
|
+
set_metadata,
|
|
10
|
+
update_metadata,
|
|
11
|
+
)
|
|
12
|
+
from model_lib.metadata.metadata_dump import (
|
|
13
|
+
add_metadata_dumper,
|
|
14
|
+
dump_metadata,
|
|
15
|
+
get_metadata_dumpers,
|
|
16
|
+
metadata_dumper,
|
|
17
|
+
set_metadata_dumpers,
|
|
18
|
+
)
|
|
19
|
+
from model_lib.metadata.metadata_fields import EventMetadata, iter_tags
|
|
20
|
+
|
|
21
|
+
__all__ = (
|
|
22
|
+
"EventMetadata",
|
|
23
|
+
"add_metadata_dumper",
|
|
24
|
+
"current_metadata",
|
|
25
|
+
"dump_metadata",
|
|
26
|
+
"get_metadata_dumpers",
|
|
27
|
+
"iter_tags",
|
|
28
|
+
"metadata_dumper",
|
|
29
|
+
"metadata_from_context_dict",
|
|
30
|
+
"pop_metadata",
|
|
31
|
+
"read_metadata",
|
|
32
|
+
"read_metadata_or_none",
|
|
33
|
+
"set_metadata",
|
|
34
|
+
"set_metadata_dumpers",
|
|
35
|
+
"update_metadata",
|
|
36
|
+
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""A dictionary with values specific to a thread/task Useful for:
|
|
2
|
+
|
|
3
|
+
- current Request in a http handler
|
|
4
|
+
- metadata during event processing
|
|
5
|
+
- storing unhandled errors
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from asyncio import Task, create_task
|
|
11
|
+
from collections import UserDict
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from contextvars import Context, ContextVar
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Callable, Coroutine, Protocol, TypeVar, Union
|
|
16
|
+
|
|
17
|
+
from typing_extensions import TypeAlias
|
|
18
|
+
from zero_3rdparty.object_name import as_name
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
KeyT: TypeAlias = Union[type, str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def identity(value: T) -> T:
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ParentChildCopy(Protocol):
|
|
29
|
+
def __call__(
|
|
30
|
+
self,
|
|
31
|
+
parent: LocalDict,
|
|
32
|
+
child: LocalDict,
|
|
33
|
+
is_new_thread: bool,
|
|
34
|
+
func_name: str,
|
|
35
|
+
) -> None: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class CopyConfig:
|
|
40
|
+
"""
|
|
41
|
+
>>> c1 = CopyConfig()
|
|
42
|
+
>>> c1.copy_to_thread
|
|
43
|
+
False
|
|
44
|
+
>>> c1.copy_to_task
|
|
45
|
+
True
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
never: bool = False
|
|
49
|
+
thread_copy: bool | Callable[[], bool] = False
|
|
50
|
+
task_copy: bool | Callable[[], bool] = True
|
|
51
|
+
copy_func: Callable[[T], T] = identity # type: ignore
|
|
52
|
+
# is thread
|
|
53
|
+
on_copy_done: ParentChildCopy | None = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def copy_to_thread(self) -> bool:
|
|
57
|
+
if self.never:
|
|
58
|
+
return False
|
|
59
|
+
thread_copy = self.thread_copy
|
|
60
|
+
if isinstance(thread_copy, bool):
|
|
61
|
+
return thread_copy
|
|
62
|
+
return thread_copy()
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def copy_to_task(self) -> bool:
|
|
66
|
+
if self.never:
|
|
67
|
+
return False
|
|
68
|
+
task_copy = self.task_copy
|
|
69
|
+
if isinstance(task_copy, bool):
|
|
70
|
+
return task_copy
|
|
71
|
+
return task_copy()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_copy_config: dict[KeyT, CopyConfig] = {}
|
|
75
|
+
DEFAULT_CONFIG = CopyConfig()
|
|
76
|
+
assert not DEFAULT_CONFIG.copy_to_thread
|
|
77
|
+
assert DEFAULT_CONFIG.copy_to_task
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def copy_value(key: KeyT, value: T) -> T:
|
|
81
|
+
config = get_copy_behavior(key) or DEFAULT_CONFIG
|
|
82
|
+
return config.copy_func(value) # type: ignore
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def set_copy_behavior(t: KeyT, config: CopyConfig) -> None:
|
|
86
|
+
global _copy_config
|
|
87
|
+
_copy_config[t] = config
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_copy_behavior(t: KeyT) -> CopyConfig | None:
|
|
91
|
+
return _copy_config.get(t)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class LocalDict(UserDict):
|
|
95
|
+
def set_instance(self, instance: T) -> T | None:
|
|
96
|
+
"""Returns the previous instance if set."""
|
|
97
|
+
instance_type = type(instance)
|
|
98
|
+
old = self.get(instance_type, None)
|
|
99
|
+
self[instance_type] = instance
|
|
100
|
+
return old
|
|
101
|
+
|
|
102
|
+
def get_instance(self, instance_type: type[T]) -> T:
|
|
103
|
+
return self[instance_type]
|
|
104
|
+
|
|
105
|
+
def get_instance_or_none(self, instance_type: type[T]) -> T | None:
|
|
106
|
+
return self.get(instance_type)
|
|
107
|
+
|
|
108
|
+
def pop_instance(self, instance_type: type[T]) -> T | None:
|
|
109
|
+
return self.pop(instance_type, None)
|
|
110
|
+
|
|
111
|
+
def copy_to_new_thread(self, thread_name: str) -> LocalDict:
|
|
112
|
+
on_done_calls = []
|
|
113
|
+
|
|
114
|
+
def should_include(t: KeyT) -> bool:
|
|
115
|
+
config = get_copy_behavior(t) or DEFAULT_CONFIG
|
|
116
|
+
if on_done := config.on_copy_done:
|
|
117
|
+
on_done_calls.append(on_done)
|
|
118
|
+
return config.copy_to_thread
|
|
119
|
+
|
|
120
|
+
copy = LocalDict({key: copy_value(key, value) for key, value in self.items() if should_include(key)})
|
|
121
|
+
for call in on_done_calls:
|
|
122
|
+
call(self, copy, True, thread_name)
|
|
123
|
+
return copy
|
|
124
|
+
|
|
125
|
+
def copy_to_new_task(self, task_name: str) -> LocalDict:
|
|
126
|
+
on_done_calls = []
|
|
127
|
+
|
|
128
|
+
def should_include(t: KeyT):
|
|
129
|
+
config = get_copy_behavior(t) or DEFAULT_CONFIG
|
|
130
|
+
if on_done := config.on_copy_done:
|
|
131
|
+
on_done_calls.append(on_done)
|
|
132
|
+
return config.copy_to_task
|
|
133
|
+
|
|
134
|
+
copy = LocalDict({key: copy_value(key, value) for key, value in self.items() if should_include(key)})
|
|
135
|
+
for call in on_done_calls:
|
|
136
|
+
call(self, copy, False, task_name)
|
|
137
|
+
return copy
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_context_dict: ContextVar[LocalDict] = ContextVar(f"{__name__}.LocalDict")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_context_dict() -> LocalDict:
|
|
144
|
+
try:
|
|
145
|
+
local_dict = _context_dict.get()
|
|
146
|
+
except LookupError:
|
|
147
|
+
local_dict = LocalDict()
|
|
148
|
+
_context_dict.set(local_dict)
|
|
149
|
+
return local_dict
|
|
150
|
+
if local_dict is ...:
|
|
151
|
+
local_dict = LocalDict()
|
|
152
|
+
_context_dict.set(local_dict)
|
|
153
|
+
return local_dict
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def set_context_dict(context_dict: LocalDict):
|
|
157
|
+
_context_dict.set(context_dict)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def force_new_context_dict_on_task(task_name: str = "unknown"):
|
|
161
|
+
old = get_context_dict()
|
|
162
|
+
new = old.copy_to_new_task(task_name)
|
|
163
|
+
set_context_dict(new)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def create_task_copy_context(awaitable: Coroutine) -> Task:
|
|
167
|
+
old = get_context_dict()
|
|
168
|
+
task_name = as_name(awaitable)
|
|
169
|
+
new = old.copy_to_new_task(task_name)
|
|
170
|
+
|
|
171
|
+
def start_task():
|
|
172
|
+
set_context_dict(new)
|
|
173
|
+
return create_task(awaitable)
|
|
174
|
+
|
|
175
|
+
return Context().run(start_task)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def clear_context_dict():
|
|
179
|
+
"""Ideally we would _context.dict.reset(token) but we have no token But
|
|
180
|
+
this is usually only needed for testing."""
|
|
181
|
+
try:
|
|
182
|
+
old = _context_dict.get()
|
|
183
|
+
except LookupError:
|
|
184
|
+
return
|
|
185
|
+
if old is ...:
|
|
186
|
+
return
|
|
187
|
+
_context_dict.set(...) # type: ignore
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_context_instance(t: type[T]) -> T:
|
|
191
|
+
return get_context_dict().get_instance(t)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_context_instance_or_none(t: type[T]) -> T | None:
|
|
195
|
+
with suppress(KeyError):
|
|
196
|
+
return get_context_instance(t)
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def set_context_instance(t: T) -> T | None:
|
|
201
|
+
return get_context_dict().set_instance(t)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def pop_context_instance(t: type[T]) -> T | None:
|
|
205
|
+
return get_context_dict().pop_instance(t)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from typing import Any, Iterable, Mapping, TypeVar
|
|
6
|
+
|
|
7
|
+
from model_lib.metadata.context_dict import (
|
|
8
|
+
CopyConfig,
|
|
9
|
+
LocalDict,
|
|
10
|
+
get_context_dict,
|
|
11
|
+
set_copy_behavior,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_METADATA_LOCAL_KEY = __name__
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
set_copy_behavior( # type: ignore
|
|
19
|
+
_METADATA_LOCAL_KEY,
|
|
20
|
+
CopyConfig(
|
|
21
|
+
thread_copy=True,
|
|
22
|
+
task_copy=True,
|
|
23
|
+
copy_func=deepcopy,
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def update_metadata(metadata: Mapping[str, Any] | Iterable[tuple[str, Any]]) -> dict:
|
|
29
|
+
metadata_in_context = current_metadata()
|
|
30
|
+
metadata_in_context.update(metadata)
|
|
31
|
+
return metadata_in_context
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def current_metadata() -> dict[str, Any]:
|
|
35
|
+
context_dict = get_context_dict()
|
|
36
|
+
return metadata_from_context_dict(context_dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def metadata_from_context_dict(context_dict: LocalDict) -> dict:
|
|
40
|
+
return context_dict.setdefault(_METADATA_LOCAL_KEY, {})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_metadata(key: str) -> Any:
|
|
44
|
+
"""Raises KeyError."""
|
|
45
|
+
return current_metadata()[key]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_metadata_or_none(key: str) -> Any:
|
|
49
|
+
with suppress(KeyError):
|
|
50
|
+
return read_metadata(key)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def set_metadata(key: str, value: Any) -> None:
|
|
54
|
+
metadata = current_metadata()
|
|
55
|
+
metadata[key] = value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def pop_metadata(key: str, default: T | None = None) -> T | None:
|
|
59
|
+
metadata = current_metadata()
|
|
60
|
+
return metadata.pop(key, default)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from contextlib import suppress
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from time import time
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from typing_extensions import TypeAlias
|
|
11
|
+
from zero_3rdparty.object_name import as_name
|
|
12
|
+
|
|
13
|
+
from model_lib.metadata.metadata import current_metadata
|
|
14
|
+
from model_lib.metadata.metadata_fields import EventMetadata
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
MetadataDumper = Callable[[dict], Any]
|
|
18
|
+
RemoveDumper: TypeAlias = Callable[[], None]
|
|
19
|
+
_dumpers: list[MetadataDumper] = []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def set_metadata_dumpers(*calls: MetadataDumper) -> None:
|
|
23
|
+
global _dumpers
|
|
24
|
+
_dumpers = list(calls)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _no_removal():
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def add_metadata_dumper(call: MetadataDumper) -> RemoveDumper:
|
|
32
|
+
global _dumpers
|
|
33
|
+
call_names = {as_name(call): call for call in _dumpers}
|
|
34
|
+
call_name = as_name(call)
|
|
35
|
+
if same_name_call := call_names.get(call_name):
|
|
36
|
+
if same_name_call is call:
|
|
37
|
+
logger.warning(f"metadat_dump_call_already_added={call_name}")
|
|
38
|
+
return _no_removal
|
|
39
|
+
_dumpers.remove(same_name_call)
|
|
40
|
+
_dumpers.append(call)
|
|
41
|
+
|
|
42
|
+
def remove():
|
|
43
|
+
with suppress(ValueError):
|
|
44
|
+
_dumpers.remove(call)
|
|
45
|
+
|
|
46
|
+
return remove
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_metadata_dumpers() -> list[MetadataDumper]:
|
|
50
|
+
return _dumpers
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def dump_metadata(skip_dumpers: bool = False) -> dict:
|
|
54
|
+
metadata = current_metadata()
|
|
55
|
+
dumped_metadata = deepcopy(metadata)
|
|
56
|
+
if not skip_dumpers:
|
|
57
|
+
for dumper in _dumpers:
|
|
58
|
+
dumper(dumped_metadata)
|
|
59
|
+
return dumped_metadata
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class metadata_dumper:
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
static_dict: dict,
|
|
66
|
+
dump_event_id: bool = True,
|
|
67
|
+
dump_time: bool = True,
|
|
68
|
+
extra_calls: list[MetadataDumper] | None = None,
|
|
69
|
+
remove_existing: bool = False,
|
|
70
|
+
):
|
|
71
|
+
self.calls = calls = [] if remove_existing else list(get_metadata_dumpers())
|
|
72
|
+
if static_dict:
|
|
73
|
+
|
|
74
|
+
def add_static_metadata(metadata: dict):
|
|
75
|
+
metadata |= static_dict
|
|
76
|
+
|
|
77
|
+
calls.append(add_static_metadata)
|
|
78
|
+
if dump_event_id:
|
|
79
|
+
|
|
80
|
+
def add_event_id(metadata: dict):
|
|
81
|
+
metadata[EventMetadata.event_id] = uuid4().hex
|
|
82
|
+
|
|
83
|
+
self.calls.append(add_event_id)
|
|
84
|
+
if dump_time:
|
|
85
|
+
|
|
86
|
+
def add_ts(metadata: dict):
|
|
87
|
+
metadata[EventMetadata.dump_time] = time()
|
|
88
|
+
|
|
89
|
+
self.calls.append(add_ts)
|
|
90
|
+
calls.extend(extra_calls or [])
|
|
91
|
+
|
|
92
|
+
def __enter__(self):
|
|
93
|
+
self.old_dumpers = get_metadata_dumpers()
|
|
94
|
+
set_metadata_dumpers(*self.calls)
|
|
95
|
+
|
|
96
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
97
|
+
set_metadata_dumpers(*self.old_dumpers)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Example of metadata to use when dumping."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EventMetadata:
|
|
7
|
+
task_id_parent = "task_id_parent"
|
|
8
|
+
topic = "topic"
|
|
9
|
+
topic_prefix = "topic_prefix"
|
|
10
|
+
tags = "tags" #: a list of [(key, value)] pairs
|
|
11
|
+
app_id = "app_id"
|
|
12
|
+
task_level = "task_level" # see Action.task_level
|
|
13
|
+
task_id = "task_id" # see Action.task_uuid
|
|
14
|
+
task_start = "task_start" # the start of the 1st RootContext
|
|
15
|
+
debug = "debug" # flag for sending messages to a debug exchange
|
|
16
|
+
trace_start = "trace_start" # the start of current RootContext
|
|
17
|
+
dispatch_start_time = "dispatch_start_time" # start of the DispatchContext
|
|
18
|
+
dispatch_name = "dispatch_name" # who dispatched
|
|
19
|
+
dump_time = "dump_time" # dump time
|
|
20
|
+
event_id = "event_id" # unique event_id per Event (useful to avoid duplicates)
|
|
21
|
+
trace_action = "trace_action" # action name of the current trace
|
|
22
|
+
session_id = "session_id" # found from request/previous metadata
|
|
23
|
+
user_id = "user_id" # found from request/previous metadata
|
|
24
|
+
maybe_duplicate = "maybe_duplicate" # e.g. when consuming the same message twice
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def iter_tags(metadata: dict) -> Iterable[tuple[str, Any]]:
|
|
28
|
+
#: a list of [(key, value)] pairs
|
|
29
|
+
yield from metadata.get(EventMetadata.tags, [])
|