ab-pydantic-patch 1.2.0__tar.gz → 1.2.2__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.
Files changed (60) hide show
  1. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/PKG-INFO +66 -1
  2. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/README.md +65 -0
  3. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/pyproject.toml +1 -1
  4. ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/core/computed_field_type_hints.py +109 -0
  5. ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/core/field_type_hints.py +62 -0
  6. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/fields.py +9 -7
  7. ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/core/orm_type_hints.py +54 -0
  8. ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/core/payload.py +44 -0
  9. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/type_hints.py +23 -0
  10. ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/examples/pydantic_examples/pydantic_computed_fields.py +139 -0
  11. ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/user.py +33 -0
  12. ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/examples/sqlmodel_examples/sqlmodel_computed_fields.py +114 -0
  13. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/orm_patch.py +96 -10
  14. ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/pydantic_jsonb.py +98 -0
  15. ab_pydantic_patch-1.2.0/src/ab_core/pydantic_patch/core/orm_type_hints.py +0 -42
  16. ab_pydantic_patch-1.2.0/src/ab_core/pydantic_patch/core/payload.py +0 -63
  17. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/.gitignore +0 -0
  18. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/LICENSE +0 -0
  19. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/__init__.py +0 -0
  20. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/__init__.py +0 -0
  21. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/cache.py +0 -0
  22. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/classproperty.py +0 -0
  23. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/config.py +0 -0
  24. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/errors.py +0 -0
  25. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/forward_references.py +0 -0
  26. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/operation.py +0 -0
  27. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/payload_types.py +0 -0
  28. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/transform.py +0 -0
  29. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/types.py +0 -0
  30. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/examples/__init__.py +0 -0
  31. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/examples/pydantic_examples/__init__.py +0 -0
  32. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/examples/pydantic_examples/pydantic_discriminated_union_api_schema.py +0 -0
  33. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/__init__.py +0 -0
  34. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/app_broken.py +0 -0
  35. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/app_resolved.py +0 -0
  36. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/__init__.py +0 -0
  37. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/project.py +0 -0
  38. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/project_milestone.py +0 -0
  39. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/project_task.py +0 -0
  40. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/task_comment.py +0 -0
  41. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/omit/__init__.py +0 -0
  42. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/omit/api.py +0 -0
  43. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/omit/config.py +0 -0
  44. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/omit/operation.py +0 -0
  45. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/partial/__init__.py +0 -0
  46. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/partial/api.py +0 -0
  47. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/partial/config.py +0 -0
  48. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/partial/operation.py +0 -0
  49. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/patch/__init__.py +0 -0
  50. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/patch/api.py +0 -0
  51. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/patch/config.py +0 -0
  52. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/patch/operation.py +0 -0
  53. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/pick/__init__.py +0 -0
  54. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/pick/api.py +0 -0
  55. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/pick/config.py +0 -0
  56. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/pick/operation.py +0 -0
  57. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/required/__init__.py +0 -0
  58. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/required/api.py +0 -0
  59. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/required/config.py +0 -0
  60. {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/required/operation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ab-pydantic-patch
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Python Pydantic support of TypeScript-style utility types, including Partial, Required, Pick, and Omit. Useful for PATCH endpoints driven from BaseModel / SQLModel classes.
5
5
  Author-email: Matt Coulter <mattcoul7@gmail.com>
6
6
  License-File: LICENSE
@@ -603,6 +603,71 @@ class HouseholdPatch(BaseModel):
603
603
 
604
604
  ---
605
605
 
606
+ ### Computed Fields
607
+
608
+ `pydantic-patch` supports Pydantic `@computed_field` values in generated models.
609
+
610
+ Computed fields are treated like regular fields for transformation purposes, so they can be selected, omitted, made optional, or made required using `Pick`, `Omit`, `Partial`, `Required`, and `Patch`.
611
+
612
+ #### Python
613
+
614
+ **Before**
615
+
616
+ ```python
617
+ from pydantic import BaseModel, computed_field
618
+
619
+
620
+ class User(BaseModel):
621
+ first_name: str
622
+ last_name: str
623
+
624
+ @computed_field
625
+ @property
626
+ def full_name(self) -> str:
627
+ return f"{self.first_name} {self.last_name}"
628
+ ```
629
+
630
+ ---
631
+
632
+ **Transform**
633
+
634
+ ```python
635
+ UserDisplay = Pick[User](fields={"full_name"})
636
+
637
+ UserPatch = Patch[User](
638
+ pick={"first_name", "full_name"},
639
+ partial={"first_name"},
640
+ required={"full_name"},
641
+ )
642
+ ```
643
+
644
+ ---
645
+
646
+ **After (conceptual)**
647
+
648
+ ```python
649
+ class UserDisplay(BaseModel):
650
+ full_name: str
651
+
652
+
653
+ class UserPatch(BaseModel):
654
+ first_name: str | None = None
655
+ full_name: str
656
+ ```
657
+
658
+ ---
659
+
660
+ Computed fields become part of the generated model payload, which means they:
661
+
662
+ * participate in `pick` / `omit`
663
+ * can be made optional with `partial`
664
+ * can be forced required with `required`
665
+ * work recursively inside nested transformed models
666
+
667
+ When using `recursive_patch_orm_scalar(...)`, computed fields from patch payloads are ignored unless they correspond to real mapped ORM scalar attributes or relationships.
668
+
669
+ ---
670
+
606
671
  ## Additional Notes
607
672
 
608
673
  ### Caching
@@ -590,6 +590,71 @@ class HouseholdPatch(BaseModel):
590
590
 
591
591
  ---
592
592
 
593
+ ### Computed Fields
594
+
595
+ `pydantic-patch` supports Pydantic `@computed_field` values in generated models.
596
+
597
+ Computed fields are treated like regular fields for transformation purposes, so they can be selected, omitted, made optional, or made required using `Pick`, `Omit`, `Partial`, `Required`, and `Patch`.
598
+
599
+ #### Python
600
+
601
+ **Before**
602
+
603
+ ```python
604
+ from pydantic import BaseModel, computed_field
605
+
606
+
607
+ class User(BaseModel):
608
+ first_name: str
609
+ last_name: str
610
+
611
+ @computed_field
612
+ @property
613
+ def full_name(self) -> str:
614
+ return f"{self.first_name} {self.last_name}"
615
+ ```
616
+
617
+ ---
618
+
619
+ **Transform**
620
+
621
+ ```python
622
+ UserDisplay = Pick[User](fields={"full_name"})
623
+
624
+ UserPatch = Patch[User](
625
+ pick={"first_name", "full_name"},
626
+ partial={"first_name"},
627
+ required={"full_name"},
628
+ )
629
+ ```
630
+
631
+ ---
632
+
633
+ **After (conceptual)**
634
+
635
+ ```python
636
+ class UserDisplay(BaseModel):
637
+ full_name: str
638
+
639
+
640
+ class UserPatch(BaseModel):
641
+ first_name: str | None = None
642
+ full_name: str
643
+ ```
644
+
645
+ ---
646
+
647
+ Computed fields become part of the generated model payload, which means they:
648
+
649
+ * participate in `pick` / `omit`
650
+ * can be made optional with `partial`
651
+ * can be forced required with `required`
652
+ * work recursively inside nested transformed models
653
+
654
+ When using `recursive_patch_orm_scalar(...)`, computed fields from patch payloads are ignored unless they correspond to real mapped ORM scalar attributes or relationships.
655
+
656
+ ---
657
+
593
658
  ## Additional Notes
594
659
 
595
660
  ### Caching
@@ -28,7 +28,7 @@ description = "Python Pydantic support of TypeScript-style utility types, includ
28
28
  name = "ab-pydantic-patch"
29
29
  readme = "README.md"
30
30
  requires-python = ">=3.12,<4.0"
31
- version = "1.2.0"
31
+ version = "1.2.2"
32
32
 
33
33
  [project.optional-dependencies]
34
34
  orm = [
@@ -0,0 +1,109 @@
1
+ """Computed-field type-hint and payload helpers."""
2
+
3
+ from collections.abc import Callable, Iterator
4
+ from functools import cached_property
5
+ from inspect import get_annotations
6
+ from typing import cast, get_type_hints
7
+
8
+ from pydantic import BaseModel, Field
9
+ from pydantic.fields import ComputedFieldInfo, FieldInfo, PydanticUndefined
10
+
11
+ from ab_core.pydantic_patch.core.forward_references import contains_forward_ref
12
+ from ab_core.pydantic_patch.core.payload_types import CreateModelPayload
13
+ from ab_core.pydantic_patch.core.types import Any
14
+
15
+
16
+ def iter_computed_field_infos(
17
+ model: type[BaseModel],
18
+ ) -> Iterator[tuple[str, ComputedFieldInfo]]:
19
+ """Yield computed-field names and metadata for a model."""
20
+ yield from model.model_computed_fields.items()
21
+
22
+
23
+ def get_computed_field_getter(
24
+ computed_field_info: ComputedFieldInfo,
25
+ ) -> Callable[..., object]:
26
+ """Return the underlying getter function for a computed field."""
27
+ wrapped_property = computed_field_info.wrapped_property
28
+
29
+ if isinstance(wrapped_property, property):
30
+ if wrapped_property.fget is None:
31
+ raise TypeError("Computed field property does not define a getter.")
32
+ return wrapped_property.fget
33
+
34
+ if isinstance(wrapped_property, cached_property):
35
+ return wrapped_property.func
36
+
37
+ raise TypeError(f"Unsupported computed field wrapper type: {type(wrapped_property).__name__}.")
38
+
39
+
40
+ def get_raw_computed_field_return_annotation(
41
+ computed_field_info: ComputedFieldInfo,
42
+ ) -> object:
43
+ """Return the raw computed-field return annotation without evaluating strings."""
44
+ if computed_field_info.return_type is not PydanticUndefined:
45
+ return computed_field_info.return_type
46
+
47
+ getter = get_computed_field_getter(computed_field_info)
48
+ annotations = get_annotations(getter, eval_str=False)
49
+
50
+ return annotations.get("return", PydanticUndefined)
51
+
52
+
53
+ def get_resolved_computed_field_return_annotation(
54
+ computed_field_info: ComputedFieldInfo,
55
+ ) -> Any:
56
+ """Return the resolved computed-field return annotation for payload generation."""
57
+ if computed_field_info.return_type is not PydanticUndefined:
58
+ return computed_field_info.return_type
59
+
60
+ getter = get_computed_field_getter(computed_field_info)
61
+ resolved_annotations = get_type_hints(getter, include_extras=True)
62
+
63
+ return resolved_annotations.get("return", Any)
64
+
65
+
66
+ def get_computed_field_return_annotation(
67
+ computed_field_info: ComputedFieldInfo,
68
+ ) -> Any:
69
+ """Return the computed-field return annotation."""
70
+ return get_resolved_computed_field_return_annotation(computed_field_info)
71
+
72
+
73
+ def computed_field_contains_forward_ref(
74
+ computed_field_info: ComputedFieldInfo,
75
+ ) -> bool:
76
+ """Return whether a computed field has an unresolved return annotation."""
77
+ return contains_forward_ref(get_raw_computed_field_return_annotation(computed_field_info))
78
+
79
+
80
+ def create_computed_field_info(
81
+ computed_field_info: ComputedFieldInfo,
82
+ ) -> FieldInfo:
83
+ """Create a concrete pydantic field definition from a computed field."""
84
+ return cast(
85
+ FieldInfo,
86
+ Field(
87
+ default=PydanticUndefined,
88
+ alias=computed_field_info.alias,
89
+ title=computed_field_info.title,
90
+ description=computed_field_info.description,
91
+ examples=computed_field_info.examples,
92
+ json_schema_extra=computed_field_info.json_schema_extra,
93
+ ),
94
+ )
95
+
96
+
97
+ def apply_computed_fields_to_payload(
98
+ model: type[BaseModel],
99
+ payload: CreateModelPayload,
100
+ ) -> None:
101
+ """Insert computed fields into a create_model payload."""
102
+ for field_name, computed_field_info in iter_computed_field_infos(model):
103
+ if field_name in payload:
104
+ continue
105
+
106
+ payload[field_name] = (
107
+ get_resolved_computed_field_return_annotation(computed_field_info),
108
+ create_computed_field_info(computed_field_info),
109
+ )
@@ -0,0 +1,62 @@
1
+ """Pydantic model-field type-hint and payload helpers."""
2
+
3
+ from collections.abc import Iterator
4
+ from typing import Annotated, get_origin
5
+
6
+ from pydantic import BaseModel, Discriminator
7
+ from pydantic.fields import FieldInfo, PydanticUndefined
8
+
9
+ from ab_core.pydantic_patch.core.payload_types import CreateModelPayload
10
+ from ab_core.pydantic_patch.core.types import Any
11
+
12
+
13
+ def iter_model_field_infos(
14
+ model: type[BaseModel],
15
+ ) -> Iterator[tuple[str, FieldInfo]]:
16
+ """Yield concrete pydantic model-field names and metadata."""
17
+ yield from model.model_fields.items()
18
+
19
+
20
+ def clone_field_info(field_info: FieldInfo) -> FieldInfo:
21
+ """Return a shallow copy of FieldInfo suitable for mutation."""
22
+ return field_info._copy() # pyright: ignore[reportPrivateUsage]
23
+
24
+
25
+ def extract_discriminator_metadata(field_info: FieldInfo) -> tuple[Discriminator, ...]:
26
+ """Return discriminator metadata attached to a field."""
27
+ return tuple(item for item in field_info.metadata if isinstance(item, Discriminator))
28
+
29
+
30
+ def get_model_field_annotation(
31
+ field_name: str,
32
+ field_info: FieldInfo,
33
+ resolved_type_hints: dict[str, object],
34
+ ) -> Any:
35
+ """Return the resolved annotation for a normal pydantic model field."""
36
+ annotation: Any = resolved_type_hints.get(field_name, field_info.annotation)
37
+ discriminator_metadata = extract_discriminator_metadata(field_info)
38
+
39
+ if discriminator_metadata and get_origin(annotation) is not Annotated:
40
+ return Annotated[annotation, *discriminator_metadata]
41
+
42
+ return annotation
43
+
44
+
45
+ def create_model_field_info(field_info: FieldInfo) -> FieldInfo:
46
+ """Create the field info to use in a generated payload."""
47
+ field_copy = clone_field_info(field_info)
48
+ field_copy.default = PydanticUndefined if field_info.is_required() else field_info.default
49
+ return field_copy
50
+
51
+
52
+ def apply_model_fields_to_payload(
53
+ model: type[BaseModel],
54
+ resolved_type_hints: dict[str, object],
55
+ payload: CreateModelPayload,
56
+ ) -> None:
57
+ """Insert normal pydantic model fields into a create_model payload."""
58
+ for field_name, field_info in iter_model_field_infos(model):
59
+ payload[field_name] = (
60
+ get_model_field_annotation(field_name, field_info, resolved_type_hints),
61
+ create_model_field_info(field_info),
62
+ )
@@ -6,22 +6,24 @@ from typing import get_args, get_origin
6
6
 
7
7
  from pydantic import BaseModel
8
8
 
9
+ from ab_core.pydantic_patch.core.computed_field_type_hints import iter_computed_field_infos
9
10
  from ab_core.pydantic_patch.core.errors import (
10
11
  ConflictingPatchConfigError,
11
12
  InvalidPatchFieldError,
12
13
  )
14
+ from ab_core.pydantic_patch.core.field_type_hints import iter_model_field_infos
15
+ from ab_core.pydantic_patch.core.orm_type_hints import iter_orm_relationship_names
13
16
  from ab_core.pydantic_patch.core.payload_types import CreateModelField, CreateModelPayload
14
17
  from ab_core.pydantic_patch.core.types import Any
15
18
 
16
19
 
17
20
  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
21
+ """Return transformable source field names."""
22
+ return {
23
+ *(field_name for field_name, _ in iter_model_field_infos(model)),
24
+ *(field_name for field_name, _ in iter_computed_field_infos(model)),
25
+ *iter_orm_relationship_names(model),
26
+ }
25
27
 
26
28
 
27
29
  def validate_fields_exist_on_model(
@@ -0,0 +1,54 @@
1
+ """SQLModel relationship type-hint and payload helpers."""
2
+
3
+ from collections.abc import Iterator
4
+ from typing import get_args, get_origin
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from ab_core.pydantic_patch.core.types import Any
9
+
10
+ from .payload_types import CreateModelPayload
11
+
12
+
13
+ def iter_orm_relationship_names(model: type[BaseModel]) -> Iterator[str]:
14
+ """Yield SQLModel relationship field names for a model."""
15
+ yield from getattr(model, "__sqlmodel_relationships__", {})
16
+
17
+
18
+ def unwrap_sqlalchemy_mapped(annotation: object) -> object:
19
+ """Unwrap SQLAlchemy Mapped[T] annotations to T when present."""
20
+ origin = get_origin(annotation)
21
+
22
+ if origin is None:
23
+ return annotation
24
+
25
+ if getattr(origin, "__name__", None) == "Mapped":
26
+ mapped_args = get_args(annotation)
27
+ if len(mapped_args) == 1:
28
+ return mapped_args[0]
29
+
30
+ return annotation
31
+
32
+
33
+ def get_orm_relationship_annotation(
34
+ relationship_name: str,
35
+ resolved_type_hints: dict[str, Any],
36
+ ) -> object:
37
+ """Return the resolved annotation for a SQLModel relationship."""
38
+ return unwrap_sqlalchemy_mapped(resolved_type_hints[relationship_name])
39
+
40
+
41
+ def apply_orm_relationships_to_payload(
42
+ model: type[BaseModel],
43
+ resolved_type_hints: dict[str, Any],
44
+ payload: CreateModelPayload,
45
+ ) -> None:
46
+ """Insert SQLModel relationship fields into a create_model payload."""
47
+ for relationship_name in iter_orm_relationship_names(model):
48
+ if relationship_name in payload:
49
+ continue
50
+
51
+ payload[relationship_name] = (
52
+ get_orm_relationship_annotation(relationship_name, resolved_type_hints),
53
+ Field(default=None),
54
+ )
@@ -0,0 +1,44 @@
1
+ """Pydantic create_model payload aggregation helpers."""
2
+
3
+ from typing import Annotated, get_origin
4
+
5
+ from pydantic import BaseModel, create_model
6
+
7
+ from .computed_field_type_hints import apply_computed_fields_to_payload
8
+ from .field_type_hints import apply_model_fields_to_payload
9
+ from .orm_type_hints import apply_orm_relationships_to_payload
10
+ from .payload_types import CreateModelPayload
11
+ from .type_hints import get_resolved_type_hints
12
+
13
+
14
+ def build_payload_from_model(model: type[BaseModel]) -> CreateModelPayload:
15
+ """Build a create_model payload from fields, computed fields, and relationships."""
16
+ payload: CreateModelPayload = {}
17
+ resolved_type_hints = get_resolved_type_hints(model)
18
+
19
+ apply_model_fields_to_payload(model, resolved_type_hints, payload)
20
+ apply_computed_fields_to_payload(model, payload)
21
+ apply_orm_relationships_to_payload(model, resolved_type_hints, payload)
22
+
23
+ return payload
24
+
25
+
26
+ def create_model_from_payload(
27
+ *,
28
+ source_model: type[BaseModel],
29
+ payload: CreateModelPayload,
30
+ name: str,
31
+ ) -> type[BaseModel]:
32
+ """Create a pydantic model from a prepared payload definition."""
33
+ created_model = create_model(
34
+ name,
35
+ __base__=BaseModel,
36
+ __module__=source_model.__module__,
37
+ **payload,
38
+ ) # ty:ignore[no-matching-overload]
39
+
40
+ for field_name, (annotation, _) in payload.items():
41
+ if get_origin(annotation) is Annotated:
42
+ created_model.model_fields[field_name].annotation = annotation
43
+
44
+ return created_model
@@ -4,6 +4,10 @@ from typing import get_type_hints
4
4
 
5
5
  from pydantic import BaseModel
6
6
 
7
+ from .computed_field_type_hints import (
8
+ computed_field_contains_forward_ref,
9
+ iter_computed_field_infos,
10
+ )
7
11
  from .errors import ForwardReferencesNotSupported
8
12
  from .forward_references import build_forward_ref_error_message
9
13
 
@@ -21,6 +25,25 @@ def get_resolved_type_hints(model: type[BaseModel]) -> dict[str, object]:
21
25
  ) from error
22
26
 
23
27
 
28
+ def unresolved_computed_field_names(model: type[BaseModel]) -> list[str]:
29
+ """Return computed fields with unresolved return annotations."""
30
+ return sorted(
31
+ field_name
32
+ for field_name, computed_field_info in iter_computed_field_infos(model)
33
+ if computed_field_contains_forward_ref(computed_field_info)
34
+ )
35
+
36
+
24
37
  def assert_no_forward_refs(model: type[BaseModel]) -> None:
25
38
  """Raise only when forward references cannot actually be resolved."""
26
39
  get_resolved_type_hints(model)
40
+
41
+ unresolved_computed_fields = unresolved_computed_field_names(model)
42
+
43
+ if unresolved_computed_fields:
44
+ raise ForwardReferencesNotSupported(
45
+ build_forward_ref_error_message(
46
+ model=model,
47
+ unresolved_fields=unresolved_computed_fields,
48
+ )
49
+ )
@@ -0,0 +1,139 @@
1
+ """Pydantic computed-field patch example."""
2
+
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+
6
+ from fastapi import FastAPI, HTTPException
7
+ from pydantic import BaseModel, computed_field
8
+
9
+ from ab_core.pydantic_patch.orm_patch import recursive_patch_scalar
10
+ from ab_core.pydantic_patch.patch import Patch
11
+
12
+ ENTITY_ID = 1
13
+
14
+
15
+ # =========================
16
+ # MODELS
17
+ # =========================
18
+
19
+
20
+ class User(BaseModel):
21
+ id: int
22
+ first_name: str
23
+ last_name: str
24
+ age: int
25
+
26
+ @computed_field
27
+ @property
28
+ def full_name(self) -> str:
29
+ """Return the user's full name."""
30
+ return f"{self.first_name} {self.last_name}"
31
+
32
+ @computed_field
33
+ @property
34
+ def is_adult(self) -> bool:
35
+ """Return whether the user is an adult."""
36
+ return self.age >= 18
37
+
38
+
39
+ # =========================
40
+ # PATCH MODEL
41
+ # =========================
42
+
43
+ UserPatch = Patch[User](
44
+ pick={
45
+ "first_name",
46
+ "last_name",
47
+ "full_name",
48
+ },
49
+ partial={
50
+ "first_name",
51
+ "last_name",
52
+ },
53
+ required={
54
+ "full_name",
55
+ },
56
+ )
57
+
58
+
59
+ # =========================
60
+ # STORE
61
+ # =========================
62
+
63
+ USERS: dict[int, User] = {}
64
+
65
+
66
+ def seed() -> None:
67
+ USERS.setdefault(
68
+ ENTITY_ID,
69
+ User(
70
+ id=ENTITY_ID,
71
+ first_name="Monique",
72
+ last_name="Kuhn",
73
+ age=28,
74
+ ),
75
+ )
76
+
77
+
78
+ @asynccontextmanager
79
+ async def lifespan(_app: FastAPI):
80
+ seed()
81
+ yield
82
+
83
+
84
+ app = FastAPI(lifespan=lifespan)
85
+
86
+
87
+ # =========================
88
+ # API
89
+ # =========================
90
+
91
+
92
+ @app.get("/users/{user_id}", response_model=User)
93
+ def get_user(user_id: int) -> User:
94
+ user = USERS.get(user_id)
95
+
96
+ if user is None:
97
+ raise HTTPException(status_code=404, detail="User not found")
98
+
99
+ return user
100
+
101
+
102
+ @app.patch("/users/{user_id}", response_model=User)
103
+ def patch_user(
104
+ user_id: int,
105
+ patch: UserPatch,
106
+ ) -> User:
107
+ user = USERS.get(user_id)
108
+
109
+ if user is None:
110
+ raise HTTPException(status_code=404, detail="User not found")
111
+
112
+ recursive_patch_scalar(user, patch)
113
+
114
+ return user
115
+
116
+
117
+ # =========================
118
+ # RUN
119
+ # =========================
120
+
121
+
122
+ def get_module_path(file_path: str) -> str:
123
+ parts = Path(file_path).resolve().with_suffix("").relative_to(Path.cwd()).parts
124
+
125
+ if parts[0] == "src":
126
+ parts = parts[1:]
127
+
128
+ return ".".join(parts)
129
+
130
+
131
+ if __name__ == "__main__":
132
+ import uvicorn
133
+
134
+ uvicorn.run(
135
+ f"{get_module_path(__file__)}:app",
136
+ host="0.0.0.0",
137
+ port=8000,
138
+ reload=False,
139
+ )
@@ -0,0 +1,33 @@
1
+ """SQLModel computed-field patch example."""
2
+
3
+ from pydantic import computed_field
4
+ from sqlmodel import Field, SQLModel
5
+
6
+ ENTITY_ID = 1
7
+
8
+
9
+ # =========================
10
+ # MODELS
11
+ # =========================
12
+
13
+
14
+ class User(SQLModel, table=True):
15
+ __tablename__ = "computed_field_user"
16
+
17
+ id: int | None = Field(default=None, primary_key=True)
18
+
19
+ first_name: str
20
+ last_name: str
21
+ email: str
22
+
23
+ @computed_field
24
+ @property
25
+ def full_name(self) -> str:
26
+ """Return the user's full name."""
27
+ return f"{self.first_name} {self.last_name}"
28
+
29
+ @computed_field
30
+ @property
31
+ def email_domain(self) -> str:
32
+ """Return the email domain portion."""
33
+ return self.email.split("@")[-1]