ab-pydantic-patch 1.2.2__tar.gz → 1.2.4__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.2 → ab_pydantic_patch-1.2.4}/PKG-INFO +129 -6
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/README.md +128 -5
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/pyproject.toml +1 -1
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/computed_field_type_hints.py +28 -5
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/forward_references.py +48 -18
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/transform.py +84 -20
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/type_hints.py +14 -4
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/__init__.py +2 -0
- ab_pydantic_patch-1.2.4/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/quote_line_item.py +23 -0
- ab_pydantic_patch-1.2.4/src/ab_core/pydantic_patch/examples/sqlmodel_examples/self_referencing_tree.py +172 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/orm_patch.py +21 -4
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/.gitignore +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/LICENSE +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/__init__.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/__init__.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/cache.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/classproperty.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/config.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/errors.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/field_type_hints.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/fields.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/operation.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/orm_type_hints.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/payload.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/payload_types.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/types.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/__init__.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/pydantic_examples/__init__.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/pydantic_examples/pydantic_computed_fields.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/pydantic_examples/pydantic_discriminated_union_api_schema.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/__init__.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/app_broken.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/app_resolved.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/project.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/project_milestone.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/project_task.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/task_comment.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/user.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/sqlmodel_computed_fields.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/omit/__init__.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/omit/api.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/omit/config.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/omit/operation.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/partial/__init__.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/partial/api.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/partial/config.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/partial/operation.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/patch/__init__.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/patch/api.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/patch/config.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/patch/operation.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/pick/__init__.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/pick/api.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/pick/config.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/pick/operation.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/pydantic_jsonb.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/required/__init__.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/required/api.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/required/config.py +0 -0
- {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/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.4
|
|
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
|
|
@@ -717,7 +717,8 @@ Applied in this order:
|
|
|
717
717
|
|
|
718
718
|
### Forward references
|
|
719
719
|
|
|
720
|
-
`pydantic-patch`
|
|
720
|
+
`pydantic-patch` automatically resolves forward references among already
|
|
721
|
+
imported sibling model classes.
|
|
721
722
|
|
|
722
723
|
Because the library is type-driven, it needs real Python types when generating
|
|
723
724
|
`Pick`, `Omit`, `Partial`, `Required`, or `Patch` models. This commonly affects
|
|
@@ -734,12 +735,15 @@ class Project(SQLModel, table=True):
|
|
|
734
735
|
milestones: list["ProjectMilestone"] = Relationship(back_populates="project")
|
|
735
736
|
```
|
|
736
737
|
|
|
737
|
-
Calling `Patch[Project](...)`
|
|
738
|
+
Calling `Patch[Project](...)` works when the referenced sibling models have
|
|
739
|
+
already been imported somewhere in the same package/module tree. If the
|
|
740
|
+
referenced model has not been imported yet, import your model package first or
|
|
741
|
+
bind the missing reference manually.
|
|
742
|
+
|
|
743
|
+
For genuinely missing types, `pydantic-patch` still raises
|
|
738
744
|
`ForwardReferencesNotSupported`.
|
|
739
745
|
|
|
740
|
-
|
|
741
|
-
references onto each module, then call `model_rebuild(force=True)` before
|
|
742
|
-
creating the patch model.
|
|
746
|
+
Manual fallback:
|
|
743
747
|
|
|
744
748
|
```python
|
|
745
749
|
import my_app.models.project as project_module
|
|
@@ -843,6 +847,125 @@ db_session.commit()
|
|
|
843
847
|
This is especially useful for FastAPI PATCH endpoints backed by SQLModel
|
|
844
848
|
relationships.
|
|
845
849
|
|
|
850
|
+
### Self-referencing 1..many trees
|
|
851
|
+
|
|
852
|
+
`pydantic-patch` supports recursive parent/child tree layouts where a model
|
|
853
|
+
contains a list of children of the same model type.
|
|
854
|
+
|
|
855
|
+
This is useful for quote line items, category trees, bill-of-materials trees,
|
|
856
|
+
nested tasks, comments, folders, and other hierarchical data.
|
|
857
|
+
|
|
858
|
+
#### Python
|
|
859
|
+
|
|
860
|
+
```python
|
|
861
|
+
from sqlmodel import Field, Relationship, SQLModel
|
|
862
|
+
|
|
863
|
+
from ab_core.pydantic_patch.orm_patch import recursive_patch_orm_scalar
|
|
864
|
+
from ab_core.pydantic_patch.patch import Patch, PatchConfig
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
class QuoteLineItem(SQLModel, table=True):
|
|
868
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
869
|
+
parent_id: int | None = Field(default=None, foreign_key="quote_line_item.id")
|
|
870
|
+
|
|
871
|
+
line_item_name: str = ""
|
|
872
|
+
quoted_base_cost: float = 0.0
|
|
873
|
+
|
|
874
|
+
parent: "QuoteLineItem" = Relationship(
|
|
875
|
+
back_populates="children",
|
|
876
|
+
sa_relationship_kwargs={
|
|
877
|
+
"remote_side": "QuoteLineItem.id",
|
|
878
|
+
},
|
|
879
|
+
)
|
|
880
|
+
children: list["QuoteLineItem"] = Relationship(back_populates="parent")
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
QuoteLineItem.model_rebuild(force=True)
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
For SQLModel relationships, keep the relationship target annotation as
|
|
887
|
+
`"QuoteLineItem"` rather than `"QuoteLineItem | None"` so SQLAlchemy can resolve
|
|
888
|
+
the mapped class name.
|
|
889
|
+
|
|
890
|
+
#### Transform
|
|
891
|
+
|
|
892
|
+
```python
|
|
893
|
+
QuoteLineItemPatch = Patch[QuoteLineItem](
|
|
894
|
+
name="QuoteLineItemPatch",
|
|
895
|
+
pick={
|
|
896
|
+
"id",
|
|
897
|
+
"line_item_name",
|
|
898
|
+
"quoted_base_cost",
|
|
899
|
+
"children",
|
|
900
|
+
},
|
|
901
|
+
partial={
|
|
902
|
+
"id",
|
|
903
|
+
"line_item_name",
|
|
904
|
+
"quoted_base_cost",
|
|
905
|
+
"children",
|
|
906
|
+
},
|
|
907
|
+
child_models={
|
|
908
|
+
QuoteLineItem: PatchConfig(
|
|
909
|
+
pick={
|
|
910
|
+
"id",
|
|
911
|
+
"line_item_name",
|
|
912
|
+
"quoted_base_cost",
|
|
913
|
+
"children",
|
|
914
|
+
},
|
|
915
|
+
partial={
|
|
916
|
+
"id",
|
|
917
|
+
"line_item_name",
|
|
918
|
+
"quoted_base_cost",
|
|
919
|
+
"children",
|
|
920
|
+
},
|
|
921
|
+
),
|
|
922
|
+
},
|
|
923
|
+
)
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
#### Apply to an ORM object graph
|
|
927
|
+
|
|
928
|
+
```python
|
|
929
|
+
line_item = db_session.get(QuoteLineItem, line_item_id)
|
|
930
|
+
|
|
931
|
+
patch = QuoteLineItemPatch.model_validate(
|
|
932
|
+
{
|
|
933
|
+
"id": line_item_id,
|
|
934
|
+
"line_item_name": "Colorbond fence",
|
|
935
|
+
"children": [
|
|
936
|
+
{
|
|
937
|
+
"id": 10,
|
|
938
|
+
"line_item_name": "Colorbond panels",
|
|
939
|
+
"quoted_base_cost": 725.0,
|
|
940
|
+
},
|
|
941
|
+
{
|
|
942
|
+
"line_item_name": "New gate allowance",
|
|
943
|
+
"quoted_base_cost": 300.0,
|
|
944
|
+
},
|
|
945
|
+
],
|
|
946
|
+
}
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
recursive_patch_orm_scalar(line_item, patch)
|
|
950
|
+
|
|
951
|
+
db_session.add(line_item)
|
|
952
|
+
db_session.commit()
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
In this layout:
|
|
956
|
+
|
|
957
|
+
* child rows with an `id` are patched onto matching existing ORM children
|
|
958
|
+
* child rows without an `id` are treated as new children
|
|
959
|
+
* omitted fields are left unchanged
|
|
960
|
+
* nested `children` can recursively patch deeper descendants
|
|
961
|
+
* the generated recursive patch model can be reused in FastAPI request bodies
|
|
962
|
+
|
|
963
|
+
For a runnable example, see:
|
|
964
|
+
|
|
965
|
+
```shell
|
|
966
|
+
uv run python src/ab_core/pydantic_patch/examples/sqlmodel_examples/self_referencing_tree.py
|
|
967
|
+
```
|
|
968
|
+
|
|
846
969
|
______________________________________________________________________
|
|
847
970
|
|
|
848
971
|
## Formatting and linting
|
|
@@ -704,7 +704,8 @@ Applied in this order:
|
|
|
704
704
|
|
|
705
705
|
### Forward references
|
|
706
706
|
|
|
707
|
-
`pydantic-patch`
|
|
707
|
+
`pydantic-patch` automatically resolves forward references among already
|
|
708
|
+
imported sibling model classes.
|
|
708
709
|
|
|
709
710
|
Because the library is type-driven, it needs real Python types when generating
|
|
710
711
|
`Pick`, `Omit`, `Partial`, `Required`, or `Patch` models. This commonly affects
|
|
@@ -721,12 +722,15 @@ class Project(SQLModel, table=True):
|
|
|
721
722
|
milestones: list["ProjectMilestone"] = Relationship(back_populates="project")
|
|
722
723
|
```
|
|
723
724
|
|
|
724
|
-
Calling `Patch[Project](...)`
|
|
725
|
+
Calling `Patch[Project](...)` works when the referenced sibling models have
|
|
726
|
+
already been imported somewhere in the same package/module tree. If the
|
|
727
|
+
referenced model has not been imported yet, import your model package first or
|
|
728
|
+
bind the missing reference manually.
|
|
729
|
+
|
|
730
|
+
For genuinely missing types, `pydantic-patch` still raises
|
|
725
731
|
`ForwardReferencesNotSupported`.
|
|
726
732
|
|
|
727
|
-
|
|
728
|
-
references onto each module, then call `model_rebuild(force=True)` before
|
|
729
|
-
creating the patch model.
|
|
733
|
+
Manual fallback:
|
|
730
734
|
|
|
731
735
|
```python
|
|
732
736
|
import my_app.models.project as project_module
|
|
@@ -830,6 +834,125 @@ db_session.commit()
|
|
|
830
834
|
This is especially useful for FastAPI PATCH endpoints backed by SQLModel
|
|
831
835
|
relationships.
|
|
832
836
|
|
|
837
|
+
### Self-referencing 1..many trees
|
|
838
|
+
|
|
839
|
+
`pydantic-patch` supports recursive parent/child tree layouts where a model
|
|
840
|
+
contains a list of children of the same model type.
|
|
841
|
+
|
|
842
|
+
This is useful for quote line items, category trees, bill-of-materials trees,
|
|
843
|
+
nested tasks, comments, folders, and other hierarchical data.
|
|
844
|
+
|
|
845
|
+
#### Python
|
|
846
|
+
|
|
847
|
+
```python
|
|
848
|
+
from sqlmodel import Field, Relationship, SQLModel
|
|
849
|
+
|
|
850
|
+
from ab_core.pydantic_patch.orm_patch import recursive_patch_orm_scalar
|
|
851
|
+
from ab_core.pydantic_patch.patch import Patch, PatchConfig
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
class QuoteLineItem(SQLModel, table=True):
|
|
855
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
856
|
+
parent_id: int | None = Field(default=None, foreign_key="quote_line_item.id")
|
|
857
|
+
|
|
858
|
+
line_item_name: str = ""
|
|
859
|
+
quoted_base_cost: float = 0.0
|
|
860
|
+
|
|
861
|
+
parent: "QuoteLineItem" = Relationship(
|
|
862
|
+
back_populates="children",
|
|
863
|
+
sa_relationship_kwargs={
|
|
864
|
+
"remote_side": "QuoteLineItem.id",
|
|
865
|
+
},
|
|
866
|
+
)
|
|
867
|
+
children: list["QuoteLineItem"] = Relationship(back_populates="parent")
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
QuoteLineItem.model_rebuild(force=True)
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
For SQLModel relationships, keep the relationship target annotation as
|
|
874
|
+
`"QuoteLineItem"` rather than `"QuoteLineItem | None"` so SQLAlchemy can resolve
|
|
875
|
+
the mapped class name.
|
|
876
|
+
|
|
877
|
+
#### Transform
|
|
878
|
+
|
|
879
|
+
```python
|
|
880
|
+
QuoteLineItemPatch = Patch[QuoteLineItem](
|
|
881
|
+
name="QuoteLineItemPatch",
|
|
882
|
+
pick={
|
|
883
|
+
"id",
|
|
884
|
+
"line_item_name",
|
|
885
|
+
"quoted_base_cost",
|
|
886
|
+
"children",
|
|
887
|
+
},
|
|
888
|
+
partial={
|
|
889
|
+
"id",
|
|
890
|
+
"line_item_name",
|
|
891
|
+
"quoted_base_cost",
|
|
892
|
+
"children",
|
|
893
|
+
},
|
|
894
|
+
child_models={
|
|
895
|
+
QuoteLineItem: PatchConfig(
|
|
896
|
+
pick={
|
|
897
|
+
"id",
|
|
898
|
+
"line_item_name",
|
|
899
|
+
"quoted_base_cost",
|
|
900
|
+
"children",
|
|
901
|
+
},
|
|
902
|
+
partial={
|
|
903
|
+
"id",
|
|
904
|
+
"line_item_name",
|
|
905
|
+
"quoted_base_cost",
|
|
906
|
+
"children",
|
|
907
|
+
},
|
|
908
|
+
),
|
|
909
|
+
},
|
|
910
|
+
)
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
#### Apply to an ORM object graph
|
|
914
|
+
|
|
915
|
+
```python
|
|
916
|
+
line_item = db_session.get(QuoteLineItem, line_item_id)
|
|
917
|
+
|
|
918
|
+
patch = QuoteLineItemPatch.model_validate(
|
|
919
|
+
{
|
|
920
|
+
"id": line_item_id,
|
|
921
|
+
"line_item_name": "Colorbond fence",
|
|
922
|
+
"children": [
|
|
923
|
+
{
|
|
924
|
+
"id": 10,
|
|
925
|
+
"line_item_name": "Colorbond panels",
|
|
926
|
+
"quoted_base_cost": 725.0,
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
"line_item_name": "New gate allowance",
|
|
930
|
+
"quoted_base_cost": 300.0,
|
|
931
|
+
},
|
|
932
|
+
],
|
|
933
|
+
}
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
recursive_patch_orm_scalar(line_item, patch)
|
|
937
|
+
|
|
938
|
+
db_session.add(line_item)
|
|
939
|
+
db_session.commit()
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
In this layout:
|
|
943
|
+
|
|
944
|
+
* child rows with an `id` are patched onto matching existing ORM children
|
|
945
|
+
* child rows without an `id` are treated as new children
|
|
946
|
+
* omitted fields are left unchanged
|
|
947
|
+
* nested `children` can recursively patch deeper descendants
|
|
948
|
+
* the generated recursive patch model can be reused in FastAPI request bodies
|
|
949
|
+
|
|
950
|
+
For a runnable example, see:
|
|
951
|
+
|
|
952
|
+
```shell
|
|
953
|
+
uv run python src/ab_core/pydantic_patch/examples/sqlmodel_examples/self_referencing_tree.py
|
|
954
|
+
```
|
|
955
|
+
|
|
833
956
|
______________________________________________________________________
|
|
834
957
|
|
|
835
958
|
## Formatting and linting
|
|
@@ -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.4"
|
|
32
32
|
|
|
33
33
|
[project.optional-dependencies]
|
|
34
34
|
orm = [
|
|
@@ -8,7 +8,10 @@ from typing import cast, get_type_hints
|
|
|
8
8
|
from pydantic import BaseModel, Field
|
|
9
9
|
from pydantic.fields import ComputedFieldInfo, FieldInfo, PydanticUndefined
|
|
10
10
|
|
|
11
|
-
from ab_core.pydantic_patch.core.forward_references import
|
|
11
|
+
from ab_core.pydantic_patch.core.forward_references import (
|
|
12
|
+
build_type_hints_namespaces,
|
|
13
|
+
contains_forward_ref,
|
|
14
|
+
)
|
|
12
15
|
from ab_core.pydantic_patch.core.payload_types import CreateModelPayload
|
|
13
16
|
from ab_core.pydantic_patch.core.types import Any
|
|
14
17
|
|
|
@@ -51,6 +54,7 @@ def get_raw_computed_field_return_annotation(
|
|
|
51
54
|
|
|
52
55
|
|
|
53
56
|
def get_resolved_computed_field_return_annotation(
|
|
57
|
+
model: type[BaseModel],
|
|
54
58
|
computed_field_info: ComputedFieldInfo,
|
|
55
59
|
) -> Any:
|
|
56
60
|
"""Return the resolved computed-field return annotation for payload generation."""
|
|
@@ -58,23 +62,42 @@ def get_resolved_computed_field_return_annotation(
|
|
|
58
62
|
return computed_field_info.return_type
|
|
59
63
|
|
|
60
64
|
getter = get_computed_field_getter(computed_field_info)
|
|
61
|
-
|
|
65
|
+
globalns, localns = build_type_hints_namespaces(model)
|
|
66
|
+
|
|
67
|
+
resolved_annotations = get_type_hints(
|
|
68
|
+
getter,
|
|
69
|
+
globalns=globalns,
|
|
70
|
+
localns=localns,
|
|
71
|
+
include_extras=True,
|
|
72
|
+
)
|
|
62
73
|
|
|
63
74
|
return resolved_annotations.get("return", Any)
|
|
64
75
|
|
|
65
76
|
|
|
66
77
|
def get_computed_field_return_annotation(
|
|
78
|
+
model: type[BaseModel],
|
|
67
79
|
computed_field_info: ComputedFieldInfo,
|
|
68
80
|
) -> Any:
|
|
69
81
|
"""Return the computed-field return annotation."""
|
|
70
|
-
return get_resolved_computed_field_return_annotation(computed_field_info)
|
|
82
|
+
return get_resolved_computed_field_return_annotation(model, computed_field_info)
|
|
71
83
|
|
|
72
84
|
|
|
73
85
|
def computed_field_contains_forward_ref(
|
|
86
|
+
model: type[BaseModel],
|
|
74
87
|
computed_field_info: ComputedFieldInfo,
|
|
75
88
|
) -> bool:
|
|
76
89
|
"""Return whether a computed field has an unresolved return annotation."""
|
|
77
|
-
|
|
90
|
+
raw_annotation = get_raw_computed_field_return_annotation(computed_field_info)
|
|
91
|
+
|
|
92
|
+
if not contains_forward_ref(raw_annotation):
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
get_resolved_computed_field_return_annotation(model, computed_field_info)
|
|
97
|
+
except NameError:
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
return False
|
|
78
101
|
|
|
79
102
|
|
|
80
103
|
def create_computed_field_info(
|
|
@@ -104,6 +127,6 @@ def apply_computed_fields_to_payload(
|
|
|
104
127
|
continue
|
|
105
128
|
|
|
106
129
|
payload[field_name] = (
|
|
107
|
-
get_resolved_computed_field_return_annotation(computed_field_info),
|
|
130
|
+
get_resolved_computed_field_return_annotation(model, computed_field_info),
|
|
108
131
|
create_computed_field_info(computed_field_info),
|
|
109
132
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Helpers for detecting and
|
|
1
|
+
"""Helpers for detecting and resolving forward references."""
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
4
|
from textwrap import dedent, indent
|
|
@@ -52,6 +52,43 @@ def _iter_model_modules(root_model: type[BaseModel]) -> list[str]:
|
|
|
52
52
|
return sorted(module_name for module_name in module_names if _iter_models_in_module(module_name))
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
def build_model_namespace(root_model: type[BaseModel]) -> dict[str, type[BaseModel]]:
|
|
56
|
+
"""Build a temporary namespace of imported sibling BaseModel classes.
|
|
57
|
+
|
|
58
|
+
This is used to resolve string / ForwardRef annotations without requiring
|
|
59
|
+
developers to manually bind circular model references onto their modules.
|
|
60
|
+
"""
|
|
61
|
+
return {
|
|
62
|
+
model.__name__: model
|
|
63
|
+
for module_name in _iter_model_modules(root_model)
|
|
64
|
+
for model in _iter_models_in_module(module_name)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_type_hints_namespaces(
|
|
69
|
+
model: type[BaseModel],
|
|
70
|
+
) -> tuple[dict[str, object], dict[str, object]]:
|
|
71
|
+
"""Return globalns/localns for resolving a model's annotations."""
|
|
72
|
+
module = sys.modules.get(model.__module__)
|
|
73
|
+
module_globals: dict[str, object] = {}
|
|
74
|
+
|
|
75
|
+
if module is not None:
|
|
76
|
+
module_globals.update(vars(module))
|
|
77
|
+
|
|
78
|
+
model_namespace = build_model_namespace(model)
|
|
79
|
+
|
|
80
|
+
globalns = {
|
|
81
|
+
**module_globals,
|
|
82
|
+
**model_namespace,
|
|
83
|
+
}
|
|
84
|
+
localns = {
|
|
85
|
+
**model_namespace,
|
|
86
|
+
model.__name__: model,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return globalns, localns
|
|
90
|
+
|
|
91
|
+
|
|
55
92
|
def unresolved_annotation_names(model: type[BaseModel]) -> list[str]:
|
|
56
93
|
"""Collect unresolved forward-reference annotation names for model modules."""
|
|
57
94
|
unresolved: list[str] = []
|
|
@@ -91,9 +128,7 @@ def _forward_ref_name(annotation: object) -> str | None:
|
|
|
91
128
|
|
|
92
129
|
def _build_resolution_example(root_model: type[BaseModel]) -> str:
|
|
93
130
|
module_names = _iter_model_modules(root_model)
|
|
94
|
-
models_by_name =
|
|
95
|
-
model.__name__: model for module_name in module_names for model in _iter_models_in_module(module_name)
|
|
96
|
-
}
|
|
131
|
+
models_by_name = build_model_namespace(root_model)
|
|
97
132
|
|
|
98
133
|
imports = [f"import {module_name} as {_module_alias(module_name)}" for module_name in module_names]
|
|
99
134
|
|
|
@@ -143,30 +178,25 @@ def build_forward_ref_error_message(
|
|
|
143
178
|
|
|
144
179
|
return dedent(
|
|
145
180
|
f"""
|
|
146
|
-
Forward references
|
|
181
|
+
Forward references could not be resolved automatically.
|
|
147
182
|
|
|
148
|
-
pydantic-patch
|
|
149
|
-
Pick/Omit/Partial/Required/Patch models
|
|
150
|
-
|
|
183
|
+
pydantic-patch tried to resolve imported sibling BaseModel / SQLModel
|
|
184
|
+
classes before generating Pick/Omit/Partial/Required/Patch models, but
|
|
185
|
+
the model {model.__name__!r} still has unresolved forward-reference
|
|
186
|
+
annotation(s): {sorted(set(unresolved_fields))!r}.
|
|
151
187
|
|
|
152
|
-
This usually
|
|
153
|
-
|
|
188
|
+
This usually means the referenced model has not been imported anywhere
|
|
189
|
+
reachable from the root model's package, or the annotation points to a
|
|
190
|
+
genuinely missing type.
|
|
154
191
|
|
|
155
192
|
See the resolved SQLModel example here:
|
|
156
193
|
{RESOLVED_EXAMPLE_URL}
|
|
157
194
|
|
|
158
|
-
|
|
159
|
-
onto their source modules, then rebuild every affected model before calling
|
|
160
|
-
Patch[...], Pick[...], Omit[...], Partial[...] or Required[...].
|
|
161
|
-
|
|
162
|
-
Suggested fix:
|
|
195
|
+
Suggested manual fallback:
|
|
163
196
|
|
|
164
197
|
```python
|
|
165
198
|
{resolution_example}
|
|
166
199
|
```
|
|
167
200
|
|
|
168
|
-
This setup usually belongs in your models package __init__.py, or in another
|
|
169
|
-
central models module that imports and prepares all ORM models before patch
|
|
170
|
-
schemas are generated.
|
|
171
201
|
"""
|
|
172
202
|
).strip()
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/transform.py
RENAMED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from collections.abc import Callable, Mapping
|
|
4
4
|
from functools import reduce
|
|
5
5
|
from operator import or_
|
|
6
|
-
from typing import Annotated, Protocol, Self, TypeVar, get_args, get_origin
|
|
6
|
+
from typing import Annotated, ForwardRef, Protocol, Self, TypeVar, get_args, get_origin
|
|
7
7
|
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
|
|
@@ -49,6 +49,9 @@ type DiscriminatorChildConfigPreparer[ConfigT: TransformConfig] = Callable[
|
|
|
49
49
|
]
|
|
50
50
|
|
|
51
51
|
_TRANSFORM_MODEL_CACHE: dict[OperationCacheKey, type[BaseModel]] = {}
|
|
52
|
+
_TRANSFORM_BUILD_STACK: list[tuple[type[BaseModel], OperationName, str, str | None]] = []
|
|
53
|
+
_TRANSFORM_BUILD_NAMESPACE: dict[str, type[BaseModel]] = {}
|
|
54
|
+
_TRANSFORM_BUILD_CREATED_MODELS: list[type[BaseModel]] = []
|
|
52
55
|
|
|
53
56
|
|
|
54
57
|
def prepare_discriminator_child_config_default[ConfigT: TransformConfig](
|
|
@@ -79,6 +82,45 @@ def default_model_name(source_model: type[BaseModel], suffix: str) -> str:
|
|
|
79
82
|
return f"{source_model.__name__}{suffix}"
|
|
80
83
|
|
|
81
84
|
|
|
85
|
+
def transformed_model_name(source_model: type[BaseModel], suffix: str, name: str | None) -> str:
|
|
86
|
+
"""Return the concrete transformed model name."""
|
|
87
|
+
return name or default_model_name(source_model, suffix)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def transform_build_key(
|
|
91
|
+
source_model: type[BaseModel],
|
|
92
|
+
operation: OperationName,
|
|
93
|
+
suffix: str,
|
|
94
|
+
name: str | None,
|
|
95
|
+
) -> tuple[type[BaseModel], OperationName, str, str | None]:
|
|
96
|
+
"""Return the key used to identify an in-progress transformed model."""
|
|
97
|
+
return (source_model, operation, suffix, name)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def find_active_build_name(
|
|
101
|
+
source_model: type[BaseModel],
|
|
102
|
+
operation: OperationName,
|
|
103
|
+
suffix: str,
|
|
104
|
+
) -> str | None:
|
|
105
|
+
"""Return the active generated model name for a recursive transform."""
|
|
106
|
+
for active_source_model, active_operation, active_suffix, active_name in reversed(_TRANSFORM_BUILD_STACK):
|
|
107
|
+
if active_source_model is source_model and active_operation == operation and active_suffix == suffix:
|
|
108
|
+
return transformed_model_name(active_source_model, active_suffix, active_name)
|
|
109
|
+
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def rebuild_created_models() -> None:
|
|
114
|
+
"""Resolve generated-model forward references created during recursion."""
|
|
115
|
+
if not _TRANSFORM_BUILD_NAMESPACE:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
type_namespace = dict(_TRANSFORM_BUILD_NAMESPACE)
|
|
119
|
+
|
|
120
|
+
for model in _TRANSFORM_BUILD_CREATED_MODELS:
|
|
121
|
+
model.model_rebuild(force=True, _types_namespace=type_namespace)
|
|
122
|
+
|
|
123
|
+
|
|
82
124
|
def build_transformed_model(
|
|
83
125
|
source_model: type[BaseModel],
|
|
84
126
|
*,
|
|
@@ -92,28 +134,46 @@ def build_transformed_model(
|
|
|
92
134
|
use_cache: bool,
|
|
93
135
|
) -> type[BaseModel]:
|
|
94
136
|
"""Build a transformed model and recursively transform nested annotations."""
|
|
95
|
-
|
|
137
|
+
build_key = transform_build_key(source_model, operation, suffix, name)
|
|
138
|
+
is_root_build = not _TRANSFORM_BUILD_STACK
|
|
139
|
+
_TRANSFORM_BUILD_STACK.append(build_key)
|
|
96
140
|
|
|
97
|
-
|
|
98
|
-
|
|
141
|
+
try:
|
|
142
|
+
assert_no_forward_refs(source_model)
|
|
99
143
|
|
|
100
|
-
|
|
101
|
-
payload,
|
|
102
|
-
config=config,
|
|
103
|
-
operation=operation,
|
|
104
|
-
suffix=suffix,
|
|
105
|
-
mutate_payload=mutate_payload,
|
|
106
|
-
make_cache_key=make_cache_key,
|
|
107
|
-
prepare_discriminator_child_config=prepare_discriminator_child_config,
|
|
108
|
-
use_cache=use_cache,
|
|
109
|
-
)
|
|
144
|
+
payload = build_payload_from_model(source_model)
|
|
145
|
+
payload = mutate_payload(payload, source_model, config)
|
|
110
146
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
147
|
+
payload = transform_payload_annotations(
|
|
148
|
+
payload,
|
|
149
|
+
config=config,
|
|
150
|
+
operation=operation,
|
|
151
|
+
suffix=suffix,
|
|
152
|
+
mutate_payload=mutate_payload,
|
|
153
|
+
make_cache_key=make_cache_key,
|
|
154
|
+
prepare_discriminator_child_config=prepare_discriminator_child_config,
|
|
155
|
+
use_cache=use_cache,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
model_name = transformed_model_name(source_model, suffix, name)
|
|
159
|
+
transformed_model = create_model_from_payload(
|
|
160
|
+
source_model=source_model,
|
|
161
|
+
payload=payload,
|
|
162
|
+
name=model_name,
|
|
163
|
+
)
|
|
164
|
+
_TRANSFORM_BUILD_NAMESPACE[model_name] = transformed_model
|
|
165
|
+
_TRANSFORM_BUILD_CREATED_MODELS.append(transformed_model)
|
|
166
|
+
|
|
167
|
+
return transformed_model
|
|
168
|
+
finally:
|
|
169
|
+
_TRANSFORM_BUILD_STACK.pop()
|
|
170
|
+
|
|
171
|
+
if is_root_build:
|
|
172
|
+
try:
|
|
173
|
+
rebuild_created_models()
|
|
174
|
+
finally:
|
|
175
|
+
_TRANSFORM_BUILD_NAMESPACE.clear()
|
|
176
|
+
_TRANSFORM_BUILD_CREATED_MODELS.clear()
|
|
117
177
|
|
|
118
178
|
|
|
119
179
|
def transform_model_cached(
|
|
@@ -164,6 +224,10 @@ def transform_model(
|
|
|
164
224
|
use_cache: bool = True,
|
|
165
225
|
) -> type[BaseModel]:
|
|
166
226
|
"""Transform a model according to an operation configuration."""
|
|
227
|
+
active_model_name = find_active_build_name(source_model, operation, suffix)
|
|
228
|
+
if active_model_name is not None:
|
|
229
|
+
return ForwardRef(active_model_name)
|
|
230
|
+
|
|
167
231
|
if use_cache:
|
|
168
232
|
return transform_model_cached(
|
|
169
233
|
source_model,
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/type_hints.py
RENAMED
|
@@ -9,13 +9,23 @@ from .computed_field_type_hints import (
|
|
|
9
9
|
iter_computed_field_infos,
|
|
10
10
|
)
|
|
11
11
|
from .errors import ForwardReferencesNotSupported
|
|
12
|
-
from .forward_references import
|
|
12
|
+
from .forward_references import (
|
|
13
|
+
build_forward_ref_error_message,
|
|
14
|
+
build_type_hints_namespaces,
|
|
15
|
+
)
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
def get_resolved_type_hints(model: type[BaseModel]) -> dict[str, object]:
|
|
16
|
-
"""Resolve model type hints and raise custom errors on unresolved refs."""
|
|
19
|
+
"""Resolve model type hints and raise custom errors on truly unresolved refs."""
|
|
20
|
+
globalns, localns = build_type_hints_namespaces(model)
|
|
21
|
+
|
|
17
22
|
try:
|
|
18
|
-
return get_type_hints(
|
|
23
|
+
return get_type_hints(
|
|
24
|
+
model,
|
|
25
|
+
globalns=globalns,
|
|
26
|
+
localns=localns,
|
|
27
|
+
include_extras=True,
|
|
28
|
+
)
|
|
19
29
|
except NameError as error:
|
|
20
30
|
raise ForwardReferencesNotSupported(
|
|
21
31
|
build_forward_ref_error_message(
|
|
@@ -30,7 +40,7 @@ def unresolved_computed_field_names(model: type[BaseModel]) -> list[str]:
|
|
|
30
40
|
return sorted(
|
|
31
41
|
field_name
|
|
32
42
|
for field_name, computed_field_info in iter_computed_field_infos(model)
|
|
33
|
-
if computed_field_contains_forward_ref(computed_field_info)
|
|
43
|
+
if computed_field_contains_forward_ref(model, computed_field_info)
|
|
34
44
|
)
|
|
35
45
|
|
|
36
46
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
from ab_core.pydantic_patch.examples.sqlmodel_examples.models.project import Project
|
|
2
2
|
from ab_core.pydantic_patch.examples.sqlmodel_examples.models.project_milestone import ProjectMilestone
|
|
3
3
|
from ab_core.pydantic_patch.examples.sqlmodel_examples.models.project_task import ProjectTask
|
|
4
|
+
from ab_core.pydantic_patch.examples.sqlmodel_examples.models.quote_line_item import QuoteLineItem
|
|
4
5
|
from ab_core.pydantic_patch.examples.sqlmodel_examples.models.task_comment import TaskComment
|
|
5
6
|
|
|
6
7
|
__all__ = [
|
|
7
8
|
"Project",
|
|
8
9
|
"ProjectMilestone",
|
|
9
10
|
"ProjectTask",
|
|
11
|
+
"QuoteLineItem",
|
|
10
12
|
"TaskComment",
|
|
11
13
|
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Self-referencing quote line item SQLModel."""
|
|
2
|
+
|
|
3
|
+
from sqlmodel import Field, Relationship, SQLModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class QuoteLineItem(SQLModel, table=True):
|
|
7
|
+
"""A quote line item that can contain child line items."""
|
|
8
|
+
|
|
9
|
+
__tablename__ = "self_referencing_quote_line_item"
|
|
10
|
+
|
|
11
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
12
|
+
parent_id: int | None = Field(default=None, foreign_key="self_referencing_quote_line_item.id")
|
|
13
|
+
|
|
14
|
+
line_item_name: str = ""
|
|
15
|
+
quoted_base_cost: float = 0.0
|
|
16
|
+
|
|
17
|
+
parent: "QuoteLineItem" = Relationship(
|
|
18
|
+
back_populates="children",
|
|
19
|
+
sa_relationship_kwargs={
|
|
20
|
+
"remote_side": "QuoteLineItem.id",
|
|
21
|
+
},
|
|
22
|
+
)
|
|
23
|
+
children: list["QuoteLineItem"] = Relationship(back_populates="parent")
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Self-referencing SQLModel tree patch example."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends as FDepends
|
|
8
|
+
from fastapi import FastAPI, HTTPException
|
|
9
|
+
from sqlalchemy.orm import Session
|
|
10
|
+
|
|
11
|
+
from ab_core.database.databases import Database
|
|
12
|
+
from ab_core.database.session_context import db_session_sync
|
|
13
|
+
from ab_core.dependency import Depends, inject
|
|
14
|
+
from ab_core.pydantic_patch.examples.sqlmodel_examples.models import QuoteLineItem
|
|
15
|
+
from ab_core.pydantic_patch.orm_patch import recursive_patch_orm_scalar
|
|
16
|
+
from ab_core.pydantic_patch.patch import Patch, PatchConfig
|
|
17
|
+
|
|
18
|
+
os.environ.setdefault("DATABASE_TYPE", "SQL_ALCHEMY")
|
|
19
|
+
os.environ.setdefault("DATABASE_SQL_ALCHEMY_URL", "sqlite:///./self_referencing_tree.db")
|
|
20
|
+
|
|
21
|
+
ENTITY_ID = 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
QuoteLineItem.model_rebuild(force=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
QuoteLineItemPatch = Patch[QuoteLineItem](
|
|
28
|
+
name="QuoteLineItemPatch",
|
|
29
|
+
pick={
|
|
30
|
+
"id",
|
|
31
|
+
"line_item_name",
|
|
32
|
+
"quoted_base_cost",
|
|
33
|
+
"children",
|
|
34
|
+
},
|
|
35
|
+
partial={
|
|
36
|
+
"id",
|
|
37
|
+
"line_item_name",
|
|
38
|
+
"quoted_base_cost",
|
|
39
|
+
"children",
|
|
40
|
+
},
|
|
41
|
+
child_models={
|
|
42
|
+
QuoteLineItem: PatchConfig(
|
|
43
|
+
pick={
|
|
44
|
+
"id",
|
|
45
|
+
"line_item_name",
|
|
46
|
+
"quoted_base_cost",
|
|
47
|
+
"children",
|
|
48
|
+
},
|
|
49
|
+
partial={
|
|
50
|
+
"id",
|
|
51
|
+
"line_item_name",
|
|
52
|
+
"quoted_base_cost",
|
|
53
|
+
"children",
|
|
54
|
+
},
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
QuoteLineItemResponse = Patch[QuoteLineItem](
|
|
60
|
+
name="QuoteLineItemResponse",
|
|
61
|
+
pick={
|
|
62
|
+
"id",
|
|
63
|
+
"line_item_name",
|
|
64
|
+
"quoted_base_cost",
|
|
65
|
+
"children",
|
|
66
|
+
},
|
|
67
|
+
required={
|
|
68
|
+
"id",
|
|
69
|
+
},
|
|
70
|
+
child_models={
|
|
71
|
+
QuoteLineItem: PatchConfig(
|
|
72
|
+
pick={
|
|
73
|
+
"id",
|
|
74
|
+
"line_item_name",
|
|
75
|
+
"quoted_base_cost",
|
|
76
|
+
"children",
|
|
77
|
+
},
|
|
78
|
+
required={
|
|
79
|
+
"id",
|
|
80
|
+
},
|
|
81
|
+
),
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def seed(db: Database) -> None:
|
|
87
|
+
"""Create demo records if they do not already exist."""
|
|
88
|
+
db.sync_upgrade_db()
|
|
89
|
+
|
|
90
|
+
with db.sync_session() as session:
|
|
91
|
+
if session.get(QuoteLineItem, ENTITY_ID):
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
quote_line_item = QuoteLineItem(
|
|
95
|
+
id=ENTITY_ID,
|
|
96
|
+
line_item_name="Fence",
|
|
97
|
+
quoted_base_cost=1200.0,
|
|
98
|
+
children=[
|
|
99
|
+
QuoteLineItem(
|
|
100
|
+
id=10,
|
|
101
|
+
line_item_name="Panels",
|
|
102
|
+
quoted_base_cost=700.0,
|
|
103
|
+
),
|
|
104
|
+
QuoteLineItem(
|
|
105
|
+
id=11,
|
|
106
|
+
line_item_name="Posts and rails",
|
|
107
|
+
quoted_base_cost=500.0,
|
|
108
|
+
),
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
session.add(quote_line_item)
|
|
113
|
+
session.commit()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@asynccontextmanager
|
|
117
|
+
@inject
|
|
118
|
+
async def lifespan(
|
|
119
|
+
_app: FastAPI,
|
|
120
|
+
db: Annotated[Database, Depends(Database, persist=True)],
|
|
121
|
+
):
|
|
122
|
+
"""Run startup seed for the example app lifecycle."""
|
|
123
|
+
seed(db)
|
|
124
|
+
yield
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
app = FastAPI(lifespan=lifespan)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.get("/line-items/{line_item_id}", response_model=QuoteLineItemResponse)
|
|
131
|
+
def get_line_item(
|
|
132
|
+
line_item_id: int,
|
|
133
|
+
db_session: Annotated[Session, FDepends(db_session_sync)],
|
|
134
|
+
) -> QuoteLineItem:
|
|
135
|
+
"""Return a line item tree by id."""
|
|
136
|
+
line_item = db_session.get(QuoteLineItem, line_item_id)
|
|
137
|
+
|
|
138
|
+
if line_item is None:
|
|
139
|
+
raise HTTPException(status_code=404, detail="Line item not found")
|
|
140
|
+
|
|
141
|
+
return line_item
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.patch("/line-items/{line_item_id}", response_model=QuoteLineItemResponse)
|
|
145
|
+
def patch_line_item(
|
|
146
|
+
line_item_id: int,
|
|
147
|
+
patch: QuoteLineItemPatch,
|
|
148
|
+
db_session: Annotated[Session, FDepends(db_session_sync)],
|
|
149
|
+
) -> QuoteLineItem:
|
|
150
|
+
"""Patch a self-referencing line item tree."""
|
|
151
|
+
line_item = db_session.get(QuoteLineItem, line_item_id)
|
|
152
|
+
|
|
153
|
+
if line_item is None:
|
|
154
|
+
raise HTTPException(status_code=404, detail="Line item not found")
|
|
155
|
+
|
|
156
|
+
recursive_patch_orm_scalar(line_item, patch)
|
|
157
|
+
|
|
158
|
+
db_session.add(line_item)
|
|
159
|
+
db_session.flush()
|
|
160
|
+
|
|
161
|
+
return line_item
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
import uvicorn
|
|
166
|
+
|
|
167
|
+
uvicorn.run(
|
|
168
|
+
app,
|
|
169
|
+
host="0.0.0.0",
|
|
170
|
+
port=8000,
|
|
171
|
+
reload=False,
|
|
172
|
+
)
|
|
@@ -27,11 +27,23 @@ def _provided_values(model: BaseModel) -> dict[str, object]:
|
|
|
27
27
|
return {name: getattr(model, name) for name in model.model_fields_set}
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
def _patch_scalar_value(
|
|
30
|
+
def _patch_scalar_value(
|
|
31
|
+
instance: Any,
|
|
32
|
+
key: str,
|
|
33
|
+
value: Any,
|
|
34
|
+
*,
|
|
35
|
+
copy_nested_basemodel: bool = False,
|
|
36
|
+
) -> None:
|
|
31
37
|
current_value = getattr(instance, key, None)
|
|
32
38
|
|
|
33
39
|
if isinstance(current_value, BaseModel) and isinstance(value, BaseModel):
|
|
34
|
-
|
|
40
|
+
target_value = current_value.model_copy(deep=True) if copy_nested_basemodel else current_value
|
|
41
|
+
|
|
42
|
+
recursive_patch_scalar(target_value, value)
|
|
43
|
+
|
|
44
|
+
# Important for ORM scalar JSON/Pydantic fields:
|
|
45
|
+
# reassign the merged current value so SQLAlchemy sees the column changed.
|
|
46
|
+
setattr(instance, key, target_value)
|
|
35
47
|
return
|
|
36
48
|
|
|
37
49
|
setattr(instance, key, value)
|
|
@@ -94,7 +106,7 @@ def _recursive_patch_pydantic_scalar(
|
|
|
94
106
|
instance: BaseModel,
|
|
95
107
|
values: BaseModel,
|
|
96
108
|
) -> None:
|
|
97
|
-
target_fields = set(instance.model_fields)
|
|
109
|
+
target_fields = set(type(instance).model_fields)
|
|
98
110
|
|
|
99
111
|
for key, value in _provided_values(values).items():
|
|
100
112
|
if key not in target_fields:
|
|
@@ -129,7 +141,12 @@ def _recursive_patch_orm_scalar(
|
|
|
129
141
|
if relationship is None:
|
|
130
142
|
if key not in scalar_attributes:
|
|
131
143
|
continue
|
|
132
|
-
_patch_scalar_value(
|
|
144
|
+
_patch_scalar_value(
|
|
145
|
+
orm_instance,
|
|
146
|
+
key,
|
|
147
|
+
value,
|
|
148
|
+
copy_nested_basemodel=True,
|
|
149
|
+
)
|
|
133
150
|
continue
|
|
134
151
|
|
|
135
152
|
if value is None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/__init__.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/cache.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/classproperty.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/config.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/errors.py
RENAMED
|
File without changes
|
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/fields.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/operation.py
RENAMED
|
File without changes
|
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/payload.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/payload_types.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/types.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/omit/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/omit/config.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/omit/operation.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/partial/__init__.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/partial/api.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/partial/config.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/partial/operation.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/patch/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/patch/config.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/patch/operation.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/pick/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/pick/config.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/pick/operation.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/pydantic_jsonb.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/required/__init__.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/required/api.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/required/config.py
RENAMED
|
File without changes
|
{ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/required/operation.py
RENAMED
|
File without changes
|