Skip to content

Commit 069f4a1

Browse files
fix: auto-apply safe defaults for missing fields in load_engine_object (#1104)
* fix: auto-apply safe defaults for missing fields in load_engine_object * style: apply pre-commit formatting to modified files
1 parent 624b233 commit 069f4a1

File tree

2 files changed

+125
-0
lines changed

2 files changed

+125
-0
lines changed

python/cocoindex/convert.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,21 +783,57 @@ def load_engine_object(expected_type: Any, v: Any) -> Any:
783783
# Drop auxiliary discriminator "kind" if present
784784
dc_init_kwargs: dict[str, Any] = {}
785785
field_types = {f.name: f.type for f in dataclasses.fields(struct_type)}
786+
dataclass_fields = {f.name: f for f in dataclasses.fields(struct_type)}
787+
786788
for name, f_type in field_types.items():
787789
if name in v:
788790
dc_init_kwargs[name] = load_engine_object(f_type, v[name])
791+
else:
792+
# Field is missing from input, check if it has a default or can use auto-default
793+
field = dataclass_fields[name]
794+
if field.default is not dataclasses.MISSING:
795+
# Field has an explicit default value
796+
dc_init_kwargs[name] = field.default
797+
elif field.default_factory is not dataclasses.MISSING:
798+
# Field has a default factory
799+
dc_init_kwargs[name] = field.default_factory()
800+
else:
801+
# No explicit default, try to get auto-default
802+
type_info = analyze_type_info(f_type)
803+
auto_default, is_supported = _get_auto_default_for_type(
804+
type_info
805+
)
806+
if is_supported:
807+
dc_init_kwargs[name] = auto_default
808+
# If not supported, skip the field (let dataclass constructor handle the error)
789809
return struct_type(**dc_init_kwargs)
790810
elif is_namedtuple_type(struct_type):
791811
if not isinstance(v, Mapping):
792812
raise ValueError(f"Expected dict for NamedTuple, got {type(v)}")
793813
# Dict format (from dump/load functions)
794814
annotations = getattr(struct_type, "__annotations__", {})
795815
field_names = list(getattr(struct_type, "_fields", ()))
816+
field_defaults = getattr(struct_type, "_field_defaults", {})
796817
nt_init_kwargs: dict[str, Any] = {}
818+
797819
for name in field_names:
798820
f_type = annotations.get(name, Any)
799821
if name in v:
800822
nt_init_kwargs[name] = load_engine_object(f_type, v[name])
823+
else:
824+
# Field is missing from input, check if it has a default or can use auto-default
825+
if name in field_defaults:
826+
# Field has an explicit default value
827+
nt_init_kwargs[name] = field_defaults[name]
828+
else:
829+
# No explicit default, try to get auto-default
830+
type_info = analyze_type_info(f_type)
831+
auto_default, is_supported = _get_auto_default_for_type(
832+
type_info
833+
)
834+
if is_supported:
835+
nt_init_kwargs[name] = auto_default
836+
# If not supported, skip the field (let NamedTuple constructor handle the error)
801837
return struct_type(**nt_init_kwargs)
802838
elif is_pydantic_model(struct_type):
803839
if not isinstance(v, Mapping):
@@ -812,9 +848,33 @@ def load_engine_object(expected_type: Any, v: Any) -> Any:
812848
field_types = {
813849
name: field.annotation for name, field in model_fields.items()
814850
}
851+
815852
for name, f_type in field_types.items():
816853
if name in v:
817854
pydantic_init_kwargs[name] = load_engine_object(f_type, v[name])
855+
else:
856+
# Field is missing from input, check if it has a default or can use auto-default
857+
field = model_fields[name]
858+
if (
859+
hasattr(field, "default") and field.default is not ...
860+
): # ... is Pydantic's sentinel for no default
861+
# Field has an explicit default value
862+
pydantic_init_kwargs[name] = field.default
863+
elif (
864+
hasattr(field, "default_factory")
865+
and field.default_factory is not None
866+
):
867+
# Field has a default factory
868+
pydantic_init_kwargs[name] = field.default_factory()
869+
else:
870+
# No explicit default, try to get auto-default
871+
type_info = analyze_type_info(f_type)
872+
auto_default, is_supported = _get_auto_default_for_type(
873+
type_info
874+
)
875+
if is_supported:
876+
pydantic_init_kwargs[name] = auto_default
877+
# If not supported, skip the field (let Pydantic constructor handle the error)
818878
return struct_type(**pydantic_init_kwargs)
819879
return v
820880

python/cocoindex/tests/test_load_convert.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,68 @@ def test_namedtuple_roundtrip_via_dump_load() -> None:
116116
loaded = load_engine_object(LocalPoint, dumped)
117117
assert isinstance(loaded, LocalPoint)
118118
assert loaded == p
119+
120+
121+
def test_dataclass_missing_fields_with_auto_defaults() -> None:
122+
"""Test that missing fields are automatically assigned safe default values."""
123+
124+
@dataclasses.dataclass
125+
class TestClass:
126+
required_field: str
127+
optional_field: str | None # Should get None
128+
list_field: list[str] # Should get []
129+
dict_field: dict[str, int] # Should get {}
130+
explicit_default: str = "default" # Should use explicit default
131+
132+
# Input missing optional_field, list_field, dict_field (but has explicit_default via class definition)
133+
input_data = {"required_field": "test_value"}
134+
135+
loaded = load_engine_object(TestClass, input_data)
136+
137+
assert isinstance(loaded, TestClass)
138+
assert loaded.required_field == "test_value"
139+
assert loaded.optional_field is None # Auto-default for Optional
140+
assert loaded.list_field == [] # Auto-default for list
141+
assert loaded.dict_field == {} # Auto-default for dict
142+
assert loaded.explicit_default == "default" # Explicit default from class
143+
144+
145+
def test_namedtuple_missing_fields_with_auto_defaults() -> None:
146+
"""Test that missing fields in NamedTuple are automatically assigned safe default values."""
147+
from typing import NamedTuple
148+
149+
class TestTuple(NamedTuple):
150+
required_field: str
151+
optional_field: str | None # Should get None
152+
list_field: list[str] # Should get []
153+
dict_field: dict[str, int] # Should get {}
154+
155+
# Input missing optional_field, list_field, dict_field
156+
input_data = {"required_field": "test_value"}
157+
158+
loaded = load_engine_object(TestTuple, input_data)
159+
160+
assert isinstance(loaded, TestTuple)
161+
assert loaded.required_field == "test_value"
162+
assert loaded.optional_field is None # Auto-default for Optional
163+
assert loaded.list_field == [] # Auto-default for list
164+
assert loaded.dict_field == {} # Auto-default for dict
165+
166+
167+
def test_dataclass_unsupported_type_still_fails() -> None:
168+
"""Test that fields with unsupported types still cause errors when missing."""
169+
170+
@dataclasses.dataclass
171+
class TestClass:
172+
required_field1: str
173+
required_field2: int # No auto-default for int
174+
175+
# Input missing required_field2 which has no safe auto-default
176+
input_data = {"required_field1": "test_value"}
177+
178+
# Should still raise an error because int has no safe auto-default
179+
try:
180+
load_engine_object(TestClass, input_data)
181+
assert False, "Expected TypeError to be raised"
182+
except TypeError:
183+
pass # Expected behavior

0 commit comments

Comments
 (0)