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.
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/PKG-INFO +66 -1
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/README.md +65 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/pyproject.toml +1 -1
- ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/core/computed_field_type_hints.py +109 -0
- ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/core/field_type_hints.py +62 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/fields.py +9 -7
- ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/core/orm_type_hints.py +54 -0
- ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/core/payload.py +44 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/type_hints.py +23 -0
- ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/examples/pydantic_examples/pydantic_computed_fields.py +139 -0
- ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/user.py +33 -0
- ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/examples/sqlmodel_examples/sqlmodel_computed_fields.py +114 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/orm_patch.py +96 -10
- ab_pydantic_patch-1.2.2/src/ab_core/pydantic_patch/pydantic_jsonb.py +98 -0
- ab_pydantic_patch-1.2.0/src/ab_core/pydantic_patch/core/orm_type_hints.py +0 -42
- ab_pydantic_patch-1.2.0/src/ab_core/pydantic_patch/core/payload.py +0 -63
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/.gitignore +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/LICENSE +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/__init__.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/__init__.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/cache.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/classproperty.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/config.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/errors.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/forward_references.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/operation.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/payload_types.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/transform.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/types.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/examples/__init__.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/examples/pydantic_examples/__init__.py +0 -0
- {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
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/__init__.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/omit/__init__.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/omit/api.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/omit/config.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/omit/operation.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/partial/__init__.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/partial/api.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/partial/config.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/partial/operation.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/patch/__init__.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/patch/api.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/patch/config.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/patch/operation.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/pick/__init__.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/pick/api.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/pick/config.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/pick/operation.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/required/__init__.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/required/api.py +0 -0
- {ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/required/config.py +0 -0
- {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.
|
|
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.
|
|
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
|
+
)
|
{ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/fields.py
RENAMED
|
@@ -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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
{ab_pydantic_patch-1.2.0 → ab_pydantic_patch-1.2.2}/src/ab_core/pydantic_patch/core/type_hints.py
RENAMED
|
@@ -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]
|