Skip to content

Commit 177f2d3

Browse files
jxnlthomasahleclaude
committed
fix(partial): prevent infinite recursion with self-referential models (#1997)
- Add _processing_models set to track recursive models - Prevent infinite recursion with self-referential models (e.g., TreeNode with children: List["TreeNode"]) - Add tests for recursive model handling Co-Authored-By: Thomas Dybdahl Ahle <thomas@ahle.dk> Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fcb74e5 commit 177f2d3

2 files changed

Lines changed: 268 additions & 7 deletions

File tree

instructor/dsl/partial.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@
4141
else:
4242
UNION_ORIGINS = (Union,)
4343

44+
# Track models currently being processed to prevent infinite recursion
45+
# with self-referential models (e.g., TreeNode with children: List["TreeNode"])
46+
_processing_models: set[type] = set()
47+
4448

4549
class MakeFieldsOptional:
4650
pass
@@ -102,11 +106,18 @@ def _process_generic_arg(
102106
return arg_origin[modified_nested_args]
103107
else:
104108
if isinstance(arg, type) and issubclass(arg, BaseModel):
105-
return (
106-
Partial[arg, MakeFieldsOptional] # type: ignore[valid-type]
107-
if make_fields_optional
108-
else Partial[arg]
109-
)
109+
# Prevent infinite recursion for self-referential models
110+
if arg in _processing_models:
111+
return arg # Already processing this model, return unwrapped
112+
_processing_models.add(arg)
113+
try:
114+
return (
115+
Partial[arg, MakeFieldsOptional] # type: ignore[valid-type]
116+
if make_fields_optional
117+
else Partial[arg]
118+
)
119+
finally:
120+
_processing_models.discard(arg)
110121
else:
111122
return arg
112123

@@ -946,7 +957,17 @@ def _wrap_models(field: FieldInfo) -> tuple[object, FieldInfo]:
946957
# If the field is a BaseModel, then recursively convert it's
947958
# attributes to optionals.
948959
elif isinstance(annotation, type) and issubclass(annotation, BaseModel):
949-
tmp_field.annotation = Partial[annotation]
960+
# Prevent infinite recursion for self-referential models
961+
if annotation in _processing_models:
962+
tmp_field.annotation = (
963+
annotation # Already processing, keep unwrapped
964+
)
965+
else:
966+
_processing_models.add(annotation)
967+
try:
968+
tmp_field.annotation = Partial[annotation]
969+
finally:
970+
_processing_models.discard(annotation)
950971
return tmp_field.annotation, tmp_field
951972

952973
model_name = (

tests/dsl/test_partial.py

Lines changed: 241 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ def test_partial_with_whitespace():
101101
partial = Partial[SamplePartial]
102102

103103
# Get the actual models from chunks - must provide complete data for final validation
104-
models = list(partial.model_from_chunks(["\n", "\t", " ", '{"a": 42, "b": {"b": 1}}']))
104+
models = list(
105+
partial.model_from_chunks(["\n", "\t", " ", '{"a": 42, "b": {"b": 1}}'])
106+
)
105107

106108
# Print actual values for debugging
107109
print(f"Number of models: {len(models)}")
@@ -882,3 +884,241 @@ async def async_chunks():
882884
pass
883885

884886
assert "age" in str(exc_info.value)
887+
888+
889+
class TestRecursiveModels:
890+
"""Test that Partial handles self-referential models without infinite recursion."""
891+
892+
def test_basic_recursive_model(self):
893+
"""Partial should work with basic recursive models."""
894+
895+
class TreeNode(BaseModel):
896+
value: str
897+
children: Optional[list["TreeNode"]] = None
898+
899+
TreeNode.model_rebuild()
900+
901+
# Should not raise RecursionError
902+
PartialTreeNode = Partial[TreeNode]
903+
TruePartial = PartialTreeNode.get_partial_model()
904+
905+
# Can validate partial data
906+
result = TruePartial.model_validate({"value": "root"})
907+
assert result.value == "root"
908+
assert result.children is None
909+
910+
def test_nested_recursive_model(self):
911+
"""Partial should work with nested children."""
912+
913+
class TreeNode(BaseModel):
914+
value: str
915+
children: Optional[list["TreeNode"]] = None
916+
917+
TreeNode.model_rebuild()
918+
919+
PartialTreeNode = Partial[TreeNode]
920+
TruePartial = PartialTreeNode.get_partial_model()
921+
922+
# Validate with nested structure
923+
data = {
924+
"value": "root",
925+
"children": [
926+
{"value": "child1"},
927+
{"value": "child2", "children": [{"value": "grandchild"}]},
928+
],
929+
}
930+
result = TruePartial.model_validate(data)
931+
assert result.value == "root"
932+
assert len(result.children) == 2
933+
assert result.children[0].value == "child1"
934+
assert result.children[1].children[0].value == "grandchild"
935+
936+
def test_mutually_recursive_models(self):
937+
"""Partial should handle mutually recursive models."""
938+
939+
class Person(BaseModel):
940+
name: str
941+
employer: Optional["Company"] = None
942+
943+
class Company(BaseModel):
944+
name: str
945+
employees: Optional[list[Person]] = None
946+
947+
Person.model_rebuild()
948+
Company.model_rebuild()
949+
950+
# Both should work without RecursionError
951+
PartialPerson = Partial[Person]
952+
PartialCompany = Partial[Company]
953+
954+
assert PartialPerson is not None
955+
assert PartialCompany is not None
956+
957+
# Validate partial data
958+
person_partial = PartialPerson.get_partial_model()
959+
result = person_partial.model_validate({"name": "Alice"})
960+
assert result.name == "Alice"
961+
962+
def test_direct_self_reference(self):
963+
"""Partial should handle direct self-reference (linked list style)."""
964+
965+
class LinkedNode(BaseModel):
966+
value: int
967+
next: Optional["LinkedNode"] = None
968+
969+
LinkedNode.model_rebuild()
970+
971+
# Should not raise RecursionError
972+
PartialLinked = Partial[LinkedNode]
973+
TruePartial = PartialLinked.get_partial_model()
974+
975+
# Validate chain
976+
data = {"value": 1, "next": {"value": 2, "next": {"value": 3}}}
977+
result = TruePartial.model_validate(data)
978+
assert result.value == 1
979+
assert result.next.value == 2
980+
assert result.next.next.value == 3
981+
982+
def test_complex_recursive_with_validators(self):
983+
"""Complex recursive model with validators, multiple self-refs, and nested types."""
984+
from typing import Literal
985+
from pydantic import model_validator, field_validator
986+
from enum import Enum
987+
988+
class NodeType(Enum):
989+
FOLDER = "folder"
990+
FILE = "file"
991+
SYMLINK = "symlink"
992+
993+
class Permission(BaseModel):
994+
user: str
995+
level: Literal["read", "write", "admin"]
996+
997+
class FileSystemNode(BaseModel):
998+
name: str
999+
node_type: NodeType
1000+
size_bytes: Optional[int] = None
1001+
children: Optional[list["FileSystemNode"]] = None
1002+
parent: Optional["FileSystemNode"] = None
1003+
symlink_target: Optional["FileSystemNode"] = None
1004+
permissions: Optional[list[Permission]] = None
1005+
metadata: Optional[dict[str, str]] = None
1006+
1007+
@field_validator("name")
1008+
@classmethod
1009+
def validate_name(cls, v):
1010+
if v and "/" in v:
1011+
raise ValueError("Name cannot contain /")
1012+
return v
1013+
1014+
@model_validator(mode="after")
1015+
def validate_node_consistency(self):
1016+
# Folders must have no size, files must have size
1017+
if self.node_type == NodeType.FOLDER and self.size_bytes is not None:
1018+
raise ValueError("Folders cannot have size_bytes")
1019+
if self.node_type == NodeType.FILE and self.children:
1020+
raise ValueError("Files cannot have children")
1021+
if self.node_type == NodeType.SYMLINK and not self.symlink_target:
1022+
raise ValueError("Symlinks must have a target")
1023+
return self
1024+
1025+
FileSystemNode.model_rebuild()
1026+
1027+
# Should not raise RecursionError
1028+
PartialFS = Partial[FileSystemNode]
1029+
TruePartial = PartialFS.get_partial_model()
1030+
1031+
# Complex nested structure
1032+
data = {
1033+
"name": "root",
1034+
"node_type": "folder",
1035+
"permissions": [{"user": "admin", "level": "admin"}],
1036+
"metadata": {"created": "2024-01-01"},
1037+
"children": [
1038+
{
1039+
"name": "documents",
1040+
"node_type": "folder",
1041+
"children": [
1042+
{
1043+
"name": "report.pdf",
1044+
"node_type": "file",
1045+
"size_bytes": 1024,
1046+
"permissions": [{"user": "alice", "level": "read"}],
1047+
},
1048+
{
1049+
"name": "data",
1050+
"node_type": "folder",
1051+
"children": [
1052+
{
1053+
"name": "archive.zip",
1054+
"node_type": "file",
1055+
"size_bytes": 2048,
1056+
}
1057+
],
1058+
},
1059+
],
1060+
},
1061+
{
1062+
"name": "shortcut",
1063+
"node_type": "symlink",
1064+
"symlink_target": {
1065+
"name": "target_file",
1066+
"node_type": "file",
1067+
"size_bytes": 512,
1068+
},
1069+
},
1070+
],
1071+
}
1072+
1073+
result = TruePartial.model_validate(data)
1074+
assert result.name == "root"
1075+
assert result.node_type == NodeType.FOLDER
1076+
assert len(result.children) == 2
1077+
assert result.children[0].name == "documents"
1078+
assert len(result.children[0].children) == 2
1079+
assert result.children[0].children[0].name == "report.pdf"
1080+
assert result.children[0].children[0].size_bytes == 1024
1081+
assert result.children[0].children[1].children[0].name == "archive.zip"
1082+
assert result.children[1].symlink_target.name == "target_file"
1083+
assert result.permissions[0].level == "admin"
1084+
1085+
def test_recursive_with_union_types(self):
1086+
"""Recursive model with Union types containing self-references."""
1087+
from typing import Union
1088+
1089+
class TextBlock(BaseModel):
1090+
text: str
1091+
1092+
class Container(BaseModel):
1093+
title: str
1094+
content: list[Union[TextBlock, "Container"]]
1095+
1096+
Container.model_rebuild()
1097+
1098+
PartialContainer = Partial[Container]
1099+
TruePartial = PartialContainer.get_partial_model()
1100+
1101+
data = {
1102+
"title": "Chapter 1",
1103+
"content": [
1104+
{"text": "Introduction paragraph"},
1105+
{
1106+
"title": "Section 1.1",
1107+
"content": [
1108+
{"text": "Section text"},
1109+
{
1110+
"title": "Subsection 1.1.1",
1111+
"content": [{"text": "Deep nested text"}],
1112+
},
1113+
],
1114+
},
1115+
{"text": "Closing paragraph"},
1116+
],
1117+
}
1118+
1119+
result = TruePartial.model_validate(data)
1120+
assert result.title == "Chapter 1"
1121+
assert len(result.content) == 3
1122+
assert result.content[0].text == "Introduction paragraph"
1123+
assert result.content[1].title == "Section 1.1"
1124+
assert result.content[1].content[1].title == "Subsection 1.1.1"

0 commit comments

Comments
 (0)