Skip to content

Commit 0e5ccec

Browse files
fix: use alias for shadowing pydantic fields in json schema
1 parent 4e7c62e commit 0e5ccec

2 files changed

Lines changed: 343 additions & 1 deletion

File tree

src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
# This allows get_type_hints() to resolve forward references
1414
_DYNAMIC_MODULE_NAME = "jsonschema_pydantic_converter._dynamic"
1515

16+
# Field names that shadow BaseModel attributes and must be renamed.
17+
# Computed from BaseModel's public interface to stay future-proof across Pydantic versions.
18+
_RESERVED_FIELD_NAMES: frozenset[str] = frozenset(
19+
name for name in dir(BaseModel) if not name.startswith("_")
20+
)
21+
1622

1723
def _get_or_create_dynamic_module() -> ModuleType:
1824
"""Get or create the shared pseudo-module for dynamic types."""
@@ -25,6 +31,116 @@ def _get_or_create_dynamic_module() -> ModuleType:
2531
return sys.modules[_DYNAMIC_MODULE_NAME]
2632

2733

34+
def _rename_reserved_properties(
35+
schema: dict[str, Any],
36+
) -> tuple[dict[str, Any], dict[str, str]]:
37+
"""Rename JSON Schema properties that would shadow BaseModel attributes.
38+
39+
Appends '_' to reserved property names in a copy of the schema. Tracks all
40+
renames so aliases can be applied to the resulting Pydantic model.
41+
42+
Returns:
43+
Tuple of (modified schema copy, {new_field_name: original_name}).
44+
"""
45+
renames: dict[str, str] = {}
46+
47+
def _process(s: dict[str, Any]) -> dict[str, Any]:
48+
result = s.copy()
49+
50+
if "properties" in result:
51+
existing_keys = set(result["properties"].keys())
52+
new_props: dict[str, Any] = {}
53+
54+
for key, value in result["properties"].items():
55+
if key in _RESERVED_FIELD_NAMES:
56+
new_key = key + "_"
57+
while new_key in existing_keys:
58+
new_key += "_"
59+
renames[new_key] = key
60+
else:
61+
new_key = key
62+
63+
new_props[new_key] = (
64+
_process(value) if isinstance(value, dict) else value
65+
)
66+
result["properties"] = new_props
67+
68+
if "required" in result:
69+
# Build a lookup from original→renamed for this level only
70+
local_renames = {v: k for k, v in renames.items() if v in existing_keys}
71+
result["required"] = [
72+
local_renames.get(name, name) for name in result["required"]
73+
]
74+
75+
for defs_key in ("$defs", "definitions"):
76+
if defs_key in result:
77+
result[defs_key] = {
78+
k: (_process(v) if isinstance(v, dict) else v)
79+
for k, v in result[defs_key].items()
80+
}
81+
82+
if "items" in result and isinstance(result["items"], dict):
83+
result["items"] = _process(result["items"])
84+
85+
for keyword in ("allOf", "anyOf", "oneOf"):
86+
if keyword in result:
87+
result[keyword] = [
88+
_process(sub) if isinstance(sub, dict) else sub
89+
for sub in result[keyword]
90+
]
91+
92+
if "not" in result and isinstance(result["not"], dict):
93+
result["not"] = _process(result["not"])
94+
95+
for keyword in ("if", "then", "else"):
96+
if keyword in result and isinstance(result[keyword], dict):
97+
result[keyword] = _process(result[keyword])
98+
99+
return result
100+
101+
modified = _process(schema)
102+
return modified, renames
103+
104+
105+
def _apply_field_aliases(
106+
model: Type[BaseModel],
107+
namespace: dict[str, Any],
108+
renames: dict[str, str],
109+
) -> None:
110+
"""Add aliases to renamed fields so serialization/validation uses original names.
111+
112+
Iterates the root model and all nested models from the namespace. For any
113+
field whose name appears in ``renames``, sets alias/validation_alias/
114+
serialization_alias to the original property name and enables
115+
``populate_by_name`` + ``serialize_by_alias`` in the model config.
116+
"""
117+
if not renames:
118+
return
119+
120+
all_models = [model]
121+
for v in namespace.values():
122+
if inspect.isclass(v) and issubclass(v, BaseModel):
123+
all_models.append(v)
124+
125+
for m in all_models:
126+
needs_rebuild = False
127+
for field_name, field_info in m.model_fields.items():
128+
if field_name in renames:
129+
original_name = renames[field_name]
130+
field_info.alias = original_name
131+
field_info.validation_alias = original_name
132+
field_info.serialization_alias = original_name
133+
needs_rebuild = True
134+
135+
if needs_rebuild:
136+
m.model_config = {
137+
**m.model_config,
138+
"populate_by_name": True,
139+
"serialize_by_alias": True,
140+
}
141+
m.model_rebuild(force=True)
142+
143+
28144
def create_model(
29145
schema: dict[str, Any],
30146
) -> Type[BaseModel]:
@@ -36,7 +152,9 @@ def create_model(
36152
category=UiPathErrorCategory.USER,
37153
)
38154

39-
model, namespace = transform_with_modules(schema)
155+
processed_schema, renames = _rename_reserved_properties(schema)
156+
model, namespace = transform_with_modules(processed_schema)
157+
_apply_field_aliases(model, namespace, renames)
40158
corrected_namespace: dict[str, Any] = {}
41159

42160
def collect_types(annotation: Any) -> None:

tests/agent/react/test_schemas.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
from typing import Any
44

55
import pytest
6+
from pydantic import BaseModel
67

78
from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode
89
from uipath_langchain.agent.react.jsonschema_pydantic_converter import (
10+
_RESERVED_FIELD_NAMES,
11+
_rename_reserved_properties,
912
create_model,
1013
has_underscore_fields,
1114
)
@@ -309,3 +312,224 @@ def test_nested_underscore_field(self) -> None:
309312
assert exc_info.value.error_info.code == AgentStartupError.full_code(
310313
AgentStartupErrorCode.UNDERSCORE_SCHEMA
311314
)
315+
316+
317+
class TestRenameReservedProperties:
318+
"""Tests for _rename_reserved_properties schema pre-processing."""
319+
320+
def test_renames_schema_field(self) -> None:
321+
schema = {
322+
"type": "object",
323+
"properties": {
324+
"schema": {"type": "string"},
325+
"name": {"type": "string"},
326+
},
327+
"required": ["schema", "name"],
328+
}
329+
modified, renames = _rename_reserved_properties(schema)
330+
331+
assert "schema_" in modified["properties"]
332+
assert "schema" not in modified["properties"]
333+
assert "name" in modified["properties"]
334+
assert renames == {"schema_": "schema"}
335+
assert modified["required"] == ["schema_", "name"]
336+
337+
def test_renames_multiple_reserved_fields(self) -> None:
338+
schema = {
339+
"type": "object",
340+
"properties": {
341+
"schema": {"type": "string"},
342+
"copy": {"type": "string"},
343+
"validate": {"type": "string"},
344+
"name": {"type": "string"},
345+
},
346+
}
347+
modified, renames = _rename_reserved_properties(schema)
348+
349+
assert "schema_" in modified["properties"]
350+
assert "copy_" in modified["properties"]
351+
assert "validate_" in modified["properties"]
352+
assert "name" in modified["properties"]
353+
assert len(renames) == 3
354+
355+
def test_handles_collision_with_existing_field(self) -> None:
356+
"""When 'schema_' already exists, 'schema' should become 'schema__'."""
357+
schema = {
358+
"type": "object",
359+
"properties": {
360+
"schema": {"type": "string"},
361+
"schema_": {"type": "string"},
362+
},
363+
}
364+
modified, renames = _rename_reserved_properties(schema)
365+
366+
assert "schema__" in modified["properties"]
367+
assert "schema_" in modified["properties"]
368+
assert renames["schema__"] == "schema"
369+
370+
def test_renames_in_defs(self) -> None:
371+
schema = {
372+
"type": "object",
373+
"properties": {"name": {"type": "string"}},
374+
"$defs": {
375+
"Inner": {
376+
"type": "object",
377+
"properties": {
378+
"schema": {"type": "string"},
379+
},
380+
"required": ["schema"],
381+
},
382+
},
383+
}
384+
modified, renames = _rename_reserved_properties(schema)
385+
386+
inner = modified["$defs"]["Inner"]
387+
assert "schema_" in inner["properties"]
388+
assert inner["required"] == ["schema_"]
389+
390+
def test_does_not_modify_original_schema(self) -> None:
391+
schema = {
392+
"type": "object",
393+
"properties": {"schema": {"type": "string"}},
394+
}
395+
_rename_reserved_properties(schema)
396+
397+
assert "schema" in schema["properties"]
398+
399+
def test_no_renames_for_normal_fields(self) -> None:
400+
schema = {
401+
"type": "object",
402+
"properties": {
403+
"name": {"type": "string"},
404+
"age": {"type": "integer"},
405+
},
406+
}
407+
modified, renames = _rename_reserved_properties(schema)
408+
409+
assert renames == {}
410+
assert modified["properties"] == schema["properties"]
411+
412+
413+
class TestCreateModelWithReservedFields:
414+
"""Tests for create_model handling of reserved field names."""
415+
416+
def test_schema_field_creates_valid_model(self) -> None:
417+
schema = {
418+
"title": "Input",
419+
"type": "object",
420+
"properties": {
421+
"schema": {"type": "string"},
422+
"name": {"type": "string"},
423+
},
424+
}
425+
model = create_model(schema)
426+
427+
assert issubclass(model, BaseModel)
428+
# BaseModel methods should still work
429+
assert callable(model.model_json_schema)
430+
assert callable(model.model_validate)
431+
432+
def test_model_validate_accepts_original_names(self) -> None:
433+
schema = {
434+
"title": "Input",
435+
"type": "object",
436+
"properties": {
437+
"schema": {"type": "string"},
438+
"name": {"type": "string"},
439+
},
440+
}
441+
model = create_model(schema)
442+
443+
instance = model.model_validate({"schema": "test_val", "name": "alice"})
444+
assert instance is not None
445+
446+
def test_model_dump_outputs_original_names(self) -> None:
447+
schema = {
448+
"title": "Input",
449+
"type": "object",
450+
"properties": {
451+
"schema": {"type": "string"},
452+
"name": {"type": "string"},
453+
},
454+
}
455+
model = create_model(schema)
456+
457+
instance = model.model_validate({"schema": "test_val", "name": "alice"})
458+
dumped = instance.model_dump()
459+
460+
assert dumped == {"schema": "test_val", "name": "alice"}
461+
462+
def test_model_dump_json_mode_outputs_original_names(self) -> None:
463+
schema = {
464+
"title": "Input",
465+
"type": "object",
466+
"properties": {
467+
"schema": {"type": "string"},
468+
},
469+
}
470+
model = create_model(schema)
471+
472+
instance = model.model_validate({"schema": "val"})
473+
dumped = instance.model_dump(mode="json")
474+
475+
assert dumped == {"schema": "val"}
476+
477+
def test_model_json_schema_shows_original_names(self) -> None:
478+
schema = {
479+
"title": "Input",
480+
"type": "object",
481+
"properties": {
482+
"schema": {"type": "string"},
483+
"copy": {"type": "integer"},
484+
},
485+
}
486+
model = create_model(schema)
487+
488+
json_schema = model.model_json_schema()
489+
assert "schema" in json_schema["properties"]
490+
assert "copy" in json_schema["properties"]
491+
assert "schema_" not in json_schema["properties"]
492+
assert "copy_" not in json_schema["properties"]
493+
494+
def test_multiple_reserved_fields(self) -> None:
495+
schema = {
496+
"title": "Input",
497+
"type": "object",
498+
"properties": {
499+
"schema": {"type": "string"},
500+
"copy": {"type": "string"},
501+
"validate": {"type": "string"},
502+
"name": {"type": "string"},
503+
},
504+
}
505+
model = create_model(schema)
506+
507+
instance = model.model_validate(
508+
{"schema": "s", "copy": "c", "validate": "v", "name": "n"}
509+
)
510+
dumped = instance.model_dump()
511+
512+
assert dumped == {"schema": "s", "copy": "c", "validate": "v", "name": "n"}
513+
514+
def test_model_fields_field_does_not_shadow(self) -> None:
515+
"""'model_fields' is in Pydantic's protected namespace — must not crash."""
516+
schema = {
517+
"title": "Input",
518+
"type": "object",
519+
"properties": {
520+
"model_fields": {"type": "string"},
521+
"name": {"type": "string"},
522+
},
523+
}
524+
model = create_model(schema)
525+
526+
# model.model_fields should still be the Pydantic descriptor, not a field value
527+
assert isinstance(model.model_fields, dict)
528+
instance = model.model_validate({"model_fields": "test", "name": "n"})
529+
assert instance.model_dump() == {"model_fields": "test", "name": "n"}
530+
531+
def test_reserved_field_names_constant_contains_known_problematic_names(
532+
self,
533+
) -> None:
534+
known_problematic = {"schema", "copy", "validate", "dict", "json", "construct"}
535+
assert known_problematic.issubset(_RESERVED_FIELD_NAMES)

0 commit comments

Comments
 (0)