@@ -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