ab-pydantic-patch 1.0.0__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.
Files changed (36) hide show
  1. ab_core/pydantic_patch/__init__.py +25 -0
  2. ab_core/pydantic_patch/core/__init__.py +1 -0
  3. ab_core/pydantic_patch/core/cache.py +43 -0
  4. ab_core/pydantic_patch/core/classproperty.py +14 -0
  5. ab_core/pydantic_patch/core/config.py +12 -0
  6. ab_core/pydantic_patch/core/errors.py +21 -0
  7. ab_core/pydantic_patch/core/fields.py +119 -0
  8. ab_core/pydantic_patch/core/operation.py +51 -0
  9. ab_core/pydantic_patch/core/payload.py +88 -0
  10. ab_core/pydantic_patch/core/transform.py +386 -0
  11. ab_core/pydantic_patch/core/types.py +65 -0
  12. ab_core/pydantic_patch/omit/__init__.py +7 -0
  13. ab_core/pydantic_patch/omit/api.py +28 -0
  14. ab_core/pydantic_patch/omit/config.py +23 -0
  15. ab_core/pydantic_patch/omit/operation.py +90 -0
  16. ab_core/pydantic_patch/partial/__init__.py +7 -0
  17. ab_core/pydantic_patch/partial/api.py +28 -0
  18. ab_core/pydantic_patch/partial/config.py +23 -0
  19. ab_core/pydantic_patch/partial/operation.py +106 -0
  20. ab_core/pydantic_patch/patch/__init__.py +7 -0
  21. ab_core/pydantic_patch/patch/api.py +36 -0
  22. ab_core/pydantic_patch/patch/config.py +30 -0
  23. ab_core/pydantic_patch/patch/operation.py +161 -0
  24. ab_core/pydantic_patch/pick/__init__.py +7 -0
  25. ab_core/pydantic_patch/pick/api.py +28 -0
  26. ab_core/pydantic_patch/pick/config.py +23 -0
  27. ab_core/pydantic_patch/pick/operation.py +88 -0
  28. ab_core/pydantic_patch/placeholder.py +8 -0
  29. ab_core/pydantic_patch/required/__init__.py +7 -0
  30. ab_core/pydantic_patch/required/api.py +28 -0
  31. ab_core/pydantic_patch/required/config.py +23 -0
  32. ab_core/pydantic_patch/required/operation.py +79 -0
  33. ab_pydantic_patch-1.0.0.dist-info/METADATA +680 -0
  34. ab_pydantic_patch-1.0.0.dist-info/RECORD +36 -0
  35. ab_pydantic_patch-1.0.0.dist-info/WHEEL +4 -0
  36. ab_pydantic_patch-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,25 @@
1
+ """Pydantic model transformation helpers for pick/omit/partial/required/patch."""
2
+
3
+ from ab_core.pydantic_patch.omit import Omit, OmitConfig, create_omit_model
4
+ from ab_core.pydantic_patch.partial import Partial, PartialConfig, create_partial_model
5
+ from ab_core.pydantic_patch.patch import Patch, PatchConfig, create_patch_model
6
+ from ab_core.pydantic_patch.pick import Pick, PickConfig, create_pick_model
7
+ from ab_core.pydantic_patch.required import Required, RequiredConfig, create_required_model
8
+
9
+ __all__ = [
10
+ "Pick",
11
+ "PickConfig",
12
+ "create_pick_model",
13
+ "Omit",
14
+ "OmitConfig",
15
+ "create_omit_model",
16
+ "Partial",
17
+ "PartialConfig",
18
+ "create_partial_model",
19
+ "Required",
20
+ "RequiredConfig",
21
+ "create_required_model",
22
+ "Patch",
23
+ "PatchConfig",
24
+ "create_patch_model",
25
+ ]
@@ -0,0 +1 @@
1
+ """Core internals for pydantic_patch."""
@@ -0,0 +1,43 @@
1
+ """Cache-key helpers for generated pydantic_patch models."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel
8
+
9
+ OperationName = Literal["pick", "omit", "partial", "required", "patch"]
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class OperationCacheKey:
14
+ """Hashable key describing a generated model transformation."""
15
+
16
+ source_model: type[BaseModel]
17
+ operation: OperationName
18
+ fields: tuple[str, ...] | None = None
19
+ pick: tuple[str, ...] | None = None
20
+ omit: tuple[str, ...] | None = None
21
+ partial: tuple[str, ...] | None = None
22
+ required: tuple[str, ...] | None = None
23
+ child_models: tuple[tuple[type[BaseModel], "OperationCacheKey"], ...] = ()
24
+ name: str | None = None
25
+
26
+
27
+ def normalise_field_key(fields: frozenset[str] | None) -> tuple[str, ...] | None:
28
+ """Return a stable tuple form for cache keys built from field sets."""
29
+ if fields is None:
30
+ return None
31
+ return tuple(sorted(fields))
32
+
33
+
34
+ def sort_child_keys(
35
+ child_keys: Mapping[type[BaseModel], OperationCacheKey],
36
+ ) -> tuple[tuple[type[BaseModel], OperationCacheKey], ...]:
37
+ """Return child cache keys in deterministic model-name order."""
38
+ return tuple(
39
+ sorted(
40
+ child_keys.items(),
41
+ key=lambda item: f"{item[0].__module__}.{item[0].__qualname__}",
42
+ )
43
+ )
@@ -0,0 +1,14 @@
1
+ """Descriptor that enables read-only class-level properties."""
2
+
3
+
4
+ class classproperty:
5
+ """Wrap a function so it can be accessed like a class property."""
6
+
7
+ def __init__(self, func):
8
+ """Store the wrapped accessor function."""
9
+ self.func = func
10
+
11
+ def __get__(self, obj, owner):
12
+ """Resolve the property value from the owning class."""
13
+ # owner is the class itself
14
+ return self.func(owner)
@@ -0,0 +1,12 @@
1
+ """Shared config helpers."""
2
+
3
+ from collections.abc import Collection
4
+
5
+ type FieldSet = frozenset[str] | None
6
+
7
+
8
+ def normalise_fields(fields: Collection[str] | None) -> FieldSet:
9
+ """Normalise user-provided field collections into immutable field sets."""
10
+ if fields is None:
11
+ return None
12
+ return frozenset(fields)
@@ -0,0 +1,21 @@
1
+ """Errors raised by pydantic_patch."""
2
+
3
+
4
+ class PydanticPatchError(Exception):
5
+ """Base exception for pydantic_patch errors."""
6
+
7
+
8
+ class InvalidPatchFieldError(PydanticPatchError):
9
+ """Raised when a configured field does not exist on the target model."""
10
+
11
+
12
+ class InvalidDiscriminatorError(PydanticPatchError):
13
+ """Raised when a discriminated-union transformation would break discrimination."""
14
+
15
+
16
+ class UnsupportedAnnotationError(PydanticPatchError):
17
+ """Raised when an annotation cannot be transformed safely."""
18
+
19
+
20
+ class ConflictingPatchConfigError(PydanticPatchError):
21
+ """Raised when operation configuration conflicts with the generated payload."""
@@ -0,0 +1,119 @@
1
+ """Field validation and payload mutation helpers."""
2
+
3
+ from functools import reduce
4
+ from operator import or_
5
+ from typing import get_args, get_origin
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from ab_core.pydantic_patch.core.errors import (
10
+ ConflictingPatchConfigError,
11
+ InvalidPatchFieldError,
12
+ )
13
+ from ab_core.pydantic_patch.core.payload import CreateModelField, CreateModelPayload
14
+ from ab_core.pydantic_patch.core.types import Any
15
+
16
+
17
+ def get_source_field_names(model: type[BaseModel]) -> set[str]:
18
+ """Return source field names including SQLModel relationship attributes."""
19
+ field_names = set(model.model_fields)
20
+
21
+ sqlmodel_relationships = getattr(model, "__sqlmodel_relationships__", {})
22
+ field_names.update(sqlmodel_relationships)
23
+
24
+ return field_names
25
+
26
+
27
+ def validate_fields_exist_on_model(
28
+ model: type[BaseModel],
29
+ fields: frozenset[str] | None,
30
+ *,
31
+ operation: str,
32
+ ) -> None:
33
+ """Validate that all configured fields exist on the source model."""
34
+ if fields is None:
35
+ return
36
+
37
+ available_fields = get_source_field_names(model)
38
+ missing = sorted(field for field in fields if field not in available_fields)
39
+
40
+ if missing:
41
+ raise InvalidPatchFieldError(
42
+ f"Unknown field(s) for {operation} on {model.__name__}: {missing}. "
43
+ f"Available fields: {sorted(available_fields)}."
44
+ )
45
+
46
+
47
+ def validate_fields_exist_in_payload(
48
+ payload: CreateModelPayload,
49
+ fields: frozenset[str] | None,
50
+ *,
51
+ model: type[BaseModel],
52
+ operation: str,
53
+ ) -> None:
54
+ """Validate that all configured fields exist in the currently generated payload."""
55
+ if fields is None:
56
+ return
57
+
58
+ missing = sorted(field for field in fields if field not in payload)
59
+ if missing:
60
+ raise ConflictingPatchConfigError(
61
+ f"Field(s) {missing} cannot be used for {operation} on {model.__name__} "
62
+ "because they are not present after pick/omit processing. "
63
+ f"Available payload fields: {sorted(payload)}."
64
+ )
65
+
66
+
67
+ def allows_none(annotation: Any) -> bool:
68
+ """Return whether an annotation already allows None."""
69
+ if annotation is None or annotation is type(None):
70
+ return True
71
+
72
+ origin = get_origin(annotation)
73
+ args = get_args(annotation)
74
+
75
+ if origin is None:
76
+ return False
77
+
78
+ return type(None) in args
79
+
80
+
81
+ def make_optional(annotation: Any) -> Any:
82
+ """Return an annotation that allows None."""
83
+ if allows_none(annotation):
84
+ return annotation
85
+
86
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
87
+ return annotation
88
+
89
+ origin = get_origin(annotation)
90
+ if origin in (list, dict, tuple):
91
+ return annotation
92
+
93
+ return annotation | None
94
+
95
+
96
+ def strip_none(annotation: Any) -> Any:
97
+ """Remove None from an annotation if it is a union."""
98
+ origin = get_origin(annotation)
99
+ args = get_args(annotation)
100
+
101
+ if origin is None or type(None) not in args:
102
+ return annotation
103
+
104
+ non_none_args = tuple(arg for arg in args if arg is not type(None))
105
+ if not non_none_args:
106
+ return annotation
107
+ if len(non_none_args) == 1:
108
+ return non_none_args[0]
109
+ return reduce(or_, non_none_args)
110
+
111
+
112
+ def make_field_optional(annotation: Any, _default: object) -> CreateModelField:
113
+ """Return a create_model field definition where the field is optional."""
114
+ return make_optional(annotation), None
115
+
116
+
117
+ def make_field_required(annotation: Any, _default: object) -> CreateModelField:
118
+ """Return a create_model field definition where the field is required."""
119
+ return strip_none(annotation), ...
@@ -0,0 +1,51 @@
1
+ """Public Required API."""
2
+
3
+ from typing import cast
4
+
5
+ from generic_preserver.utils import canonical_key
6
+ from generic_preserver.wrapper import generic_preserver
7
+ from pydantic import BaseModel
8
+
9
+ from .classproperty import classproperty
10
+
11
+
12
+ @generic_preserver
13
+ class Operation[T: BaseModel]:
14
+ """Create a model where selected fields are required."""
15
+
16
+ @classproperty
17
+ def source_model(cls) -> type[BaseModel]:
18
+ """Return the source model bound to the generic operation type."""
19
+ # NOTE: `generic-preserver` allows us to retrieve the type instance passed to the generic class,
20
+ # which is how we get the source model.
21
+ #
22
+ # Whilst this is really convenient, `generic-preserver` intended us to access the generic
23
+ # arg via self[T]. However, since we are in the `__new__` part of the object instantiation
24
+ # lifecycle, the instance (self) has not yet been created, so we cannot access self[T].
25
+ #
26
+ # Instead, we can read the `generic-preserver` state directly from the class (cls) via
27
+ # `cls.__generic_map__`. Since the GenericMeta actually creates a new class for each unique
28
+ # set of generic args, the `cls` in this context is already the unique class, therefore we
29
+ # don't need to worry about conflicts between different generic args.
30
+ #
31
+ # Whilst this isn't ideal, and it should be noted that this isn't how `generic-preserver` is
32
+ # intended to be used, it works and allows us to have a really clean API for users of our library,
33
+ # so it's a worthwhile tradeoff IMO.
34
+ #
35
+ # It should also be noted that this relies on string serialisation of the generic arg (canonical_key(T))
36
+ # to retrieve the source model, which feels a bit hacky, but important to note that this canonical_key
37
+ # method comes from the same generic-preserver library, so I don't anticipate it breaking.
38
+ #
39
+ # Just being cautious since we've observed this happening between Python 3.11 and 3.12, where the string
40
+ # serialisation of generic args changed from "~T" to "T", respectively.
41
+ #
42
+ # However, since this is an internal implementation detail of our library, we can easily update our code
43
+ # to accommodate any changes in the string serialisation of generic args in future versions of Python, so
44
+ # it's not a major concern.
45
+ #
46
+ generic_map = cast(
47
+ dict[str, type[BaseModel]],
48
+ getattr(cls, "__generic_map__", None),
49
+ )
50
+ source_model = generic_map[canonical_key(T)]
51
+ return source_model
@@ -0,0 +1,88 @@
1
+ """Pydantic create_model payload helpers."""
2
+
3
+ from typing import Annotated, get_args, get_origin, get_type_hints
4
+
5
+ from pydantic import BaseModel, Discriminator, Field, create_model
6
+ from pydantic.fields import FieldInfo, PydanticUndefined
7
+
8
+ from ab_core.pydantic_patch.core.types import Any
9
+
10
+ type CreateModelField = tuple[Any, object]
11
+ type CreateModelPayload = dict[str, CreateModelField]
12
+
13
+
14
+ def unwrap_sqlalchemy_mapped(annotation: object) -> object:
15
+ """Unwrap SQLAlchemy Mapped[T] annotations to T when present."""
16
+ origin = get_origin(annotation)
17
+
18
+ if origin is None:
19
+ return annotation
20
+
21
+ if getattr(origin, "__name__", None) == "Mapped":
22
+ mapped_args = get_args(annotation)
23
+ if len(mapped_args) == 1:
24
+ return mapped_args[0]
25
+
26
+ return annotation
27
+
28
+
29
+ def clone_field_info(field_info: FieldInfo) -> FieldInfo:
30
+ """Return a shallow copy of FieldInfo suitable for mutation."""
31
+ return field_info._copy() # pyright: ignore[reportPrivateUsage]
32
+
33
+
34
+ def _extract_discriminator_metadata(field_info: FieldInfo) -> tuple[Discriminator, ...]:
35
+ return tuple(item for item in field_info.metadata if isinstance(item, Discriminator))
36
+
37
+
38
+ def build_payload_from_model(model: type[BaseModel]) -> CreateModelPayload:
39
+ """Build a create_model payload from model fields and relationships."""
40
+ payload: CreateModelPayload = {}
41
+
42
+ for field_name, field_info in model.model_fields.items():
43
+ annotation = field_info.annotation
44
+ discriminator_metadata = _extract_discriminator_metadata(field_info)
45
+
46
+ if discriminator_metadata:
47
+ annotation = Annotated[annotation, *discriminator_metadata]
48
+
49
+ field_copy = clone_field_info(field_info)
50
+
51
+ field_copy.default = PydanticUndefined if field_info.is_required() else field_info.default
52
+
53
+ payload[field_name] = (annotation, field_copy)
54
+
55
+ type_hints = get_type_hints(model, include_extras=True)
56
+ relationship_names = getattr(model, "__sqlmodel_relationships__", {})
57
+
58
+ for relationship_name in relationship_names:
59
+ if relationship_name in payload:
60
+ continue
61
+
62
+ payload[relationship_name] = (
63
+ unwrap_sqlalchemy_mapped(type_hints[relationship_name]),
64
+ Field(default=None),
65
+ )
66
+
67
+ return payload
68
+
69
+
70
+ def create_model_from_payload(
71
+ *,
72
+ source_model: type[BaseModel],
73
+ payload: CreateModelPayload,
74
+ name: str,
75
+ ) -> type[BaseModel]:
76
+ """Create a pydantic model from a prepared payload definition."""
77
+ created_model = create_model(
78
+ name,
79
+ __base__=BaseModel,
80
+ __module__=source_model.__module__,
81
+ **payload,
82
+ ) # ty:ignore[no-matching-overload]
83
+
84
+ for field_name, (annotation, _) in payload.items():
85
+ if get_origin(annotation) is Annotated:
86
+ created_model.model_fields[field_name].annotation = annotation
87
+
88
+ return created_model