uipath-langchain 0.1.34__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. uipath_langchain/_cli/_templates/langgraph.json.template +2 -4
  2. uipath_langchain/_cli/cli_new.py +1 -2
  3. uipath_langchain/agent/guardrails/actions/escalate_action.py +252 -108
  4. uipath_langchain/agent/guardrails/actions/filter_action.py +247 -12
  5. uipath_langchain/agent/guardrails/guardrail_nodes.py +47 -12
  6. uipath_langchain/agent/guardrails/guardrails_factory.py +40 -15
  7. uipath_langchain/agent/guardrails/utils.py +64 -33
  8. uipath_langchain/agent/react/agent.py +4 -2
  9. uipath_langchain/agent/react/file_type_handler.py +123 -0
  10. uipath_langchain/agent/react/guardrails/guardrails_subgraph.py +67 -12
  11. uipath_langchain/agent/react/init_node.py +16 -1
  12. uipath_langchain/agent/react/job_attachments.py +125 -0
  13. uipath_langchain/agent/react/json_utils.py +183 -0
  14. uipath_langchain/agent/react/jsonschema_pydantic_converter.py +76 -0
  15. uipath_langchain/agent/react/llm_with_files.py +76 -0
  16. uipath_langchain/agent/react/types.py +4 -0
  17. uipath_langchain/agent/react/utils.py +29 -3
  18. uipath_langchain/agent/tools/__init__.py +5 -1
  19. uipath_langchain/agent/tools/context_tool.py +151 -1
  20. uipath_langchain/agent/tools/escalation_tool.py +46 -15
  21. uipath_langchain/agent/tools/integration_tool.py +20 -16
  22. uipath_langchain/agent/tools/internal_tools/__init__.py +5 -0
  23. uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py +113 -0
  24. uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py +54 -0
  25. uipath_langchain/agent/tools/process_tool.py +8 -1
  26. uipath_langchain/agent/tools/static_args.py +18 -40
  27. uipath_langchain/agent/tools/tool_factory.py +13 -5
  28. uipath_langchain/agent/tools/tool_node.py +133 -4
  29. uipath_langchain/agent/tools/utils.py +31 -0
  30. uipath_langchain/agent/wrappers/__init__.py +6 -0
  31. uipath_langchain/agent/wrappers/job_attachment_wrapper.py +62 -0
  32. uipath_langchain/agent/wrappers/static_args_wrapper.py +34 -0
  33. uipath_langchain/chat/mapper.py +60 -42
  34. uipath_langchain/runtime/factory.py +10 -5
  35. uipath_langchain/runtime/runtime.py +38 -35
  36. uipath_langchain/runtime/storage.py +178 -71
  37. {uipath_langchain-0.1.34.dist-info → uipath_langchain-0.3.1.dist-info}/METADATA +5 -4
  38. {uipath_langchain-0.1.34.dist-info → uipath_langchain-0.3.1.dist-info}/RECORD +41 -30
  39. {uipath_langchain-0.1.34.dist-info → uipath_langchain-0.3.1.dist-info}/WHEEL +0 -0
  40. {uipath_langchain-0.1.34.dist-info → uipath_langchain-0.3.1.dist-info}/entry_points.txt +0 -0
  41. {uipath_langchain-0.1.34.dist-info → uipath_langchain-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,123 @@
1
+ import base64
2
+ from enum import StrEnum
3
+ from typing import Any
4
+
5
+ import httpx
6
+ from uipath._utils._ssl_context import get_httpx_client_kwargs
7
+
8
+ IMAGE_MIME_TYPES: set[str] = {
9
+ "image/png",
10
+ "image/jpeg",
11
+ "image/gif",
12
+ "image/webp",
13
+ }
14
+
15
+
16
+ class LlmProvider(StrEnum):
17
+ OPENAI = "openai"
18
+ BEDROCK = "bedrock"
19
+ VERTEX = "vertex"
20
+ UNKNOWN = "unknown"
21
+
22
+
23
+ def is_pdf(mime_type: str) -> bool:
24
+ """Check if the MIME type represents a PDF document."""
25
+ return mime_type.lower() == "application/pdf"
26
+
27
+
28
+ def is_image(mime_type: str) -> bool:
29
+ """Check if the MIME type represents a supported image format (PNG, JPEG, GIF, WebP)."""
30
+ return mime_type.lower() in IMAGE_MIME_TYPES
31
+
32
+
33
+ def detect_provider(model_name: str) -> LlmProvider:
34
+ """Detect the LLM provider (Bedrock, OpenAI, or Vertex) based on the model name."""
35
+ if not model_name:
36
+ raise ValueError(f"Unsupported model: {model_name}")
37
+
38
+ model_lower = model_name.lower()
39
+
40
+ if "anthropic" in model_lower or "claude" in model_lower:
41
+ return LlmProvider.BEDROCK
42
+
43
+ if "gpt" in model_lower:
44
+ return LlmProvider.OPENAI
45
+
46
+ if "gemini" in model_lower:
47
+ return LlmProvider.VERTEX
48
+
49
+ raise ValueError(f"Unsupported model: {model_name}")
50
+
51
+
52
+ async def _download_file(url: str) -> str:
53
+ """Download a file from a URL and return its content as a base64 string."""
54
+ async with httpx.AsyncClient(**get_httpx_client_kwargs()) as client:
55
+ response = await client.get(url)
56
+ response.raise_for_status()
57
+ file_content = response.content
58
+
59
+ return base64.b64encode(file_content).decode("utf-8")
60
+
61
+
62
+ async def build_message_content_part_from_data(
63
+ url: str,
64
+ filename: str,
65
+ mime_type: str,
66
+ model: str,
67
+ ) -> dict[str, Any]:
68
+ """Download a file and build a provider-specific message content part.
69
+
70
+ The format varies based on the detected provider (Bedrock, OpenAI, or Vertex).
71
+ """
72
+ provider = detect_provider(model)
73
+
74
+ if provider == LlmProvider.BEDROCK:
75
+ raise ValueError("Anthropic models are not yet supported for file attachments")
76
+
77
+ if provider == LlmProvider.OPENAI:
78
+ return await _build_openai_content_part_from_data(
79
+ url, mime_type, filename, False
80
+ )
81
+
82
+ if provider == LlmProvider.VERTEX:
83
+ raise ValueError("Gemini models are not yet supported for file attachments")
84
+
85
+ raise ValueError(f"Unsupported provider: {provider}")
86
+
87
+
88
+ async def _build_openai_content_part_from_data(
89
+ url: str,
90
+ mime_type: str,
91
+ filename: str,
92
+ download_image: bool,
93
+ ) -> dict[str, Any]:
94
+ """Build a content part for OpenAI models (base64-encoded or URL reference)."""
95
+ if download_image:
96
+ base64_content = await _download_file(url)
97
+ if is_image(mime_type):
98
+ data_url = f"data:{mime_type};base64,{base64_content}"
99
+ return {
100
+ "type": "input_image",
101
+ "image_url": data_url,
102
+ }
103
+
104
+ if is_pdf(mime_type):
105
+ return {
106
+ "type": "input_file",
107
+ "filename": filename,
108
+ "file_data": base64_content,
109
+ }
110
+
111
+ elif is_image(mime_type):
112
+ return {
113
+ "type": "input_image",
114
+ "image_url": url,
115
+ }
116
+
117
+ elif is_pdf(mime_type):
118
+ return {
119
+ "type": "input_file",
120
+ "file_url": url,
121
+ }
122
+
123
+ raise ValueError(f"Unsupported mime_type: {mime_type}")
@@ -1,9 +1,10 @@
1
1
  from functools import partial
2
- from typing import Any, Callable, Sequence
2
+ from typing import Any, Callable, Mapping, Sequence
3
3
 
4
+ from langgraph._internal._runnable import RunnableCallable
4
5
  from langgraph.constants import END, START
5
6
  from langgraph.graph import StateGraph
6
- from langgraph.prebuilt import ToolNode
7
+ from uipath.core.guardrails import DeterministicGuardrail
7
8
  from uipath.platform.guardrails import (
8
9
  BaseGuardrail,
9
10
  BuiltInValidatorGuardrail,
@@ -116,7 +117,7 @@ def _create_guardrails_subgraph(
116
117
  ExecutionStage.POST_EXECUTION,
117
118
  node_factory,
118
119
  END,
119
- inner_node,
120
+ inner_name,
120
121
  )
121
122
  subgraph.add_edge(inner_name, first_post_exec_guardrail_node)
122
123
  else:
@@ -203,10 +204,21 @@ def create_llm_guardrails_subgraph(
203
204
  llm_node: tuple[str, Any],
204
205
  guardrails: Sequence[tuple[BaseGuardrail, GuardrailAction]] | None,
205
206
  ):
207
+ """Create a guarded LLM node.
208
+
209
+ Args:
210
+ llm_node: Tuple of (node_name, node_callable) for the LLM node.
211
+ guardrails: Optional sequence of (guardrail, action) tuples.
212
+
213
+ Returns:
214
+ Either the original node callable (if no applicable guardrails) or a compiled
215
+ LangGraph subgraph that enforces the configured guardrails.
216
+ """
206
217
  applicable_guardrails = [
207
218
  (guardrail, _)
208
219
  for (guardrail, _) in (guardrails or [])
209
220
  if GuardrailScope.LLM in guardrail.selector.scopes
221
+ and not isinstance(guardrail, DeterministicGuardrail)
210
222
  ]
211
223
  if applicable_guardrails is None or len(applicable_guardrails) == 0:
212
224
  return llm_node[1]
@@ -221,13 +233,19 @@ def create_llm_guardrails_subgraph(
221
233
 
222
234
 
223
235
  def create_tools_guardrails_subgraph(
224
- tool_nodes: dict[str, ToolNode],
236
+ tool_nodes: Mapping[str, RunnableCallable],
225
237
  guardrails: Sequence[tuple[BaseGuardrail, GuardrailAction]] | None,
226
- ) -> dict[str, ToolNode]:
227
- """Create tool nodes with guardrails.
238
+ ) -> dict[str, RunnableCallable]:
239
+ """Create tool nodes with guardrails applied.
228
240
  Args:
241
+ tool_nodes: Mapping of tool name to a LangGraph `ToolNode`.
242
+ guardrails: Optional sequence of (guardrail, action) tuples.
243
+
244
+ Returns:
245
+ A mapping of tool name to either the original `ToolNode` or a compiled subgraph
246
+ that enforces the matching tool guardrails.
229
247
  """
230
- result: dict[str, ToolNode] = {}
248
+ result: dict[str, RunnableCallable] = {}
231
249
  for tool_name, tool_node in tool_nodes.items():
232
250
  subgraph = create_tool_guardrails_subgraph(
233
251
  (tool_name, tool_node),
@@ -241,23 +259,49 @@ def create_tools_guardrails_subgraph(
241
259
  def create_agent_init_guardrails_subgraph(
242
260
  init_node: tuple[str, Any],
243
261
  guardrails: Sequence[tuple[BaseGuardrail, GuardrailAction]] | None,
244
- ):
245
- """Create a subgraph for INIT node that applies guardrails on the state messages."""
262
+ ) -> Any:
263
+ """Create a subgraph for the INIT node and apply AGENT guardrails after INIT.
264
+
265
+ This subgraph intentionally **runs the INIT node first** (so it can seed/normalize
266
+ the agent state), and then evaluates guardrails as **PRE_EXECUTION**. This lets
267
+ guardrails intended to run "before agent execution" validate the post-init state.
268
+
269
+ Args:
270
+ init_node: Tuple of (node_name, node_callable) for the INIT node.
271
+ guardrails: Optional sequence of (guardrail, action) tuples.
272
+
273
+ Returns:
274
+ Either the original node callable (if no applicable guardrails) or a compiled
275
+ LangGraph subgraph that runs INIT then enforces PRE_EXECUTION AGENT guardrails.
276
+ """
246
277
  applicable_guardrails = [
247
278
  (guardrail, _)
248
279
  for (guardrail, _) in (guardrails or [])
249
280
  if GuardrailScope.AGENT in guardrail.selector.scopes
281
+ and not isinstance(guardrail, DeterministicGuardrail)
250
282
  ]
283
+ applicable_guardrails = _filter_guardrails_by_stage(
284
+ applicable_guardrails, ExecutionStage.PRE_EXECUTION
285
+ )
251
286
  if applicable_guardrails is None or len(applicable_guardrails) == 0:
252
287
  return init_node[1]
253
288
 
254
- return _create_guardrails_subgraph(
255
- main_inner_node=init_node,
289
+ inner_name, inner_node = init_node
290
+ subgraph = StateGraph(AgentGuardrailsGraphState)
291
+ subgraph.add_node(inner_name, inner_node)
292
+ subgraph.add_edge(START, inner_name)
293
+
294
+ first_guardrail_node = _build_guardrail_node_chain(
295
+ subgraph=subgraph,
256
296
  guardrails=applicable_guardrails,
257
297
  scope=GuardrailScope.AGENT,
258
- execution_stages=[ExecutionStage.POST_EXECUTION],
298
+ execution_stage=ExecutionStage.PRE_EXECUTION,
259
299
  node_factory=create_agent_init_guardrail_node,
300
+ next_node=END,
301
+ guarded_node_name=inner_name,
260
302
  )
303
+ subgraph.add_edge(inner_name, first_guardrail_node)
304
+ return subgraph.compile()
261
305
 
262
306
 
263
307
  def create_agent_terminate_guardrails_subgraph(
@@ -277,6 +321,7 @@ def create_agent_terminate_guardrails_subgraph(
277
321
  (guardrail, _)
278
322
  for (guardrail, _) in (guardrails or [])
279
323
  if GuardrailScope.AGENT in guardrail.selector.scopes
324
+ and not isinstance(guardrail, DeterministicGuardrail)
280
325
  ]
281
326
  if applicable_guardrails is None or len(applicable_guardrails) == 0:
282
327
  return terminate_node[1]
@@ -302,6 +347,16 @@ def create_tool_guardrails_subgraph(
302
347
  tool_node: tuple[str, Any],
303
348
  guardrails: Sequence[tuple[BaseGuardrail, GuardrailAction]] | None,
304
349
  ):
350
+ """Create a guarded tool node.
351
+
352
+ Args:
353
+ tool_node: Tuple of (tool_name, tool_node_callable).
354
+ guardrails: Optional sequence of (guardrail, action) tuples.
355
+
356
+ Returns:
357
+ Either the original tool node callable (if no matching guardrails) or a compiled
358
+ LangGraph subgraph that enforces the matching tool guardrails.
359
+ """
305
360
  tool_name, _ = tool_node
306
361
  applicable_guardrails = [
307
362
  (guardrail, action)
@@ -3,11 +3,17 @@
3
3
  from typing import Any, Callable, Sequence
4
4
 
5
5
  from langchain_core.messages import HumanMessage, SystemMessage
6
+ from pydantic import BaseModel
7
+
8
+ from .job_attachments import (
9
+ get_job_attachments,
10
+ )
6
11
 
7
12
 
8
13
  def create_init_node(
9
14
  messages: Sequence[SystemMessage | HumanMessage]
10
15
  | Callable[[Any], Sequence[SystemMessage | HumanMessage]],
16
+ input_schema: type[BaseModel] | None,
11
17
  ):
12
18
  def graph_state_init(state: Any):
13
19
  if callable(messages):
@@ -15,6 +21,15 @@ def create_init_node(
15
21
  else:
16
22
  resolved_messages = messages
17
23
 
18
- return {"messages": list(resolved_messages)}
24
+ schema = input_schema if input_schema is not None else BaseModel
25
+ job_attachments = get_job_attachments(schema, state)
26
+ job_attachments_dict = {
27
+ str(att.id): att for att in job_attachments if att.id is not None
28
+ }
29
+
30
+ return {
31
+ "messages": list(resolved_messages),
32
+ "job_attachments": job_attachments_dict,
33
+ }
19
34
 
20
35
  return graph_state_init
@@ -0,0 +1,125 @@
1
+ """Job attachment utilities for ReAct Agent."""
2
+
3
+ import copy
4
+ import uuid
5
+ from typing import Any
6
+
7
+ from jsonpath_ng import parse # type: ignore[import-untyped]
8
+ from pydantic import BaseModel
9
+ from uipath.platform.attachments import Attachment
10
+
11
+ from .json_utils import extract_values_by_paths, get_json_paths_by_type
12
+
13
+
14
+ def get_job_attachments(
15
+ schema: type[BaseModel],
16
+ data: dict[str, Any] | BaseModel,
17
+ ) -> list[Attachment]:
18
+ """Extract job attachments from data based on schema and convert to Attachment objects.
19
+
20
+ Args:
21
+ schema: The Pydantic model class defining the data structure
22
+ data: The data object (dict or Pydantic model) to extract attachments from
23
+
24
+ Returns:
25
+ List of Attachment objects
26
+ """
27
+ job_attachment_paths = get_job_attachment_paths(schema)
28
+ job_attachments = extract_values_by_paths(data, job_attachment_paths)
29
+
30
+ result = []
31
+ for attachment in job_attachments:
32
+ result.append(Attachment.model_validate(attachment, from_attributes=True))
33
+
34
+ return result
35
+
36
+
37
+ def get_job_attachment_paths(model: type[BaseModel]) -> list[str]:
38
+ """Get JSONPath expressions for all job attachment fields in a Pydantic model.
39
+
40
+ Args:
41
+ model: The Pydantic model class to analyze
42
+
43
+ Returns:
44
+ List of JSONPath expressions pointing to job attachment fields
45
+ """
46
+ return get_json_paths_by_type(model, "Job_attachment")
47
+
48
+
49
+ def replace_job_attachment_ids(
50
+ json_paths: list[str],
51
+ tool_args: dict[str, Any],
52
+ state: dict[str, Attachment],
53
+ errors: list[str],
54
+ ) -> dict[str, Any]:
55
+ """Replace job attachment IDs in tool_args with full attachment objects from state.
56
+
57
+ For each JSON path, this function finds matching objects in tool_args and
58
+ replaces them with corresponding attachment objects from state. The matching
59
+ is done by looking up the object's 'ID' field in the state dictionary.
60
+
61
+ If an ID is not a valid UUID or is not present in state, an error message
62
+ is added to the errors list.
63
+
64
+ Args:
65
+ json_paths: List of JSONPath expressions (e.g., ["$.attachment", "$.attachments[*]"])
66
+ tool_args: The dictionary containing tool arguments to modify
67
+ state: Dictionary mapping attachment UUID strings to Attachment objects
68
+ errors: List to collect error messages for invalid or missing IDs
69
+
70
+ Returns:
71
+ Modified copy of tool_args with attachment IDs replaced by full objects
72
+
73
+ Example:
74
+ >>> state = {
75
+ ... "123e4567-e89b-12d3-a456-426614174000": Attachment(id="123e4567-e89b-12d3-a456-426614174000", name="file1.pdf"),
76
+ ... "223e4567-e89b-12d3-a456-426614174001": Attachment(id="223e4567-e89b-12d3-a456-426614174001", name="file2.pdf")
77
+ ... }
78
+ >>> tool_args = {
79
+ ... "attachment": {"ID": "123"},
80
+ ... "other_field": "value"
81
+ ... }
82
+ >>> paths = ['$.attachment']
83
+ >>> errors = []
84
+ >>> replace_job_attachment_ids(paths, tool_args, state, errors)
85
+ {'attachment': {'ID': '123', 'name': 'file1.pdf', ...}, 'other_field': 'value'}
86
+ """
87
+ result = copy.deepcopy(tool_args)
88
+
89
+ for json_path in json_paths:
90
+ expr = parse(json_path)
91
+ matches = expr.find(result)
92
+
93
+ for match in matches:
94
+ current_value = match.value
95
+
96
+ if isinstance(current_value, dict) and "ID" in current_value:
97
+ attachment_id_str = str(current_value["ID"])
98
+
99
+ try:
100
+ uuid.UUID(attachment_id_str)
101
+ except (ValueError, AttributeError):
102
+ errors.append(
103
+ _create_job_attachment_error_message(attachment_id_str)
104
+ )
105
+ continue
106
+
107
+ if attachment_id_str in state:
108
+ replacement_value = state[attachment_id_str]
109
+ match.full_path.update(
110
+ result, replacement_value.model_dump(by_alias=True, mode="json")
111
+ )
112
+ else:
113
+ errors.append(
114
+ _create_job_attachment_error_message(attachment_id_str)
115
+ )
116
+
117
+ return result
118
+
119
+
120
+ def _create_job_attachment_error_message(attachment_id_str: str) -> str:
121
+ return (
122
+ f"Could not find JobAttachment with ID='{attachment_id_str}'. "
123
+ f"Try invoking the tool again and please make sure that you pass "
124
+ f"valid JobAttachment IDs associated with existing JobAttachments in the current context."
125
+ )
@@ -0,0 +1,183 @@
1
+ import sys
2
+ from typing import Any, ForwardRef, Union, get_args, get_origin
3
+
4
+ from jsonpath_ng import parse # type: ignore[import-untyped]
5
+ from pydantic import BaseModel
6
+
7
+
8
+ def get_json_paths_by_type(model: type[BaseModel], type_name: str) -> list[str]:
9
+ """Get JSONPath expressions for all fields that reference a specific type.
10
+
11
+ This function recursively traverses nested Pydantic models to find all paths
12
+ that lead to fields of the specified type.
13
+
14
+ Args:
15
+ model: A Pydantic model class
16
+ type_name: The name of the type to search for (e.g., "Job_attachment")
17
+
18
+ Returns:
19
+ List of JSONPath expressions using standard JSONPath syntax.
20
+ For array fields, uses [*] to indicate all array elements.
21
+
22
+ Example:
23
+ >>> schema = {
24
+ ... "type": "object",
25
+ ... "properties": {
26
+ ... "attachment": {"$ref": "#/definitions/job-attachment"},
27
+ ... "attachments": {
28
+ ... "type": "array",
29
+ ... "items": {"$ref": "#/definitions/job-attachment"}
30
+ ... }
31
+ ... },
32
+ ... "definitions": {
33
+ ... "job-attachment": {"type": "object", "properties": {"id": {"type": "string"}}}
34
+ ... }
35
+ ... }
36
+ >>> model = transform(schema)
37
+ >>> _get_json_paths_by_type(model, "Job_attachment")
38
+ ['$.attachment', '$.attachments[*]']
39
+ """
40
+
41
+ def _recursive_search(
42
+ current_model: type[BaseModel], current_path: str
43
+ ) -> list[str]:
44
+ """Recursively search for fields of the target type."""
45
+ json_paths = []
46
+
47
+ target_type = _get_target_type(current_model, type_name)
48
+ matches_type = _create_type_matcher(type_name, target_type)
49
+
50
+ for field_name, field_info in current_model.model_fields.items():
51
+ annotation = field_info.annotation
52
+
53
+ if current_path:
54
+ field_path = f"{current_path}.{field_name}"
55
+ else:
56
+ field_path = f"$.{field_name}"
57
+
58
+ annotation = _unwrap_optional(annotation)
59
+ origin = get_origin(annotation)
60
+
61
+ if matches_type(annotation):
62
+ json_paths.append(field_path)
63
+ continue
64
+
65
+ if origin is list:
66
+ args = get_args(annotation)
67
+ if args:
68
+ list_item_type = args[0]
69
+ if matches_type(list_item_type):
70
+ json_paths.append(f"{field_path}[*]")
71
+ continue
72
+
73
+ if _is_pydantic_model(list_item_type):
74
+ nested_paths = _recursive_search(
75
+ list_item_type, f"{field_path}[*]"
76
+ )
77
+ json_paths.extend(nested_paths)
78
+ continue
79
+
80
+ if _is_pydantic_model(annotation):
81
+ nested_paths = _recursive_search(annotation, field_path)
82
+ json_paths.extend(nested_paths)
83
+
84
+ return json_paths
85
+
86
+ return _recursive_search(model, "")
87
+
88
+
89
+ def extract_values_by_paths(
90
+ obj: dict[str, Any] | BaseModel, json_paths: list[str]
91
+ ) -> list[Any]:
92
+ """Extract values from an object using JSONPath expressions.
93
+
94
+ Args:
95
+ obj: The object (dict or Pydantic model) to extract values from
96
+ json_paths: List of JSONPath expressions. **Paths are assumed to be disjoint**
97
+ (non-overlapping). If paths overlap, duplicate values will be returned.
98
+
99
+ Returns:
100
+ List of all extracted values (flattened)
101
+
102
+ Example:
103
+ >>> obj = {
104
+ ... "attachment": {"id": "123"},
105
+ ... "attachments": [{"id": "456"}, {"id": "789"}]
106
+ ... }
107
+ >>> paths = ['$.attachment', '$.attachments[*]']
108
+ >>> _extract_values_by_paths(obj, paths)
109
+ [{'id': '123'}, {'id': '456'}, {'id': '789'}]
110
+ """
111
+ data = obj.model_dump() if isinstance(obj, BaseModel) else obj
112
+
113
+ results = []
114
+ for json_path in json_paths:
115
+ expr = parse(json_path)
116
+ matches = expr.find(data)
117
+ results.extend([match.value for match in matches])
118
+
119
+ return results
120
+
121
+
122
+ def _get_target_type(model: type[BaseModel], type_name: str) -> Any:
123
+ """Get the target type from the model's module.
124
+
125
+ Args:
126
+ model: A Pydantic model class
127
+ type_name: The name of the type to search for
128
+
129
+ Returns:
130
+ The target type if found, None otherwise
131
+ """
132
+ model_module = sys.modules.get(model.__module__)
133
+ if model_module and hasattr(model_module, type_name):
134
+ return getattr(model_module, type_name)
135
+ return None
136
+
137
+
138
+ def _create_type_matcher(type_name: str, target_type: Any) -> Any:
139
+ """Create a function that checks if an annotation matches the target type.
140
+
141
+ Args:
142
+ type_name: The name of the type to match
143
+ target_type: The actual type object (can be None)
144
+
145
+ Returns:
146
+ A function that takes an annotation and returns True if it matches
147
+ """
148
+
149
+ def matches_type(annotation: Any) -> bool:
150
+ """Check if an annotation matches the target type name."""
151
+ if isinstance(annotation, ForwardRef):
152
+ return annotation.__forward_arg__ == type_name
153
+ if isinstance(annotation, str):
154
+ return annotation == type_name
155
+ if hasattr(annotation, "__name__") and annotation.__name__ == type_name:
156
+ return True
157
+ if target_type is not None and annotation is target_type:
158
+ return True
159
+ return False
160
+
161
+ return matches_type
162
+
163
+
164
+ def _unwrap_optional(annotation: Any) -> Any:
165
+ """Unwrap Optional/Union types to get the underlying type.
166
+
167
+ Args:
168
+ annotation: The type annotation to unwrap
169
+
170
+ Returns:
171
+ The unwrapped type, or the original if not Optional/Union
172
+ """
173
+ origin = get_origin(annotation)
174
+ if origin is Union:
175
+ args = get_args(annotation)
176
+ non_none_args = [arg for arg in args if arg is not type(None)]
177
+ if non_none_args:
178
+ return non_none_args[0]
179
+ return annotation
180
+
181
+
182
+ def _is_pydantic_model(annotation: Any) -> bool:
183
+ return isinstance(annotation, type) and issubclass(annotation, BaseModel)
@@ -0,0 +1,76 @@
1
+ import inspect
2
+ import sys
3
+ from types import ModuleType
4
+ from typing import Any, Type, get_args, get_origin
5
+
6
+ from jsonschema_pydantic_converter import transform_with_modules
7
+ from pydantic import BaseModel
8
+
9
+ # Shared pseudo-module for all dynamically created types
10
+ # This allows get_type_hints() to resolve forward references
11
+ _DYNAMIC_MODULE_NAME = "jsonschema_pydantic_converter._dynamic"
12
+
13
+
14
+ def _get_or_create_dynamic_module() -> ModuleType:
15
+ """Get or create the shared pseudo-module for dynamic types."""
16
+ if _DYNAMIC_MODULE_NAME not in sys.modules:
17
+ pseudo_module = ModuleType(_DYNAMIC_MODULE_NAME)
18
+ pseudo_module.__doc__ = (
19
+ "Shared module for dynamically generated Pydantic models from JSON schemas"
20
+ )
21
+ sys.modules[_DYNAMIC_MODULE_NAME] = pseudo_module
22
+ return sys.modules[_DYNAMIC_MODULE_NAME]
23
+
24
+
25
+ def create_model(
26
+ schema: dict[str, Any],
27
+ ) -> Type[BaseModel]:
28
+ model, namespace = transform_with_modules(schema)
29
+ corrected_namespace: dict[str, Any] = {}
30
+
31
+ def collect_types(annotation: Any) -> None:
32
+ """Recursively collect all BaseModel types from an annotation."""
33
+ # Unwrap generic types like List, Optional, etc.
34
+ origin = get_origin(annotation)
35
+ if origin is not None:
36
+ for arg in get_args(annotation):
37
+ collect_types(arg)
38
+
39
+ elif inspect.isclass(annotation) and issubclass(annotation, BaseModel):
40
+ # Find the original name for this type from the namespace
41
+ for type_name, type_def in namespace.items():
42
+ # Match by class name since rebuild may create new instances
43
+ if (
44
+ hasattr(annotation, "__name__")
45
+ and hasattr(type_def, "__name__")
46
+ and annotation.__name__ == type_def.__name__
47
+ ):
48
+ # Store the actual annotation type, not the old namespace one
49
+ annotation.__name__ = type_name
50
+ corrected_namespace[type_name] = annotation
51
+ break
52
+
53
+ # Collect all types from field annotations
54
+ for field_info in model.model_fields.values():
55
+ collect_types(field_info.annotation)
56
+
57
+ # Get the shared pseudo-module and populate it with this schema's types
58
+ # This ensures that forward references can be resolved by get_type_hints()
59
+ # when the model is used with external libraries (e.g., LangGraph)
60
+ pseudo_module = _get_or_create_dynamic_module()
61
+
62
+ # Populate the pseudo-module with all types from the namespace
63
+ # Use the original names so forward references resolve correctly
64
+ for type_name, type_def in corrected_namespace.items():
65
+ setattr(pseudo_module, type_name, type_def)
66
+
67
+ setattr(pseudo_module, model.__name__, model)
68
+
69
+ # Update the model's __module__ to point to the shared pseudo-module
70
+ model.__module__ = _DYNAMIC_MODULE_NAME
71
+
72
+ # Update the __module__ of all generated types in the namespace
73
+ for type_def in corrected_namespace.values():
74
+ if inspect.isclass(type_def) and issubclass(type_def, BaseModel):
75
+ type_def.__module__ = _DYNAMIC_MODULE_NAME
76
+ return model