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.
@@ -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"]
@@ -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,8 @@
1
+ """Composable serialization capabilities."""
2
+
3
+ from .json import JsonMixin
4
+ from .serde import SerdeMixin
5
+ from .toml import TomlMixin
6
+ from .yaml import YamlMixin
7
+
8
+ __all__ = ["JsonMixin", "SerdeMixin", "TomlMixin", "YamlMixin"]
@@ -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"]