arcade-core 2.2.0__py3-none-any.whl → 2.2.2__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.
arcade_core/catalog.py CHANGED
@@ -4,7 +4,7 @@ import logging
4
4
  import os
5
5
  import re
6
6
  import typing
7
- from collections.abc import Iterator
7
+ from collections.abc import Callable, Iterator
8
8
  from dataclasses import dataclass
9
9
  from datetime import datetime
10
10
  from enum import Enum
@@ -13,13 +13,12 @@ from types import ModuleType
13
13
  from typing import (
14
14
  Annotated,
15
15
  Any,
16
- Callable,
17
16
  Literal,
18
- Optional,
19
17
  Union,
20
18
  cast,
21
19
  get_args,
22
20
  get_origin,
21
+ get_type_hints,
23
22
  )
24
23
 
25
24
  from pydantic import BaseModel, Field, create_model
@@ -62,6 +61,28 @@ InnerWireType = Literal["string", "integer", "number", "boolean", "json"]
62
61
  WireType = Union[InnerWireType, Literal["array"]]
63
62
 
64
63
 
64
+ def is_typeddict(tp: type) -> bool:
65
+ """
66
+ Check if a type is a TypedDict.
67
+ Works with both typing.TypedDict and typing_extensions.TypedDict.
68
+ """
69
+ try:
70
+ # TypedDict creates classes that inherit from dict
71
+ if not isinstance(tp, type) or not issubclass(tp, dict):
72
+ return False
73
+
74
+ # Check for TypedDict-specific attributes
75
+ return (
76
+ hasattr(tp, "__annotations__")
77
+ and hasattr(tp, "__total__")
78
+ and hasattr(tp, "__required_keys__")
79
+ and hasattr(tp, "__optional_keys__")
80
+ )
81
+ except TypeError:
82
+ # Some special forms raise TypeError when checking issubclass
83
+ return False
84
+
85
+
65
86
  @dataclass
66
87
  class WireTypeInfo:
67
88
  """
@@ -71,6 +92,9 @@ class WireTypeInfo:
71
92
  wire_type: WireType
72
93
  inner_wire_type: InnerWireType | None = None
73
94
  enum_values: list[str] | None = None
95
+ properties: dict[str, "WireTypeInfo"] | None = None
96
+ inner_properties: dict[str, "WireTypeInfo"] | None = None
97
+ description: str | None = None
74
98
 
75
99
 
76
100
  class ToolMeta(BaseModel):
@@ -79,9 +103,9 @@ class ToolMeta(BaseModel):
79
103
  """
80
104
 
81
105
  module: str
82
- toolkit: Optional[str] = None
83
- package: Optional[str] = None
84
- path: Optional[str] = None
106
+ toolkit: str | None = None
107
+ package: str | None = None
108
+ path: str | None = None
85
109
  date_added: datetime = Field(default_factory=datetime.now)
86
110
  date_updated: datetime = Field(default_factory=datetime.now)
87
111
 
@@ -171,7 +195,7 @@ class ToolCatalog(BaseModel):
171
195
  def add_tool(
172
196
  self,
173
197
  tool_func: Callable,
174
- toolkit_or_name: Union[str, Toolkit],
198
+ toolkit_or_name: str | Toolkit,
175
199
  module: ModuleType | None = None,
176
200
  ) -> None:
177
201
  """
@@ -289,7 +313,10 @@ class ToolCatalog(BaseModel):
289
313
  raise ValueError(f"Tool {func} not found in the catalog.")
290
314
 
291
315
  def get_tool_by_name(
292
- self, name: str, version: Optional[str] = None, separator: str = TOOL_NAME_SEPARATOR
316
+ self,
317
+ name: str,
318
+ version: str | None = None,
319
+ separator: str = TOOL_NAME_SEPARATOR,
293
320
  ) -> MaterializedTool:
294
321
  """Get a tool from the catalog by name.
295
322
 
@@ -353,8 +380,8 @@ class ToolCatalog(BaseModel):
353
380
  def create_tool_definition(
354
381
  tool: Callable,
355
382
  toolkit_name: str,
356
- toolkit_version: Optional[str] = None,
357
- toolkit_desc: Optional[str] = None,
383
+ toolkit_version: str | None = None,
384
+ toolkit_desc: str | None = None,
358
385
  ) -> ToolDefinition:
359
386
  """
360
387
  Given a tool function, create a ToolDefinition
@@ -431,16 +458,13 @@ def create_input_definition(func: Callable) -> ToolInput:
431
458
  description=tool_field_info.description,
432
459
  required=is_required,
433
460
  inferrable=tool_field_info.is_inferrable,
434
- value_schema=ValueSchema(
435
- val_type=tool_field_info.wire_type_info.wire_type,
436
- inner_val_type=tool_field_info.wire_type_info.inner_wire_type,
437
- enum=tool_field_info.wire_type_info.enum_values,
438
- ),
461
+ value_schema=wire_type_info_to_value_schema(tool_field_info.wire_type_info),
439
462
  )
440
463
  )
441
464
 
442
465
  return ToolInput(
443
- parameters=input_parameters, tool_context_parameter_name=tool_context_param_name
466
+ parameters=input_parameters,
467
+ tool_context_parameter_name=tool_context_param_name,
444
468
  )
445
469
 
446
470
 
@@ -478,11 +502,7 @@ def create_output_definition(func: Callable) -> ToolOutput:
478
502
  return ToolOutput(
479
503
  description=description,
480
504
  available_modes=available_modes,
481
- value_schema=ValueSchema(
482
- val_type=wire_type_info.wire_type,
483
- inner_val_type=wire_type_info.inner_wire_type,
484
- enum=wire_type_info.enum_values,
485
- ),
505
+ value_schema=wire_type_info_to_value_schema(wire_type_info),
486
506
  )
487
507
 
488
508
 
@@ -669,12 +689,21 @@ def get_wire_type_info(_type: type) -> WireTypeInfo:
669
689
  # Is this a list type?
670
690
  # If so, get the inner (enclosed) type
671
691
  is_list = get_origin(_type) is list
692
+ inner_properties = None
693
+
672
694
  if is_list:
673
695
  inner_type = get_args(_type)[0]
674
- inner_wire_type = cast(
675
- InnerWireType,
676
- get_wire_type(str) if is_string_literal(inner_type) else get_wire_type(inner_type),
677
- )
696
+
697
+ # Recursively get wire type info for inner type
698
+ inner_info = get_wire_type_info(inner_type)
699
+ inner_wire_type = cast(InnerWireType, inner_info.wire_type)
700
+
701
+ # If inner type has properties (it's a complex object), propagate them
702
+ if inner_info.properties:
703
+ inner_properties = inner_info.properties
704
+ # If inner type is array (nested arrays), propagate inner_properties
705
+ elif inner_info.inner_properties:
706
+ inner_properties = inner_info.inner_properties
678
707
  else:
679
708
  inner_wire_type = None
680
709
 
@@ -696,11 +725,133 @@ def get_wire_type_info(_type: type) -> WireTypeInfo:
696
725
  enum_values = [str(e) for e in get_args(type_to_check)]
697
726
 
698
727
  # Special case: Enum can be enumerated on the wire
699
- elif issubclass(actual_type, Enum):
728
+ elif isinstance(actual_type, type) and issubclass(actual_type, Enum):
700
729
  is_enum = True
701
- enum_values = [e.value for e in actual_type] # type: ignore[union-attr]
730
+ enum_values = [e.value for e in actual_type]
731
+
732
+ # Extract properties for complex types
733
+ properties = None
734
+ if wire_type == "json" and not is_list:
735
+ properties = extract_properties(type_to_check)
736
+
737
+ return WireTypeInfo(
738
+ wire_type,
739
+ inner_wire_type,
740
+ enum_values if is_enum else None,
741
+ properties,
742
+ inner_properties,
743
+ )
744
+
745
+
746
+ def _extract_typeddict_field_descriptions(typeddict_class: type) -> dict[str, str]:
747
+ """
748
+ Extract field descriptions from TypedDict docstrings.
749
+
750
+ TypedDict classes typically have field descriptions as docstrings after each field.
751
+ This function attempts to parse the source code to extract these descriptions.
752
+ """
753
+ descriptions = {}
754
+
755
+ try:
756
+ source = inspect.getsource(typeddict_class)
757
+ # Simple regex to match field: type pattern followed by a docstring
758
+ # This is a simplified approach - a full AST parser would be more robust
759
+ import re
760
+
761
+ # Pattern to match field definition followed by docstring
762
+ pattern = r'(\w+):\s*[^"\n]+\n\s*"""([^"]+)"""'
763
+ matches = re.findall(pattern, source)
764
+
765
+ for field_name, description in matches:
766
+ descriptions[field_name] = description.strip()
767
+
768
+ except (OSError, TypeError):
769
+ # If we can't get the source, return empty descriptions
770
+ pass
771
+
772
+ return descriptions
773
+
774
+
775
+ def extract_properties(type_to_check: type) -> dict[str, WireTypeInfo] | None:
776
+ """
777
+ Extract properties from TypedDict, Pydantic models, or other structured types.
778
+ """
779
+ properties = {}
780
+
781
+ # Handle Pydantic BaseModel
782
+ if isinstance(type_to_check, type) and issubclass(type_to_check, BaseModel):
783
+ for field_name, field_info in type_to_check.model_fields.items():
784
+ # Get the field type
785
+ field_type = field_info.annotation
786
+ if field_type is None:
787
+ continue
788
+
789
+ # Handle Optional types (Union[T, None])
790
+ if is_strict_optional(field_type):
791
+ # Extract the non-None type from Optional
792
+ field_type = next(arg for arg in get_args(field_type) if arg is not type(None))
793
+
794
+ # Get wire type info recursively
795
+ wire_info = get_wire_type_info(field_type)
796
+ properties[field_name] = wire_info
797
+
798
+ # Handle TypedDict
799
+ elif is_typeddict(type_to_check):
800
+ # Get type hints for the TypedDict
801
+ type_hints = get_type_hints(type_to_check, include_extras=True)
702
802
 
703
- return WireTypeInfo(wire_type, inner_wire_type, enum_values if is_enum else None)
803
+ # Try to extract field descriptions from the class source
804
+ field_descriptions = _extract_typeddict_field_descriptions(type_to_check)
805
+
806
+ for field_name, field_type in type_hints.items():
807
+ # Handle Optional types (Union[T, None])
808
+ if is_strict_optional(field_type):
809
+ # Extract the non-None type from Optional
810
+ field_type = next(arg for arg in get_args(field_type) if arg is not type(None))
811
+ wire_info = get_wire_type_info(field_type)
812
+
813
+ # Add description if available
814
+ if field_name in field_descriptions:
815
+ wire_info.description = field_descriptions[field_name]
816
+
817
+ properties[field_name] = wire_info
818
+
819
+ # Handle regular dict with type annotations (e.g., dict[str, Any])
820
+ elif get_origin(type_to_check) is dict:
821
+ # For generic dicts, we can't extract specific properties
822
+ return None
823
+
824
+ return properties if properties else None
825
+
826
+
827
+ def wire_type_info_to_value_schema(wire_info: WireTypeInfo) -> ValueSchema:
828
+ """
829
+ Convert WireTypeInfo to ValueSchema, including nested properties.
830
+ """
831
+ # Convert nested properties if they exist
832
+ properties = None
833
+ if wire_info.properties:
834
+ properties = {
835
+ name: wire_type_info_to_value_schema(nested_info)
836
+ for name, nested_info in wire_info.properties.items()
837
+ }
838
+
839
+ # Convert inner properties for array items
840
+ inner_properties = None
841
+ if wire_info.inner_properties:
842
+ inner_properties = {
843
+ name: wire_type_info_to_value_schema(nested_info)
844
+ for name, nested_info in wire_info.inner_properties.items()
845
+ }
846
+
847
+ return ValueSchema(
848
+ val_type=wire_info.wire_type,
849
+ inner_val_type=wire_info.inner_wire_type,
850
+ enum=wire_info.enum_values,
851
+ properties=properties,
852
+ inner_properties=inner_properties,
853
+ description=wire_info.description,
854
+ )
704
855
 
705
856
 
706
857
  def extract_python_param_info(param: inspect.Parameter) -> ParamInfo:
@@ -799,6 +950,9 @@ def get_wire_type(
799
950
  if isinstance(_type, type) and issubclass(_type, BaseModel):
800
951
  return "json"
801
952
 
953
+ if is_typeddict(_type):
954
+ return "json"
955
+
802
956
  raise ToolDefinitionError(f"Unsupported parameter type: {_type}")
803
957
 
804
958
 
@@ -831,7 +985,7 @@ def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]
831
985
  return input_model, output_model
832
986
 
833
987
 
834
- def determine_output_model(func: Callable) -> type[BaseModel]:
988
+ def determine_output_model(func: Callable) -> type[BaseModel]: # noqa: C901
835
989
  """
836
990
  Determine the output model for a function based on its return annotation.
837
991
  """
@@ -845,6 +999,18 @@ def determine_output_model(func: Callable) -> type[BaseModel]:
845
999
  description = (
846
1000
  return_annotation.__metadata__[0] if return_annotation.__metadata__ else ""
847
1001
  )
1002
+
1003
+ # Check if the field type is a TypedDict
1004
+ if is_typeddict(field_type):
1005
+ # Create a Pydantic model from TypedDict
1006
+ typeddict_model = create_model_from_typeddict(
1007
+ field_type, f"{output_model_name}TypedDict"
1008
+ )
1009
+ return create_model(
1010
+ output_model_name,
1011
+ result=(typeddict_model, Field(description=str(description))),
1012
+ )
1013
+
848
1014
  if description:
849
1015
  return create_model(
850
1016
  output_model_name,
@@ -857,6 +1023,18 @@ def determine_output_model(func: Callable) -> type[BaseModel]:
857
1023
  # TODO handle multiple non-None arguments. Raise error?
858
1024
  for arg in get_args(return_annotation):
859
1025
  if arg is not type(None):
1026
+ # Check if the arg is a TypedDict
1027
+ if is_typeddict(arg):
1028
+ typeddict_model = create_model_from_typeddict(
1029
+ arg, f"{output_model_name}TypedDict"
1030
+ )
1031
+ return create_model(
1032
+ output_model_name,
1033
+ result=(
1034
+ typeddict_model,
1035
+ Field(description="No description provided."),
1036
+ ),
1037
+ )
860
1038
  return create_model(
861
1039
  output_model_name,
862
1040
  result=(arg, Field(description="No description provided.")),
@@ -871,6 +1049,17 @@ def determine_output_model(func: Callable) -> type[BaseModel]:
871
1049
  ),
872
1050
  )
873
1051
  else:
1052
+ # Check if return type is TypedDict
1053
+ if is_typeddict(return_annotation):
1054
+ typeddict_model = create_model_from_typeddict(return_annotation, output_model_name)
1055
+ return create_model(
1056
+ output_model_name,
1057
+ result=(
1058
+ typeddict_model,
1059
+ Field(description="No description provided."),
1060
+ ),
1061
+ )
1062
+
874
1063
  # Handle simple return types (like str)
875
1064
  return create_model(
876
1065
  output_model_name,
@@ -878,6 +1067,37 @@ def determine_output_model(func: Callable) -> type[BaseModel]:
878
1067
  )
879
1068
 
880
1069
 
1070
+ def create_model_from_typeddict(typeddict_class: type, model_name: str) -> type[BaseModel]:
1071
+ """
1072
+ Create a Pydantic model from a TypedDict class.
1073
+ This enables runtime validation of TypedDict structures.
1074
+ """
1075
+ # Get type hints for the TypedDict
1076
+ type_hints = get_type_hints(typeddict_class, include_extras=True)
1077
+
1078
+ # Build field definitions for the Pydantic model
1079
+ field_definitions: dict[str, Any] = {}
1080
+ for field_name, field_type in type_hints.items():
1081
+ # Check if field is required
1082
+ is_required = field_name in getattr(typeddict_class, "__required_keys__", set())
1083
+
1084
+ # Handle nested TypedDict
1085
+ if is_typeddict(field_type):
1086
+ nested_model = create_model_from_typeddict(field_type, f"{model_name}_{field_name}")
1087
+ if is_required:
1088
+ field_definitions[field_name] = (nested_model, Field())
1089
+ else:
1090
+ field_definitions[field_name] = (nested_model, Field(default=None))
1091
+ else:
1092
+ if is_required:
1093
+ field_definitions[field_name] = (field_type, Field())
1094
+ else:
1095
+ field_definitions[field_name] = (field_type, Field(default=None))
1096
+
1097
+ # Create and return the Pydantic model
1098
+ return create_model(model_name, **field_definitions)
1099
+
1100
+
881
1101
  def to_tool_secret_requirements(
882
1102
  secrets_requirement: list[str],
883
1103
  ) -> list[ToolSecretRequirement]:
arcade_core/executor.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import traceback
3
- from typing import Any, Callable
3
+ from collections.abc import Callable
4
+ from typing import Any
4
5
 
5
6
  from pydantic import BaseModel, ValidationError
6
7
 
@@ -12,7 +13,12 @@ from arcade_core.errors import (
12
13
  ToolSerializationError,
13
14
  )
14
15
  from arcade_core.output import output_factory
15
- from arcade_core.schema import ToolCallLog, ToolCallOutput, ToolContext, ToolDefinition
16
+ from arcade_core.schema import (
17
+ ToolCallLog,
18
+ ToolCallOutput,
19
+ ToolContext,
20
+ ToolDefinition,
21
+ )
16
22
 
17
23
 
18
24
  class ToolExecutor:
@@ -34,7 +40,9 @@ class ToolExecutor:
34
40
  if definition.deprecation_message is not None:
35
41
  tool_call_logs.append(
36
42
  ToolCallLog(
37
- message=definition.deprecation_message, level="warning", subtype="deprecation"
43
+ message=definition.deprecation_message,
44
+ level="warning",
45
+ subtype="deprecation",
38
46
  )
39
47
  )
40
48
 
@@ -101,7 +109,8 @@ class ToolExecutor:
101
109
 
102
110
  except ValidationError as e:
103
111
  raise ToolInputError(
104
- message="Error in tool input deserialization", developer_message=str(e)
112
+ message="Error in tool input deserialization",
113
+ developer_message=str(e),
105
114
  ) from e
106
115
 
107
116
  return inputs
arcade_core/output.py CHANGED
@@ -1,5 +1,7 @@
1
1
  from typing import TypeVar
2
2
 
3
+ from pydantic import BaseModel
4
+
3
5
  from arcade_core.schema import ToolCallError, ToolCallLog, ToolCallOutput
4
6
  from arcade_core.utils import coerce_empty_list_to_none
5
7
 
@@ -17,9 +19,29 @@ class ToolOutputFactory:
17
19
  data: T | None = None,
18
20
  logs: list[ToolCallLog] | None = None,
19
21
  ) -> ToolCallOutput:
20
- value = getattr(data, "result", "") if data else ""
22
+ # Extract the result value
23
+ """
24
+ Extracts the result value for the tool output.
25
+
26
+ The executor guarantees that `data` is either a string, a dict, or None.
27
+ """
28
+ value: str | int | float | bool | dict | list[str] | None
29
+ if data is None:
30
+ value = ""
31
+ elif hasattr(data, "result"):
32
+ value = getattr(data, "result", "")
33
+ elif isinstance(data, BaseModel):
34
+ value = data.model_dump()
35
+ elif isinstance(data, (str, int, float, bool, list)):
36
+ value = data
37
+ else:
38
+ raise ValueError(f"Unsupported data output type: {type(data)}")
39
+
21
40
  logs = coerce_empty_list_to_none(logs)
22
- return ToolCallOutput(value=value, logs=logs)
41
+ return ToolCallOutput(
42
+ value=value,
43
+ logs=logs,
44
+ )
23
45
 
24
46
  def fail(
25
47
  self,
@@ -56,6 +78,7 @@ class ToolOutputFactory:
56
78
  can_retry=True,
57
79
  additional_prompt_content=additional_prompt_content,
58
80
  retry_after_ms=retry_after_ms,
81
+ traceback_info=traceback_info,
59
82
  ),
60
83
  logs=coerce_empty_list_to_none(logs),
61
84
  )
arcade_core/parse.py CHANGED
@@ -1,6 +1,5 @@
1
1
  import ast
2
2
  from pathlib import Path
3
- from typing import Optional, Union
4
3
 
5
4
 
6
5
  def load_ast_tree(filepath: str | Path) -> ast.AST:
@@ -16,8 +15,8 @@ def load_ast_tree(filepath: str | Path) -> ast.AST:
16
15
 
17
16
 
18
17
  def get_function_name_if_decorated(
19
- node: Union[ast.FunctionDef, ast.AsyncFunctionDef],
20
- ) -> Optional[str]:
18
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
19
+ ) -> str | None:
21
20
  """
22
21
  Check if a function has a decorator.
23
22
  """
arcade_core/schema.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import os
2
2
  from dataclasses import dataclass
3
3
  from enum import Enum
4
- from typing import Any, Literal, Optional, Union
4
+ from typing import Any, Literal
5
5
 
6
6
  from pydantic import BaseModel, Field
7
7
 
@@ -15,12 +15,21 @@ class ValueSchema(BaseModel):
15
15
  val_type: Literal["string", "integer", "number", "boolean", "json", "array"]
16
16
  """The type of the value."""
17
17
 
18
- inner_val_type: Optional[Literal["string", "integer", "number", "boolean", "json"]] = None
18
+ inner_val_type: Literal["string", "integer", "number", "boolean", "json"] | None = None
19
19
  """The type of the inner value, if the value is a list."""
20
20
 
21
- enum: Optional[list[str]] = None
21
+ enum: list[str] | None = None
22
22
  """The list of possible values for the value, if it is a closed list."""
23
23
 
24
+ properties: dict[str, "ValueSchema"] | None = None
25
+ """For object types (json), the schema of nested properties."""
26
+
27
+ inner_properties: dict[str, "ValueSchema"] | None = None
28
+ """For array types with json items, the schema of properties for each array item."""
29
+
30
+ description: str | None = None
31
+ """Optional description of the value."""
32
+
24
33
 
25
34
  class InputParameter(BaseModel):
26
35
  """A parameter that can be passed to a tool."""
@@ -30,8 +39,9 @@ class InputParameter(BaseModel):
30
39
  ...,
31
40
  description="Whether this parameter is required (true) or optional (false).",
32
41
  )
33
- description: Optional[str] = Field(
34
- None, description="A descriptive, human-readable explanation of the parameter."
42
+ description: str | None = Field(
43
+ None,
44
+ description="A descriptive, human-readable explanation of the parameter.",
35
45
  )
36
46
  value_schema: ValueSchema = Field(
37
47
  ...,
@@ -59,14 +69,14 @@ class ToolInput(BaseModel):
59
69
  class ToolOutput(BaseModel):
60
70
  """The output of a tool."""
61
71
 
62
- description: Optional[str] = Field(
72
+ description: str | None = Field(
63
73
  None, description="A descriptive, human-readable explanation of the output."
64
74
  )
65
75
  available_modes: list[str] = Field(
66
76
  default_factory=lambda: ["value", "error", "null"],
67
77
  description="The available modes for the output.",
68
78
  )
69
- value_schema: Optional[ValueSchema] = Field(
79
+ value_schema: ValueSchema | None = Field(
70
80
  None, description="The schema of the value of the output."
71
81
  )
72
82
 
@@ -74,7 +84,7 @@ class ToolOutput(BaseModel):
74
84
  class OAuth2Requirement(BaseModel):
75
85
  """Indicates that the tool requires OAuth 2.0 authorization."""
76
86
 
77
- scopes: Optional[list[str]] = None
87
+ scopes: list[str] | None = None
78
88
  """The scope(s) needed for the authorized action."""
79
89
 
80
90
 
@@ -90,16 +100,16 @@ class ToolAuthRequirement(BaseModel):
90
100
  #
91
101
  # The Arcade SDK translates these into the appropriate provider ID (Google) and type (OAuth2).
92
102
  # The only time the developer will set these is if they are using a custom auth provider.
93
- provider_id: Optional[str] = None
103
+ provider_id: str | None = None
94
104
  """The provider ID configured in Arcade that acts as an alias to well-known configuration."""
95
105
 
96
106
  provider_type: str
97
107
  """The type of the authorization provider."""
98
108
 
99
- id: Optional[str] = None
109
+ id: str | None = None
100
110
  """A provider's unique identifier, allowing the tool to specify a specific authorization provider. Recommended for private tools only."""
101
111
 
102
- oauth2: Optional[OAuth2Requirement] = None
112
+ oauth2: OAuth2Requirement | None = None
103
113
  """The OAuth 2.0 requirement, if any."""
104
114
 
105
115
 
@@ -133,13 +143,13 @@ class ToolMetadataRequirement(BaseModel):
133
143
  class ToolRequirements(BaseModel):
134
144
  """The requirements for a tool to run."""
135
145
 
136
- authorization: Union[ToolAuthRequirement, None] = None
146
+ authorization: ToolAuthRequirement | None = None
137
147
  """The authorization requirements for the tool, if any."""
138
148
 
139
- secrets: Union[list[ToolSecretRequirement], None] = None
149
+ secrets: list[ToolSecretRequirement] | None = None
140
150
  """The secret requirements for the tool, if any."""
141
151
 
142
- metadata: Union[list[ToolMetadataRequirement], None] = None
152
+ metadata: list[ToolMetadataRequirement] | None = None
143
153
  """The metadata requirements for the tool, if any."""
144
154
 
145
155
 
@@ -149,10 +159,10 @@ class ToolkitDefinition(BaseModel):
149
159
  name: str
150
160
  """The name of the toolkit."""
151
161
 
152
- description: Optional[str] = None
162
+ description: str | None = None
153
163
  """The description of the toolkit."""
154
164
 
155
- version: Optional[str] = None
165
+ version: str | None = None
156
166
  """The version identifier of the toolkit."""
157
167
 
158
168
 
@@ -166,7 +176,7 @@ class FullyQualifiedName:
166
176
  toolkit_name: str
167
177
  """The name of the toolkit containing the tool."""
168
178
 
169
- toolkit_version: Optional[str] = None
179
+ toolkit_version: str | None = None
170
180
  """The version of the toolkit containing the tool."""
171
181
 
172
182
  def __str__(self) -> str:
@@ -225,7 +235,7 @@ class ToolDefinition(BaseModel):
225
235
  requirements: ToolRequirements
226
236
  """The requirements (e.g. authorization) for the tool to run."""
227
237
 
228
- deprecation_message: Optional[str] = None
238
+ deprecation_message: str | None = None
229
239
  """The message to display when the tool is deprecated."""
230
240
 
231
241
  def get_fully_qualified_name(self) -> FullyQualifiedName:
@@ -241,7 +251,7 @@ class ToolReference(BaseModel):
241
251
  toolkit: str
242
252
  """The name of the toolkit containing the tool."""
243
253
 
244
- version: Optional[str] = None
254
+ version: str | None = None
245
255
  """The version of the toolkit containing the tool."""
246
256
 
247
257
  def get_fully_qualified_name(self) -> FullyQualifiedName:
@@ -313,7 +323,10 @@ class ToolContext(BaseModel):
313
323
  return self._get_item(key, self.metadata, "metadata")
314
324
 
315
325
  def _get_item(
316
- self, key: str, items: list[ToolMetadataItem] | list[ToolSecretItem] | None, item_name: str
326
+ self,
327
+ key: str,
328
+ items: list[ToolMetadataItem] | list[ToolSecretItem] | None,
329
+ item_name: str,
317
330
  ) -> str:
318
331
  if not key or not key.strip():
319
332
  raise ValueError(
@@ -368,7 +381,7 @@ class ToolCallLog(BaseModel):
368
381
  ]
369
382
  """The level of severity for the log."""
370
383
 
371
- subtype: Optional[Literal["deprecation"]] = None
384
+ subtype: Literal["deprecation"] | None = None
372
385
  """Optional field for further categorization of the log."""
373
386
 
374
387
 
@@ -405,7 +418,7 @@ class ToolCallRequiresAuthorization(BaseModel):
405
418
  class ToolCallOutput(BaseModel):
406
419
  """The output of a tool invocation."""
407
420
 
408
- value: Union[str, int, float, bool, dict, list[str]] | None = None
421
+ value: str | int | float | bool | dict | list[str] | None = None
409
422
  """The value returned by the tool."""
410
423
  logs: list[ToolCallLog] | None = None
411
424
  """The logs that occurred during the tool invocation."""
arcade_core/toolkit.py CHANGED
@@ -4,7 +4,7 @@ import logging
4
4
  import os
5
5
  import types
6
6
  from collections import defaultdict
7
- from pathlib import Path
7
+ from pathlib import Path, PurePosixPath, PureWindowsPath
8
8
 
9
9
  from pydantic import BaseModel, ConfigDict, field_validator
10
10
 
@@ -87,14 +87,6 @@ class Toolkit(BaseModel):
87
87
  except (ImportError, AttributeError) as e:
88
88
  raise ToolkitLoadError(f"Failed to locate package directory for '{package}'.") from e
89
89
 
90
- # Get all python files in the package directory
91
- try:
92
- modules = [f for f in package_dir.glob("**/*.py") if f.is_file()]
93
- except OSError as e:
94
- raise ToolkitLoadError(
95
- f"Failed to locate Python files in package directory for '{package}'."
96
- ) from e
97
-
98
90
  toolkit = cls(
99
91
  name=name,
100
92
  package_name=package_name,
@@ -105,14 +97,7 @@ class Toolkit(BaseModel):
105
97
  repository=repo,
106
98
  )
107
99
 
108
- for module_path in modules:
109
- relative_path = module_path.relative_to(package_dir)
110
- import_path = ".".join(relative_path.with_suffix("").parts)
111
- import_path = f"{package_name}.{import_path}"
112
- toolkit.tools[import_path] = get_tools_from_file(str(module_path))
113
-
114
- if not toolkit.tools:
115
- raise ToolkitLoadError(f"No tools found in package {package}")
100
+ toolkit.tools = cls.tools_from_directory(package_dir, package_name)
116
101
 
117
102
  return toolkit
118
103
 
@@ -229,6 +214,61 @@ class Toolkit(BaseModel):
229
214
 
230
215
  return all_toolkits
231
216
 
217
+ @classmethod
218
+ def tools_from_directory(cls, package_dir: Path, package_name: str) -> dict[str, list[str]]:
219
+ """
220
+ Load a Toolkit from a directory.
221
+ """
222
+ # Get all python files in the package directory
223
+ try:
224
+ modules = [f for f in package_dir.glob("**/*.py") if f.is_file() and Validate.path(f)]
225
+ except OSError as e:
226
+ raise ToolkitLoadError(
227
+ f"Failed to locate Python files in package directory for '{package_name}'."
228
+ ) from e
229
+
230
+ tools: dict[str, list[str]] = {}
231
+
232
+ for module_path in modules:
233
+ relative_path = module_path.relative_to(package_dir)
234
+ cls.validate_file(module_path)
235
+ import_path = ".".join(relative_path.with_suffix("").parts)
236
+ import_path = f"{package_name}.{import_path}"
237
+ tools[import_path] = get_tools_from_file(str(module_path))
238
+
239
+ if not tools:
240
+ raise ToolkitLoadError(f"No tools found in package {package_name}")
241
+
242
+ return tools
243
+
244
+ @classmethod
245
+ def validate_file(cls, file_path: str | Path) -> None:
246
+ """
247
+ Validate that the Python code in the given file is syntactically correct.
248
+
249
+ Args:
250
+ file_path: Path to the Python file to validate
251
+ """
252
+ # Convert string path to Path object if needed
253
+ path = Path(file_path) if isinstance(file_path, str) else file_path
254
+
255
+ # Check if file exists
256
+ if not path.exists():
257
+ raise ValueError(f"❌ File not found: {path}")
258
+
259
+ # Check if it's a Python file
260
+ if not path.suffix == ".py":
261
+ raise ValueError(f"❌ Not a Python file: {path}")
262
+
263
+ try:
264
+ # Try to compile the code to check for syntax errors
265
+ with open(path, encoding="utf-8") as f:
266
+ source = f.read()
267
+
268
+ compile(source, str(path), "exec")
269
+ except Exception as e:
270
+ raise SyntaxError(f"{path}: {e}")
271
+
232
272
 
233
273
  def get_package_directory(package_name: str) -> str:
234
274
  """
@@ -247,3 +287,32 @@ def get_package_directory(package_name: str) -> str:
247
287
  return spec.submodule_search_locations[0]
248
288
  else:
249
289
  raise ImportError(f"Package {package_name} does not have a file path associated with it")
290
+
291
+
292
+ class Validate:
293
+ warn = True
294
+
295
+ @classmethod
296
+ def path(cls, path: str | Path) -> bool:
297
+ """
298
+ Validate if a path is valid to be served or deployed.
299
+ """
300
+ # Check both POSIX and Windows interpretations
301
+ posix_path = PurePosixPath(path)
302
+ windows_path = PureWindowsPath(path)
303
+
304
+ # Get all possible parts from both interpretations
305
+ all_parts = set(posix_path.parts) | set(windows_path.parts)
306
+
307
+ for part in all_parts:
308
+ if (part == "venv" or part.startswith(".")) and cls.warn:
309
+ print(
310
+ f"⚠️ Your package may contain a venv directory or hidden files. We suggest moving these out of the toolkit directory to avoid deployment issues: {path}"
311
+ )
312
+ cls.warn = False
313
+ if part in {"dist", "build", "__pycache__", "coverage.xml"}:
314
+ return False
315
+ if part.endswith(".lock"):
316
+ return False
317
+
318
+ return True
arcade_core/utils.py CHANGED
@@ -3,9 +3,9 @@ from __future__ import annotations
3
3
  import ast
4
4
  import inspect
5
5
  import re
6
- from collections.abc import Iterable
6
+ from collections.abc import Callable, Iterable
7
7
  from types import UnionType
8
- from typing import Any, Callable, Literal, TypeVar, Union, get_args, get_origin
8
+ from typing import Any, Literal, TypeVar, Union, get_args, get_origin
9
9
 
10
10
  T = TypeVar("T")
11
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arcade-core
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: Arcade Core - Core library for Arcade platform
5
5
  Author-email: Arcade <dev@arcade.dev>
6
6
  License: MIT
@@ -0,0 +1,19 @@
1
+ arcade_core/__init__.py,sha256=1heu3AROAjpistehPzY2H-2nkj_IjQEh-vVlVOCRF1E,88
2
+ arcade_core/annotations.py,sha256=Nst6aejLWXlpTu7GwzWETu1gQCG1XVAUR_qcFbNvyRc,198
3
+ arcade_core/auth.py,sha256=-x7NipXciB6ASMjH_iTCdOl86s-R0JzQKeWkhDg1aDI,5613
4
+ arcade_core/catalog.py,sha256=WVSTuJN2euY7PR6FBFADTR79teeetBJPFG06O-ohYjs,39492
5
+ arcade_core/config.py,sha256=e98XQAkYySGW9T_yrJg54BB8Wuq06GPVHp7xqe2d1vU,572
6
+ arcade_core/config_model.py,sha256=GYO37yKi7ih6EYKPpX1Kl-K1XwM2JyEJguyaJ7j9TY8,4260
7
+ arcade_core/errors.py,sha256=h4H1ck4TP-CDKPuRA5EVtRnplWwk9ofwFWE5h4AuMyg,2043
8
+ arcade_core/executor.py,sha256=87fge4Mvvggn-ibVdxsin3IlPv5oRaZFdP70KYCIE7I,4550
9
+ arcade_core/output.py,sha256=T5CP2wDdySgzaKspHRNA1g94nB6V6mbama0GmI9gUTI,2560
10
+ arcade_core/parse.py,sha256=gIo9w0iaKU_FpR9pFbUOxRfCK-cizg9SacoQC_jmgCc,1855
11
+ arcade_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ arcade_core/schema.py,sha256=ldgw2GYIhdVMM_fbrvbtAWQYbtr23g3z71F0kt5SnH0,14666
13
+ arcade_core/telemetry.py,sha256=qDv8T-wO8nFi0Qh93WKaPH1b6asfoJoyyfA7ZOxPnbA,5566
14
+ arcade_core/toolkit.py,sha256=O-e8Pq6AKk78d78c16TPhEjufNuCjM_AWfLknpQvmy0,11108
15
+ arcade_core/utils.py,sha256=anzEqj7Q_3dko-oY8U2QcH62rmQE7Y4fQBm0WjwLlIE,2980
16
+ arcade_core/version.py,sha256=CpXi3jGlx23RvRyU7iytOMZrnspdWw4yofS8lpP1AJU,18
17
+ arcade_core-2.2.2.dist-info/METADATA,sha256=RnM4R8_HgyjnYvmXJVJVt5xKSIH8cHAtYcgvrA5_9mE,2557
18
+ arcade_core-2.2.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
+ arcade_core-2.2.2.dist-info/RECORD,,
@@ -1,19 +0,0 @@
1
- arcade_core/__init__.py,sha256=1heu3AROAjpistehPzY2H-2nkj_IjQEh-vVlVOCRF1E,88
2
- arcade_core/annotations.py,sha256=Nst6aejLWXlpTu7GwzWETu1gQCG1XVAUR_qcFbNvyRc,198
3
- arcade_core/auth.py,sha256=-x7NipXciB6ASMjH_iTCdOl86s-R0JzQKeWkhDg1aDI,5613
4
- arcade_core/catalog.py,sha256=MH-o-NMvUoK_hg8Ibr8dKoF4Yu0z53D5pfUR--BhPCA,31432
5
- arcade_core/config.py,sha256=e98XQAkYySGW9T_yrJg54BB8Wuq06GPVHp7xqe2d1vU,572
6
- arcade_core/config_model.py,sha256=GYO37yKi7ih6EYKPpX1Kl-K1XwM2JyEJguyaJ7j9TY8,4260
7
- arcade_core/errors.py,sha256=h4H1ck4TP-CDKPuRA5EVtRnplWwk9ofwFWE5h4AuMyg,2043
8
- arcade_core/executor.py,sha256=tEDAM-4d-LMZJc0xAemjoL73kKCyLxTa79NK6vjWqmw,4444
9
- arcade_core/output.py,sha256=0v0Y47NVQ40-vl9a47XZT7T-cZqcrVZrvO3xYJlcZPs,1852
10
- arcade_core/parse.py,sha256=SURNI-B9xHCIprxTRTAR0AMT9hIJpQqHjOmrENzFBVI,1899
11
- arcade_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- arcade_core/schema.py,sha256=DgVqrRDZMhndhXcb-CvLEnAtVWkseisvtAwcei5Qgmc,14358
13
- arcade_core/telemetry.py,sha256=qDv8T-wO8nFi0Qh93WKaPH1b6asfoJoyyfA7ZOxPnbA,5566
14
- arcade_core/toolkit.py,sha256=AdDaUpNvKst1IBnaLegfQpRg9dHxmK-9cOSQqzBpS4I,8763
15
- arcade_core/utils.py,sha256=Gg4na-85pY21e5Ab-yxoRlzTQu3FhlP5xQ9G1BhfrI8,2980
16
- arcade_core/version.py,sha256=CpXi3jGlx23RvRyU7iytOMZrnspdWw4yofS8lpP1AJU,18
17
- arcade_core-2.2.0.dist-info/METADATA,sha256=qAlQB4EcdrnebvNw6Ibifth-aREBFmcLzjgF6tTuvNg,2557
18
- arcade_core-2.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
- arcade_core-2.2.0.dist-info/RECORD,,