tigrbl_model 0.4.5__py3-none-any.whl
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.
- tigrbl_model/__init__.py +35 -0
- tigrbl_model/config.py +36 -0
- tigrbl_model/errors.py +98 -0
- tigrbl_model/factory.py +53 -0
- tigrbl_model/fields.py +172 -0
- tigrbl_model/mixins/__init__.py +8 -0
- tigrbl_model/mixins/json.py +75 -0
- tigrbl_model/mixins/serde.py +142 -0
- tigrbl_model/mixins/toml.py +48 -0
- tigrbl_model/mixins/yaml.py +49 -0
- tigrbl_model/model.py +319 -0
- tigrbl_model/py.typed +1 -0
- tigrbl_model/root.py +54 -0
- tigrbl_model/schema.py +207 -0
- tigrbl_model/serialization.py +161 -0
- tigrbl_model/validation.py +457 -0
- tigrbl_model/validators.py +47 -0
- tigrbl_model-0.4.5.dist-info/METADATA +166 -0
- tigrbl_model-0.4.5.dist-info/RECORD +22 -0
- tigrbl_model-0.4.5.dist-info/WHEEL +4 -0
- tigrbl_model-0.4.5.dist-info/licenses/LICENSE +17 -0
- tigrbl_model-0.4.5.dist-info/licenses/NOTICE +4 -0
tigrbl_model/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Pydantic-independent models for Tigrbl."""
|
|
2
|
+
|
|
3
|
+
from .config import ConfigDict, ModelConfig
|
|
4
|
+
from .errors import ErrorDetails, ModelUserError, ValidationError
|
|
5
|
+
from .factory import create_model
|
|
6
|
+
from .fields import AliasChoices, Field, FieldInfo, PydanticUndefined, Undefined
|
|
7
|
+
from .mixins import JsonMixin, SerdeMixin, TomlMixin, YamlMixin
|
|
8
|
+
from .model import BaseModel, Model
|
|
9
|
+
from .root import RootModel
|
|
10
|
+
from .validators import field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
__version__ = "0.4.5.dev4"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AliasChoices",
|
|
16
|
+
"BaseModel",
|
|
17
|
+
"ConfigDict",
|
|
18
|
+
"ErrorDetails",
|
|
19
|
+
"Field",
|
|
20
|
+
"FieldInfo",
|
|
21
|
+
"JsonMixin",
|
|
22
|
+
"Model",
|
|
23
|
+
"ModelConfig",
|
|
24
|
+
"ModelUserError",
|
|
25
|
+
"PydanticUndefined",
|
|
26
|
+
"RootModel",
|
|
27
|
+
"SerdeMixin",
|
|
28
|
+
"TomlMixin",
|
|
29
|
+
"Undefined",
|
|
30
|
+
"ValidationError",
|
|
31
|
+
"YamlMixin",
|
|
32
|
+
"create_model",
|
|
33
|
+
"field_validator",
|
|
34
|
+
"model_validator",
|
|
35
|
+
]
|
tigrbl_model/config.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Model configuration primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigDict(dict[str, Any]):
|
|
9
|
+
"""Dictionary configuration compatible with Tigrbl's Pydantic usage."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
ModelConfig = ConfigDict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG = ConfigDict(
|
|
16
|
+
extra="ignore",
|
|
17
|
+
populate_by_name=False,
|
|
18
|
+
from_attributes=False,
|
|
19
|
+
arbitrary_types_allowed=False,
|
|
20
|
+
validate_default=False,
|
|
21
|
+
use_enum_values=False,
|
|
22
|
+
json_schema_extra=None,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def merge_config(*configs: dict[str, Any] | None) -> ConfigDict:
|
|
27
|
+
"""Merge model configuration from oldest base to newest declaration."""
|
|
28
|
+
|
|
29
|
+
merged = ConfigDict(DEFAULT_CONFIG)
|
|
30
|
+
for config in configs:
|
|
31
|
+
if config:
|
|
32
|
+
merged.update(config)
|
|
33
|
+
return merged
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["ConfigDict", "ModelConfig"]
|
tigrbl_model/errors.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Structured model validation errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Iterable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class ErrorDetails:
|
|
12
|
+
"""One validation failure with a stable location and machine-readable type."""
|
|
13
|
+
|
|
14
|
+
type: str
|
|
15
|
+
loc: tuple[str | int, ...]
|
|
16
|
+
msg: str
|
|
17
|
+
input: Any = None
|
|
18
|
+
ctx: dict[str, Any] | None = None
|
|
19
|
+
|
|
20
|
+
def as_dict(self, *, include_url: bool = True) -> dict[str, Any]:
|
|
21
|
+
result: dict[str, Any] = {
|
|
22
|
+
"type": self.type,
|
|
23
|
+
"loc": self.loc,
|
|
24
|
+
"msg": self.msg,
|
|
25
|
+
"input": self.input,
|
|
26
|
+
}
|
|
27
|
+
if self.ctx:
|
|
28
|
+
result["ctx"] = dict(self.ctx)
|
|
29
|
+
if include_url:
|
|
30
|
+
result["url"] = f"https://errors.tigrbl.dev/model/{self.type}"
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ValidationError(ValueError):
|
|
35
|
+
"""Aggregate validation failures for a model operation."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, title: str, errors: Iterable[ErrorDetails | dict[str, Any]]):
|
|
38
|
+
self.title = title
|
|
39
|
+
self._errors = tuple(
|
|
40
|
+
error
|
|
41
|
+
if isinstance(error, ErrorDetails)
|
|
42
|
+
else ErrorDetails(
|
|
43
|
+
type=str(error.get("type", "value_error")),
|
|
44
|
+
loc=tuple(error.get("loc", ())),
|
|
45
|
+
msg=str(error.get("msg", "Invalid value")),
|
|
46
|
+
input=error.get("input"),
|
|
47
|
+
ctx=error.get("ctx"),
|
|
48
|
+
)
|
|
49
|
+
for error in errors
|
|
50
|
+
)
|
|
51
|
+
super().__init__(str(self))
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_exception_data(
|
|
55
|
+
cls, title: str, line_errors: Iterable[dict[str, Any]]
|
|
56
|
+
) -> "ValidationError":
|
|
57
|
+
return cls(title, line_errors)
|
|
58
|
+
|
|
59
|
+
def error_count(self) -> int:
|
|
60
|
+
return len(self._errors)
|
|
61
|
+
|
|
62
|
+
def errors(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
include_url: bool = True,
|
|
66
|
+
include_context: bool = True,
|
|
67
|
+
include_input: bool = True,
|
|
68
|
+
) -> list[dict[str, Any]]:
|
|
69
|
+
values = [error.as_dict(include_url=include_url) for error in self._errors]
|
|
70
|
+
for value in values:
|
|
71
|
+
if not include_context:
|
|
72
|
+
value.pop("ctx", None)
|
|
73
|
+
if not include_input:
|
|
74
|
+
value.pop("input", None)
|
|
75
|
+
return values
|
|
76
|
+
|
|
77
|
+
def json(self, *, indent: int | None = None, **_: Any) -> str:
|
|
78
|
+
return json.dumps(self.errors(), indent=indent, default=str)
|
|
79
|
+
|
|
80
|
+
def __str__(self) -> str:
|
|
81
|
+
count = len(self._errors)
|
|
82
|
+
heading = f"{count} validation error{'s' if count != 1 else ''} for {self.title}"
|
|
83
|
+
lines = [heading]
|
|
84
|
+
for error in self._errors:
|
|
85
|
+
location = ".".join(str(item) for item in error.loc) or "__root__"
|
|
86
|
+
lines.extend((location, f" {error.msg} [type={error.type}]"))
|
|
87
|
+
return "\n".join(lines)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ModelUserError(TypeError):
|
|
91
|
+
"""Raised when a declaration uses a removed or unsupported model option."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, message: str, *, code: str = "model-user-error") -> None:
|
|
94
|
+
self.code = code
|
|
95
|
+
super().__init__(message)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["ErrorDetails", "ModelUserError", "ValidationError"]
|
tigrbl_model/factory.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Dynamic model construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .config import ConfigDict
|
|
9
|
+
from .fields import FieldInfo, PydanticUndefined
|
|
10
|
+
from .model import Model
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_model(
|
|
14
|
+
model_name: str,
|
|
15
|
+
*,
|
|
16
|
+
__config__: ConfigDict | dict[str, Any] | None = None,
|
|
17
|
+
__base__: type[Model] | tuple[type[Model], ...] | None = None,
|
|
18
|
+
__module__: str | None = None,
|
|
19
|
+
__validators__: dict[str, Any] | None = None,
|
|
20
|
+
__doc__: str | None = None,
|
|
21
|
+
**field_definitions: Any,
|
|
22
|
+
) -> type[Model]:
|
|
23
|
+
"""Create a model class from `(annotation, default)` field definitions."""
|
|
24
|
+
|
|
25
|
+
bases = __base__ or Model
|
|
26
|
+
if not isinstance(bases, tuple):
|
|
27
|
+
bases = (bases,)
|
|
28
|
+
annotations: dict[str, Any] = {}
|
|
29
|
+
namespace: dict[str, Any] = {
|
|
30
|
+
"__annotations__": annotations,
|
|
31
|
+
"__module__": __module__ or sys._getframe(1).f_globals.get("__name__", "__main__"),
|
|
32
|
+
}
|
|
33
|
+
if __doc__ is not None:
|
|
34
|
+
namespace["__doc__"] = __doc__
|
|
35
|
+
if __config__ is not None:
|
|
36
|
+
namespace["model_config"] = ConfigDict(__config__)
|
|
37
|
+
namespace.update(__validators__ or {})
|
|
38
|
+
for name, definition in field_definitions.items():
|
|
39
|
+
if isinstance(definition, tuple) and len(definition) == 2:
|
|
40
|
+
annotation, default = definition
|
|
41
|
+
else:
|
|
42
|
+
annotation, default = definition, PydanticUndefined
|
|
43
|
+
annotations[name] = annotation
|
|
44
|
+
if default is Ellipsis:
|
|
45
|
+
default = PydanticUndefined
|
|
46
|
+
if isinstance(default, FieldInfo):
|
|
47
|
+
namespace[name] = default
|
|
48
|
+
elif default is not PydanticUndefined:
|
|
49
|
+
namespace[name] = default
|
|
50
|
+
return type(model_name, bases, namespace)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = ["create_model"]
|
tigrbl_model/fields.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Field declarations and metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field as dataclass_field
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _UndefinedType:
|
|
12
|
+
def __repr__(self) -> str:
|
|
13
|
+
return "PydanticUndefined"
|
|
14
|
+
|
|
15
|
+
def __copy__(self) -> "_UndefinedType":
|
|
16
|
+
return self
|
|
17
|
+
|
|
18
|
+
def __deepcopy__(self, memo: dict[int, Any]) -> "_UndefinedType":
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
PydanticUndefined = _UndefinedType()
|
|
23
|
+
Undefined = PydanticUndefined
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class AliasChoices:
|
|
28
|
+
"""Ordered validation aliases; the first present alias wins."""
|
|
29
|
+
|
|
30
|
+
choices: tuple[str, ...]
|
|
31
|
+
|
|
32
|
+
def __init__(self, first: str, *choices: str):
|
|
33
|
+
object.__setattr__(self, "choices", (first, *choices))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class FieldInfo:
|
|
38
|
+
"""Runtime metadata for one declared model field."""
|
|
39
|
+
|
|
40
|
+
annotation: Any = Any
|
|
41
|
+
default: Any = PydanticUndefined
|
|
42
|
+
default_factory: Callable[[], Any] | None = None
|
|
43
|
+
alias: str | None = None
|
|
44
|
+
validation_alias: str | AliasChoices | None = None
|
|
45
|
+
serialization_alias: str | None = None
|
|
46
|
+
title: str | None = None
|
|
47
|
+
description: str | None = None
|
|
48
|
+
examples: list[Any] | None = None
|
|
49
|
+
json_schema_extra: dict[str, Any] | Callable[[dict[str, Any]], None] | None = None
|
|
50
|
+
gt: Any = None
|
|
51
|
+
ge: Any = None
|
|
52
|
+
lt: Any = None
|
|
53
|
+
le: Any = None
|
|
54
|
+
multiple_of: Any = None
|
|
55
|
+
min_length: int | None = None
|
|
56
|
+
max_length: int | None = None
|
|
57
|
+
pattern: str | re.Pattern[str] | None = None
|
|
58
|
+
min_items: int | None = None
|
|
59
|
+
max_items: int | None = None
|
|
60
|
+
exclude: bool = False
|
|
61
|
+
repr: bool = True
|
|
62
|
+
metadata: list[Any] = dataclass_field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
def is_required(self) -> bool:
|
|
65
|
+
return self.default is PydanticUndefined and self.default_factory is None
|
|
66
|
+
|
|
67
|
+
def get_default(self, *, call_default_factory: bool = False) -> Any:
|
|
68
|
+
if self.default_factory is not None:
|
|
69
|
+
return self.default_factory() if call_default_factory else None
|
|
70
|
+
if self.default is PydanticUndefined:
|
|
71
|
+
return PydanticUndefined
|
|
72
|
+
return copy.deepcopy(self.default)
|
|
73
|
+
|
|
74
|
+
def validation_names(self, name: str) -> tuple[str, ...]:
|
|
75
|
+
alias = self.validation_alias or self.alias
|
|
76
|
+
if isinstance(alias, AliasChoices):
|
|
77
|
+
return alias.choices
|
|
78
|
+
if isinstance(alias, str):
|
|
79
|
+
return (alias,)
|
|
80
|
+
return (name,)
|
|
81
|
+
|
|
82
|
+
def serialization_name(self, name: str) -> str:
|
|
83
|
+
return self.serialization_alias or self.alias or name
|
|
84
|
+
|
|
85
|
+
def schema_name(self, name: str, *, mode: str) -> str:
|
|
86
|
+
if mode == "serialization":
|
|
87
|
+
return self.serialization_name(name)
|
|
88
|
+
alias = self.validation_alias or self.alias
|
|
89
|
+
if isinstance(alias, AliasChoices):
|
|
90
|
+
return alias.choices[0]
|
|
91
|
+
return alias if isinstance(alias, str) else name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def Field(
|
|
95
|
+
default: Any = PydanticUndefined,
|
|
96
|
+
*,
|
|
97
|
+
default_factory: Callable[[], Any] | None = None,
|
|
98
|
+
alias: str | None = None,
|
|
99
|
+
validation_alias: str | AliasChoices | None = None,
|
|
100
|
+
serialization_alias: str | None = None,
|
|
101
|
+
title: str | None = None,
|
|
102
|
+
description: str | None = None,
|
|
103
|
+
examples: list[Any] | None = None,
|
|
104
|
+
json_schema_extra: dict[str, Any] | Callable[[dict[str, Any]], None] | None = None,
|
|
105
|
+
gt: Any = None,
|
|
106
|
+
ge: Any = None,
|
|
107
|
+
lt: Any = None,
|
|
108
|
+
le: Any = None,
|
|
109
|
+
multiple_of: Any = None,
|
|
110
|
+
min_length: int | None = None,
|
|
111
|
+
max_length: int | None = None,
|
|
112
|
+
pattern: str | re.Pattern[str] | None = None,
|
|
113
|
+
min_items: int | None = None,
|
|
114
|
+
max_items: int | None = None,
|
|
115
|
+
exclude: bool = False,
|
|
116
|
+
repr: bool = True,
|
|
117
|
+
**extra: Any,
|
|
118
|
+
) -> FieldInfo:
|
|
119
|
+
"""Declare field defaults, aliases, constraints, and schema metadata."""
|
|
120
|
+
|
|
121
|
+
if default is Ellipsis:
|
|
122
|
+
default = PydanticUndefined
|
|
123
|
+
if "regex" in extra:
|
|
124
|
+
from .errors import ModelUserError
|
|
125
|
+
|
|
126
|
+
raise ModelUserError(
|
|
127
|
+
"`regex` is removed; use `pattern` instead",
|
|
128
|
+
code="removed-kwargs",
|
|
129
|
+
)
|
|
130
|
+
if "unique_items" in extra:
|
|
131
|
+
from .errors import ModelUserError
|
|
132
|
+
|
|
133
|
+
raise ModelUserError(
|
|
134
|
+
"`unique_items` is removed; use a set annotation instead",
|
|
135
|
+
code="removed-kwargs",
|
|
136
|
+
)
|
|
137
|
+
if default is not PydanticUndefined and default_factory is not None:
|
|
138
|
+
raise TypeError("cannot specify both default and default_factory")
|
|
139
|
+
info = FieldInfo(
|
|
140
|
+
default=default,
|
|
141
|
+
default_factory=default_factory,
|
|
142
|
+
alias=alias,
|
|
143
|
+
validation_alias=validation_alias,
|
|
144
|
+
serialization_alias=serialization_alias,
|
|
145
|
+
title=title,
|
|
146
|
+
description=description,
|
|
147
|
+
examples=examples,
|
|
148
|
+
json_schema_extra=json_schema_extra,
|
|
149
|
+
gt=gt,
|
|
150
|
+
ge=ge,
|
|
151
|
+
lt=lt,
|
|
152
|
+
le=le,
|
|
153
|
+
multiple_of=multiple_of,
|
|
154
|
+
min_length=min_length,
|
|
155
|
+
max_length=max_length,
|
|
156
|
+
pattern=pattern,
|
|
157
|
+
min_items=min_items,
|
|
158
|
+
max_items=max_items,
|
|
159
|
+
exclude=exclude,
|
|
160
|
+
repr=repr,
|
|
161
|
+
)
|
|
162
|
+
info.metadata.extend((key, value) for key, value in extra.items())
|
|
163
|
+
return info
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
__all__ = [
|
|
167
|
+
"AliasChoices",
|
|
168
|
+
"Field",
|
|
169
|
+
"FieldInfo",
|
|
170
|
+
"PydanticUndefined",
|
|
171
|
+
"Undefined",
|
|
172
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""JSON serialization and validation capability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
try:
|
|
8
|
+
from typing import Self
|
|
9
|
+
except ImportError: # pragma: no cover - Python 3.10 compatibility
|
|
10
|
+
from typing_extensions import Self
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JsonMixin:
|
|
14
|
+
def model_dump_json(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
indent: int | None = None,
|
|
18
|
+
include: Any = None,
|
|
19
|
+
exclude: Any = None,
|
|
20
|
+
by_alias: bool = False,
|
|
21
|
+
exclude_unset: bool = False,
|
|
22
|
+
exclude_defaults: bool = False,
|
|
23
|
+
exclude_none: bool = False,
|
|
24
|
+
fallback: Any = None,
|
|
25
|
+
**_: Any,
|
|
26
|
+
) -> str:
|
|
27
|
+
payload = self.model_dump( # type: ignore[attr-defined]
|
|
28
|
+
mode="json",
|
|
29
|
+
include=include,
|
|
30
|
+
exclude=exclude,
|
|
31
|
+
by_alias=by_alias,
|
|
32
|
+
exclude_unset=exclude_unset,
|
|
33
|
+
exclude_defaults=exclude_defaults,
|
|
34
|
+
exclude_none=exclude_none,
|
|
35
|
+
fallback=fallback,
|
|
36
|
+
)
|
|
37
|
+
separators = None if indent is not None else (",", ":")
|
|
38
|
+
return json.dumps(payload, indent=indent, separators=separators, ensure_ascii=False)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def model_validate_json(
|
|
42
|
+
cls,
|
|
43
|
+
json_data: str | bytes | bytearray,
|
|
44
|
+
*,
|
|
45
|
+
strict: bool | None = None,
|
|
46
|
+
context: Any = None,
|
|
47
|
+
by_alias: bool | None = None,
|
|
48
|
+
by_name: bool | None = None,
|
|
49
|
+
) -> Self:
|
|
50
|
+
try:
|
|
51
|
+
payload = json.loads(json_data)
|
|
52
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
53
|
+
from ..errors import ErrorDetails, ValidationError
|
|
54
|
+
|
|
55
|
+
raise ValidationError(
|
|
56
|
+
cls.__name__,
|
|
57
|
+
[ErrorDetails("json_invalid", (), f"Invalid JSON: {exc}", json_data)],
|
|
58
|
+
) from exc
|
|
59
|
+
return cls.model_validate( # type: ignore[attr-defined]
|
|
60
|
+
payload,
|
|
61
|
+
strict=bool(strict),
|
|
62
|
+
context=context,
|
|
63
|
+
by_alias=by_alias,
|
|
64
|
+
by_name=by_name,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def to_json(self, *, indent: int | None = None, **options: Any) -> str:
|
|
68
|
+
return self.model_dump_json(indent=indent, **options)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_json(cls, payload: str | bytes | bytearray, **options: Any) -> Self:
|
|
72
|
+
return cls.model_validate_json(payload, **options)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = ["JsonMixin"]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Aggregate Python, JSON, YAML, and TOML serialization capability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import fields as dataclass_fields, is_dataclass
|
|
6
|
+
from typing import Any, Callable, Literal
|
|
7
|
+
try:
|
|
8
|
+
from typing import Self
|
|
9
|
+
except ImportError: # pragma: no cover - Python 3.10 compatibility
|
|
10
|
+
from typing_extensions import Self
|
|
11
|
+
|
|
12
|
+
from ..fields import PydanticUndefined
|
|
13
|
+
from ..serialization import excluded, nested_selection, selected, serialize_value
|
|
14
|
+
from .json import JsonMixin
|
|
15
|
+
from .toml import TomlMixin
|
|
16
|
+
from .yaml import YamlMixin
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SerdeMixin(JsonMixin, YamlMixin, TomlMixin):
|
|
20
|
+
def model_dump(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
mode: Literal["python", "json"] = "python",
|
|
24
|
+
include: Any = None,
|
|
25
|
+
exclude: Any = None,
|
|
26
|
+
context: Any = None,
|
|
27
|
+
by_alias: bool | None = None,
|
|
28
|
+
exclude_unset: bool = False,
|
|
29
|
+
exclude_defaults: bool = False,
|
|
30
|
+
exclude_none: bool = False,
|
|
31
|
+
round_trip: bool = False,
|
|
32
|
+
warnings: Any = True,
|
|
33
|
+
fallback: Callable[[Any], Any] | None = None,
|
|
34
|
+
serialize_as_any: bool = False,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
del context, round_trip, warnings, serialize_as_any
|
|
37
|
+
by_alias = bool(by_alias)
|
|
38
|
+
model_fields = getattr(type(self), "model_fields", None)
|
|
39
|
+
fields_set = getattr(self, "model_fields_set", set())
|
|
40
|
+
if isinstance(model_fields, dict):
|
|
41
|
+
source = ((name, info, getattr(self, name, PydanticUndefined)) for name, info in model_fields.items())
|
|
42
|
+
elif is_dataclass(self):
|
|
43
|
+
source = (
|
|
44
|
+
(item.name, None, getattr(self, item.name, PydanticUndefined))
|
|
45
|
+
for item in dataclass_fields(self)
|
|
46
|
+
)
|
|
47
|
+
else:
|
|
48
|
+
source = (
|
|
49
|
+
(name, None, value)
|
|
50
|
+
for name, value in vars(self).items()
|
|
51
|
+
if not name.startswith("_")
|
|
52
|
+
)
|
|
53
|
+
result: dict[str, Any] = {}
|
|
54
|
+
for name, info, value in source:
|
|
55
|
+
if value is PydanticUndefined or (info is not None and info.exclude):
|
|
56
|
+
continue
|
|
57
|
+
if not selected(include, name, default=include is None):
|
|
58
|
+
continue
|
|
59
|
+
if excluded(exclude, name):
|
|
60
|
+
continue
|
|
61
|
+
if exclude_unset and name not in fields_set:
|
|
62
|
+
continue
|
|
63
|
+
if exclude_none and value is None:
|
|
64
|
+
continue
|
|
65
|
+
if exclude_defaults and info is not None and not info.is_required():
|
|
66
|
+
default = info.get_default(call_default_factory=True)
|
|
67
|
+
if value == default:
|
|
68
|
+
continue
|
|
69
|
+
output_name = info.serialization_name(name) if by_alias and info is not None else name
|
|
70
|
+
result[output_name] = serialize_value(
|
|
71
|
+
value,
|
|
72
|
+
mode=mode,
|
|
73
|
+
include=nested_selection(include, name),
|
|
74
|
+
exclude=nested_selection(exclude, name),
|
|
75
|
+
by_alias=by_alias,
|
|
76
|
+
exclude_none=exclude_none,
|
|
77
|
+
exclude_unset=exclude_unset,
|
|
78
|
+
exclude_defaults=exclude_defaults,
|
|
79
|
+
fallback=fallback,
|
|
80
|
+
)
|
|
81
|
+
extra = getattr(self, "__model_extra__", None)
|
|
82
|
+
if extra:
|
|
83
|
+
result.update(
|
|
84
|
+
serialize_value(
|
|
85
|
+
extra,
|
|
86
|
+
mode=mode,
|
|
87
|
+
by_alias=by_alias,
|
|
88
|
+
exclude_none=exclude_none,
|
|
89
|
+
fallback=fallback,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def model_validate(
|
|
96
|
+
cls,
|
|
97
|
+
obj: Any,
|
|
98
|
+
*,
|
|
99
|
+
strict: bool | None = None,
|
|
100
|
+
extra: str | None = None,
|
|
101
|
+
from_attributes: bool | None = None,
|
|
102
|
+
context: Any = None,
|
|
103
|
+
by_alias: bool | None = None,
|
|
104
|
+
by_name: bool | None = None,
|
|
105
|
+
) -> Self:
|
|
106
|
+
del context, by_alias, by_name
|
|
107
|
+
if isinstance(obj, cls):
|
|
108
|
+
return obj
|
|
109
|
+
if not isinstance(obj, dict):
|
|
110
|
+
use_attributes = bool(from_attributes) or bool(
|
|
111
|
+
getattr(cls, "model_config", {}).get("from_attributes", False)
|
|
112
|
+
)
|
|
113
|
+
if use_attributes:
|
|
114
|
+
obj = {
|
|
115
|
+
name: getattr(obj, name)
|
|
116
|
+
for name in getattr(cls, "model_fields", {})
|
|
117
|
+
if hasattr(obj, name)
|
|
118
|
+
}
|
|
119
|
+
else:
|
|
120
|
+
from ..errors import ErrorDetails, ValidationError
|
|
121
|
+
|
|
122
|
+
raise ValidationError(
|
|
123
|
+
cls.__name__,
|
|
124
|
+
[ErrorDetails("model_type", (), "Input should be a valid mapping", obj)],
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
return cls(__tigrbl_strict__=bool(strict), __tigrbl_extra__=extra, **obj)
|
|
128
|
+
except TypeError as exc:
|
|
129
|
+
# Dataclass and simple-class Serde consumers do not accept engine options.
|
|
130
|
+
if "__tigrbl_" not in str(exc):
|
|
131
|
+
raise
|
|
132
|
+
return cls(**obj)
|
|
133
|
+
|
|
134
|
+
def to_dict(self, **options: Any) -> dict[str, Any]:
|
|
135
|
+
return self.model_dump(**options)
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_dict(cls, payload: dict[str, Any], **options: Any) -> Self:
|
|
139
|
+
return cls.model_validate(payload, **options)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
__all__ = ["SerdeMixin"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""TOML serialization and validation capability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
try:
|
|
7
|
+
from typing import Self
|
|
8
|
+
except ImportError: # pragma: no cover - Python 3.10 compatibility
|
|
9
|
+
from typing_extensions import Self
|
|
10
|
+
|
|
11
|
+
from ..serialization import strip_toml_nulls
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TomlMixin:
|
|
15
|
+
def model_dump_toml(self, **options: Any) -> str:
|
|
16
|
+
import tomli_w
|
|
17
|
+
|
|
18
|
+
payload = self.model_dump(mode="json", **options) # type: ignore[attr-defined]
|
|
19
|
+
return tomli_w.dumps(strip_toml_nulls(payload))
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def model_validate_toml(cls, toml_data: str | bytes, **options: Any) -> Self:
|
|
23
|
+
try:
|
|
24
|
+
import tomllib
|
|
25
|
+
except ModuleNotFoundError: # pragma: no cover - Python 3.10
|
|
26
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
27
|
+
|
|
28
|
+
text = toml_data.decode("utf-8") if isinstance(toml_data, bytes) else toml_data
|
|
29
|
+
try:
|
|
30
|
+
payload = tomllib.loads(text)
|
|
31
|
+
except tomllib.TOMLDecodeError as exc:
|
|
32
|
+
from ..errors import ErrorDetails, ValidationError
|
|
33
|
+
|
|
34
|
+
raise ValidationError(
|
|
35
|
+
cls.__name__,
|
|
36
|
+
[ErrorDetails("toml_invalid", (), f"Invalid TOML: {exc}", text)],
|
|
37
|
+
) from exc
|
|
38
|
+
return cls.model_validate(payload, **options) # type: ignore[attr-defined]
|
|
39
|
+
|
|
40
|
+
def to_toml(self, **options: Any) -> str:
|
|
41
|
+
return self.model_dump_toml(**options)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_toml(cls, payload: str | bytes, **options: Any) -> Self:
|
|
45
|
+
return cls.model_validate_toml(payload, **options)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["TomlMixin"]
|