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.
Files changed (60) hide show
  1. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/PKG-INFO +129 -6
  2. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/README.md +128 -5
  3. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/pyproject.toml +1 -1
  4. {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
  5. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/forward_references.py +48 -18
  6. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/transform.py +84 -20
  7. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/type_hints.py +14 -4
  8. {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
  9. ab_pydantic_patch-1.2.4/src/ab_core/pydantic_patch/examples/sqlmodel_examples/models/quote_line_item.py +23 -0
  10. ab_pydantic_patch-1.2.4/src/ab_core/pydantic_patch/examples/sqlmodel_examples/self_referencing_tree.py +172 -0
  11. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/orm_patch.py +21 -4
  12. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/.gitignore +0 -0
  13. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/LICENSE +0 -0
  14. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/__init__.py +0 -0
  15. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/__init__.py +0 -0
  16. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/cache.py +0 -0
  17. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/classproperty.py +0 -0
  18. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/config.py +0 -0
  19. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/errors.py +0 -0
  20. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/field_type_hints.py +0 -0
  21. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/fields.py +0 -0
  22. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/operation.py +0 -0
  23. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/orm_type_hints.py +0 -0
  24. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/payload.py +0 -0
  25. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/payload_types.py +0 -0
  26. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/core/types.py +0 -0
  27. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/__init__.py +0 -0
  28. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/pydantic_examples/__init__.py +0 -0
  29. {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
  30. {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
  31. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/examples/sqlmodel_examples/__init__.py +0 -0
  32. {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
  33. {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
  34. {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
  35. {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
  36. {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
  37. {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
  38. {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
  39. {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
  40. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/omit/__init__.py +0 -0
  41. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/omit/api.py +0 -0
  42. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/omit/config.py +0 -0
  43. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/omit/operation.py +0 -0
  44. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/partial/__init__.py +0 -0
  45. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/partial/api.py +0 -0
  46. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/partial/config.py +0 -0
  47. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/partial/operation.py +0 -0
  48. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/patch/__init__.py +0 -0
  49. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/patch/api.py +0 -0
  50. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/patch/config.py +0 -0
  51. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/patch/operation.py +0 -0
  52. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/pick/__init__.py +0 -0
  53. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/pick/api.py +0 -0
  54. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/pick/config.py +0 -0
  55. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/pick/operation.py +0 -0
  56. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/pydantic_jsonb.py +0 -0
  57. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/required/__init__.py +0 -0
  58. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/required/api.py +0 -0
  59. {ab_pydantic_patch-1.2.2 → ab_pydantic_patch-1.2.4}/src/ab_core/pydantic_patch/required/config.py +0 -0
  60. {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.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` does not currently support unresolved forward references.
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](...)` before resolving `"ProjectMilestone"` will raise
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
- To fix this, import all related model modules first, bind the missing shallow
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` does not currently support unresolved forward references.
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](...)` before resolving `"ProjectMilestone"` will raise
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
- To fix this, import all related model modules first, bind the missing shallow
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.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 contains_forward_ref
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
- resolved_annotations = get_type_hints(getter, include_extras=True)
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
- return contains_forward_ref(get_raw_computed_field_return_annotation(computed_field_info))
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 reporting unresolved forward references."""
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 are not supported by pydantic-patch until they are resolved.
181
+ Forward references could not be resolved automatically.
147
182
 
148
- pydantic-patch is type-driven and needs real Python types when generating
149
- Pick/Omit/Partial/Required/Patch models. The model {model.__name__!r} has
150
- unresolved forward-reference annotation(s): {sorted(set(unresolved_fields))!r}.
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 happens with SQLModel relationships split across modules, where
153
- relationship attributes are declared with strings to avoid circular imports.
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
- Import every related ORM module first, bind the shallow circular references
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()
@@ -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
- assert_no_forward_refs(source_model)
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
- payload = build_payload_from_model(source_model)
98
- payload = mutate_payload(payload, source_model, config)
141
+ try:
142
+ assert_no_forward_refs(source_model)
99
143
 
100
- payload = transform_payload_annotations(
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
- model_name = name or default_model_name(source_model, suffix)
112
- return create_model_from_payload(
113
- source_model=source_model,
114
- payload=payload,
115
- name=model_name,
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,
@@ -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 build_forward_ref_error_message
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(model, include_extras=True)
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(instance: Any, key: str, value: Any) -> None:
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
- recursive_patch_scalar(current_value, value)
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(orm_instance, key, 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: