Skip to content

Commit fb5f31b

Browse files
fix: remove namespace filtering from create_model (#662)
1 parent 8beb60f commit fb5f31b

5 files changed

Lines changed: 226 additions & 44 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.8.3"
3+
version = "0.8.4"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py

Lines changed: 5 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import inspect
22
import sys
33
from types import ModuleType
4-
from typing import Any, Type, get_args, get_origin
4+
from typing import Any, Type
55

66
from jsonschema_pydantic_converter import transform_with_modules
77
from pydantic import BaseModel
@@ -37,53 +37,17 @@ def create_model(
3737
)
3838

3939
model, namespace = transform_with_modules(schema)
40-
corrected_namespace: dict[str, Any] = {}
41-
42-
def collect_types(annotation: Any) -> None:
43-
"""Recursively collect all BaseModel types from an annotation."""
44-
# Unwrap generic types like List, Optional, etc.
45-
origin = get_origin(annotation)
46-
if origin is not None:
47-
for arg in get_args(annotation):
48-
collect_types(arg)
49-
50-
elif inspect.isclass(annotation) and issubclass(annotation, BaseModel):
51-
# Find the original name for this type from the namespace
52-
for type_name, type_def in namespace.items():
53-
# Match by class name since rebuild may create new instances
54-
if (
55-
hasattr(annotation, "__name__")
56-
and hasattr(type_def, "__name__")
57-
and annotation.__name__ == type_def.__name__
58-
):
59-
# Store the actual annotation type, not the old namespace one
60-
annotation.__name__ = type_name
61-
corrected_namespace[type_name] = annotation
62-
break
63-
64-
# Collect all types from field annotations
65-
for field_info in model.model_fields.values():
66-
collect_types(field_info.annotation)
67-
68-
# Get the shared pseudo-module and populate it with this schema's types
69-
# This ensures that forward references can be resolved by get_type_hints()
70-
# when the model is used with external libraries (e.g., LangGraph)
40+
7141
pseudo_module = _get_or_create_dynamic_module()
7242

73-
# Populate the pseudo-module with all types from the namespace
74-
# Use the original names so forward references resolve correctly
75-
for type_name, type_def in corrected_namespace.items():
43+
for type_name, type_def in namespace.items():
7644
setattr(pseudo_module, type_name, type_def)
45+
if inspect.isclass(type_def) and issubclass(type_def, BaseModel):
46+
type_def.__module__ = _DYNAMIC_MODULE_NAME
7747

7848
setattr(pseudo_module, model.__name__, model)
79-
80-
# Update the model's __module__ to point to the shared pseudo-module
8149
model.__module__ = _DYNAMIC_MODULE_NAME
8250

83-
# Update the __module__ of all generated types in the namespace
84-
for type_def in corrected_namespace.values():
85-
if inspect.isclass(type_def) and issubclass(type_def, BaseModel):
86-
type_def.__module__ = _DYNAMIC_MODULE_NAME
8751
return model
8852

8953

tests/agent/react/test_json_utils.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
from typing import Optional
1+
from typing import Any, Optional
22

33
from pydantic import BaseModel, RootModel
44

55
from uipath_langchain.agent.react.json_utils import (
66
extract_values_by_paths,
77
get_json_paths_by_type,
88
)
9+
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
910

1011

1112
class Target(BaseModel):
@@ -151,3 +152,78 @@ def test_extract_no_paths(self):
151152
def test_extract_path_not_found(self):
152153
values = extract_values_by_paths({"a": 1}, ["$.missing"])
153154
assert values == []
155+
156+
157+
# -- get_json_paths_by_type: dynamic models from create_model ------------------
158+
159+
160+
class TestGetJsonPathsByTypeDynamic:
161+
"""Exercise _get_target_type pseudo-module lookup with dynamic models."""
162+
163+
def test_direct_ref(self) -> None:
164+
schema: dict[str, Any] = {
165+
"type": "object",
166+
"properties": {
167+
"attachment": {"$ref": "#/definitions/job-attachment"},
168+
"name": {"type": "string"},
169+
},
170+
"definitions": {
171+
"job-attachment": {
172+
"type": "object",
173+
"properties": {
174+
"ID": {"type": "string"},
175+
"FullName": {"type": "string"},
176+
},
177+
}
178+
},
179+
}
180+
model = create_model(schema)
181+
paths = get_json_paths_by_type(model, "__Job_attachment")
182+
assert paths == ["$.attachment"]
183+
184+
def test_array_ref(self) -> None:
185+
schema: dict[str, Any] = {
186+
"type": "object",
187+
"properties": {
188+
"items": {
189+
"type": "array",
190+
"items": {"$ref": "#/definitions/job-attachment"},
191+
}
192+
},
193+
"definitions": {
194+
"job-attachment": {
195+
"type": "object",
196+
"properties": {"ID": {"type": "string"}},
197+
}
198+
},
199+
}
200+
model = create_model(schema)
201+
paths = get_json_paths_by_type(model, "__Job_attachment")
202+
assert paths == ["$.items[*]"]
203+
204+
def test_nested_ref_only_in_defs(self) -> None:
205+
"""Type referenced only by another $def, not directly by root."""
206+
schema: dict[str, Any] = {
207+
"type": "object",
208+
"properties": {
209+
"order": {"$ref": "#/$defs/Order"},
210+
},
211+
"$defs": {
212+
"Order": {
213+
"type": "object",
214+
"properties": {
215+
"item": {"$ref": "#/$defs/Item"},
216+
"quantity": {"type": "integer"},
217+
},
218+
},
219+
"Item": {
220+
"type": "object",
221+
"properties": {
222+
"name": {"type": "string"},
223+
},
224+
},
225+
},
226+
}
227+
model = create_model(schema)
228+
paths = get_json_paths_by_type(model, "__Item")
229+
assert paths == ["$.order.item"]

tests/agent/react/test_schemas.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,148 @@ def test_returns_false(self, schema: dict[str, Any]) -> None:
275275
assert has_underscore_fields(schema) is False
276276

277277

278+
class TestCreateModelJsonSchemaRoundtrip:
279+
"""Verify that model_json_schema() works on models produced by create_model().
280+
281+
Pydantic re-resolves forward references via sys.modules[cls.__module__],
282+
so all types must be registered in the pseudo-module.
283+
"""
284+
285+
def test_single_ref(self) -> None:
286+
schema: dict[str, Any] = {
287+
"type": "object",
288+
"properties": {
289+
"address": {"$ref": "#/$defs/Address"},
290+
},
291+
"$defs": {
292+
"Address": {
293+
"type": "object",
294+
"properties": {
295+
"street": {"type": "string"},
296+
"city": {"type": "string"},
297+
},
298+
},
299+
},
300+
}
301+
model = create_model(schema)
302+
result = model.model_json_schema()
303+
defs = result.get("$defs") or result.get("definitions") or {}
304+
assert len(defs) == 1
305+
306+
def test_deeply_nested_defs_chain(self) -> None:
307+
"""Type B is only referenced by type A (not by root). Old code missed B."""
308+
schema: dict[str, Any] = {
309+
"type": "object",
310+
"properties": {
311+
"order": {"$ref": "#/$defs/Order"},
312+
},
313+
"$defs": {
314+
"Order": {
315+
"type": "object",
316+
"properties": {
317+
"item": {"$ref": "#/$defs/Item"},
318+
"quantity": {"type": "integer"},
319+
},
320+
},
321+
"Item": {
322+
"type": "object",
323+
"properties": {
324+
"name": {"type": "string"},
325+
"price": {"type": "number"},
326+
},
327+
},
328+
},
329+
}
330+
model = create_model(schema)
331+
result = model.model_json_schema()
332+
defs = result.get("$defs") or result.get("definitions") or {}
333+
assert len(defs) == 2
334+
335+
def test_three_level_defs_chain(self) -> None:
336+
"""Root -> A -> B -> C. C is two levels removed from root fields."""
337+
schema: dict[str, Any] = {
338+
"type": "object",
339+
"properties": {
340+
"company": {"$ref": "#/$defs/Company"},
341+
},
342+
"$defs": {
343+
"Company": {
344+
"type": "object",
345+
"properties": {
346+
"department": {"$ref": "#/$defs/Department"},
347+
},
348+
},
349+
"Department": {
350+
"type": "object",
351+
"properties": {
352+
"manager": {"$ref": "#/$defs/Employee"},
353+
},
354+
},
355+
"Employee": {
356+
"type": "object",
357+
"properties": {
358+
"name": {"type": "string"},
359+
"role": {"type": "string"},
360+
},
361+
},
362+
},
363+
}
364+
model = create_model(schema)
365+
result = model.model_json_schema()
366+
defs = result.get("$defs") or result.get("definitions") or {}
367+
assert len(defs) == 3
368+
369+
def test_enum_in_defs(self) -> None:
370+
"""Non-BaseModel types (enums) in $defs must also be registered."""
371+
schema: dict[str, Any] = {
372+
"type": "object",
373+
"properties": {
374+
"status": {"$ref": "#/$defs/Status"},
375+
},
376+
"$defs": {
377+
"Status": {
378+
"type": "string",
379+
"enum": ["active", "inactive", "pending"],
380+
},
381+
},
382+
}
383+
model = create_model(schema)
384+
result = model.model_json_schema()
385+
assert result is not None
386+
387+
def test_array_of_nested_refs(self) -> None:
388+
"""Array field referencing a $def type that itself has a $ref."""
389+
schema: dict[str, Any] = {
390+
"type": "object",
391+
"properties": {
392+
"tasks": {
393+
"type": "array",
394+
"items": {"$ref": "#/$defs/Task"},
395+
},
396+
},
397+
"$defs": {
398+
"Task": {
399+
"type": "object",
400+
"properties": {
401+
"assignee": {"$ref": "#/$defs/Person"},
402+
"title": {"type": "string"},
403+
},
404+
},
405+
"Person": {
406+
"type": "object",
407+
"properties": {
408+
"name": {"type": "string"},
409+
"email": {"type": "string"},
410+
},
411+
},
412+
},
413+
}
414+
model = create_model(schema)
415+
result = model.model_json_schema()
416+
defs = result.get("$defs") or result.get("definitions") or {}
417+
assert len(defs) == 2
418+
419+
278420
class TestCreateModelRejectsUnderscoreFields:
279421
def test_top_level_underscore_field(self) -> None:
280422
schema = {

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)