swiftpy-framework 0.1.0__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 (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,489 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import enum
5
+ import functools
6
+ import inspect
7
+ import math
8
+ import re
9
+ from abc import ABC, abstractmethod
10
+ from collections.abc import Callable, Mapping, Sequence
11
+ from types import UnionType
12
+ from typing import Any, Literal, Union, get_args, get_origin, get_type_hints, overload
13
+
14
+ _TOOL_NAME_PATTERN = re.compile(r"\A[a-zA-Z0-9_-]+\Z")
15
+
16
+
17
+ class BaseTool(ABC):
18
+ """
19
+ Abstract contract for executable tools in SwiftPY AI agents and tool loops.
20
+ """
21
+
22
+ @property
23
+ @abstractmethod
24
+ def name(self) -> str:
25
+ """The unique name of the tool, matching ^[a-zA-Z0-9_-]+$."""
26
+
27
+ @property
28
+ @abstractmethod
29
+ def description(self) -> str:
30
+ """A detailed description of what the tool does and when the LLM should invoke it."""
31
+
32
+ @property
33
+ @abstractmethod
34
+ def parameters(self) -> dict[str, Any]:
35
+ """JSON Schema dictionary describing the parameters required by this tool."""
36
+
37
+ @abstractmethod
38
+ async def handle(self, *args: Any, **kwargs: Any) -> Any:
39
+ """
40
+ Execute the tool asynchronously with arguments provided by the model.
41
+ """
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ """
45
+ Format the tool schema as an OpenAI-compatible function definition.
46
+ """
47
+ return {
48
+ "type": "function",
49
+ "function": {
50
+ "name": self.name,
51
+ "description": self.description,
52
+ "parameters": self.parameters,
53
+ },
54
+ }
55
+
56
+ def to_anthropic_dict(self) -> dict[str, Any]:
57
+ """
58
+ Format the tool schema as an Anthropic-compatible tool definition.
59
+ """
60
+ return {
61
+ "name": self.name,
62
+ "description": self.description,
63
+ "input_schema": self.parameters,
64
+ }
65
+
66
+
67
+ def _infer_param_schema(ann: Any) -> dict[str, Any]:
68
+ """Map a Python type annotation to a detailed JSON Schema dictionary."""
69
+ if ann is inspect.Parameter.empty or ann is Any:
70
+ return {"type": "string"}
71
+
72
+ origin = get_origin(ann)
73
+ args = get_args(ann)
74
+
75
+ # Literal[...]
76
+ if origin is Literal:
77
+ values = list(args)
78
+ val_type = "string"
79
+ if values and all(isinstance(v, bool) for v in values):
80
+ val_type = "boolean"
81
+ elif values and all(isinstance(v, int) and not isinstance(v, bool) for v in values):
82
+ val_type = "integer"
83
+ elif values and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in values):
84
+ val_type = "number"
85
+ return {"type": val_type, "enum": values}
86
+
87
+ # Enum subclasses
88
+ if isinstance(ann, type) and issubclass(ann, enum.Enum):
89
+ enum_values = [e.value for e in ann]
90
+ if issubclass(ann, enum.IntEnum) or (
91
+ enum_values and all(isinstance(v, int) and not isinstance(v, bool) for v in enum_values)
92
+ ):
93
+ val_type = "integer"
94
+ elif enum_values and all(isinstance(v, bool) for v in enum_values):
95
+ val_type = "boolean"
96
+ elif enum_values and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in enum_values):
97
+ val_type = "number"
98
+ else:
99
+ val_type = "string"
100
+ return {"type": val_type, "enum": enum_values}
101
+
102
+ # Union & Optional (e.g. str | None, Optional[int], Union[int, str])
103
+ if origin in (Union, UnionType):
104
+ non_none_args = [a for a in args if a is not type(None)]
105
+ is_nullable = len(non_none_args) < len(args)
106
+ if len(non_none_args) == 1:
107
+ schema = _infer_param_schema(non_none_args[0])
108
+ if is_nullable:
109
+ schema["nullable"] = True
110
+ return schema
111
+ schemas = [_infer_param_schema(a) for a in non_none_args]
112
+ return {"anyOf": schemas, "nullable": is_nullable} if is_nullable else {"anyOf": schemas}
113
+
114
+ # Arrays: list, tuple, set, Sequence
115
+ if ann is list or origin in (list, set, tuple, Sequence):
116
+ item_schema = _infer_param_schema(args[0]) if args else {"type": "string"}
117
+ return {"type": "array", "items": item_schema}
118
+
119
+ # Objects: dict, Mapping
120
+ if ann is dict or origin in (dict, Mapping):
121
+ return {"type": "object"}
122
+
123
+ # Primitives: Note that bool is a subclass of int in Python, so bool must be checked first!
124
+ if ann is bool:
125
+ return {"type": "boolean"}
126
+ if ann is int:
127
+ return {"type": "integer"}
128
+ if ann is float:
129
+ return {"type": "number"}
130
+ if ann is str:
131
+ return {"type": "string"}
132
+
133
+ return {"type": "string"}
134
+
135
+
136
+ def _extract_enum_type(ann: Any) -> type[enum.Enum] | None:
137
+ """Extract Enum subclass from direct annotations or Optional[Enum] unions."""
138
+ if isinstance(ann, type) and issubclass(ann, enum.Enum):
139
+ return ann
140
+ origin = get_origin(ann)
141
+ if origin in (Union, UnionType):
142
+ non_none = [a for a in get_args(ann) if a is not type(None)]
143
+ if len(non_none) == 1 and isinstance(non_none[0], type) and issubclass(non_none[0], enum.Enum):
144
+ return non_none[0]
145
+ return None
146
+
147
+
148
+ def _unwrap_partial_and_callable(fn: Callable[..., Any]) -> tuple[Callable[..., Any], Callable[..., Any]]:
149
+ """Return (underlying_target, hint_and_doc_target) handling partials and callable instances."""
150
+ base: Any = fn
151
+ while isinstance(base, functools.partial):
152
+ base = base.func
153
+ hint_target = base
154
+ if (
155
+ not inspect.isfunction(base)
156
+ and not inspect.ismethod(base)
157
+ and not inspect.isbuiltin(base)
158
+ and not isinstance(base, type)
159
+ and callable(base)
160
+ ):
161
+ hint_target = base.__call__
162
+ return base, hint_target
163
+
164
+
165
+ def is_async_callable(fn: Callable[..., Any]) -> bool:
166
+ """Return True if a function, partial, or callable object defines an async entrypoint."""
167
+ current: Any = fn
168
+ while isinstance(current, functools.partial):
169
+ current = current.func
170
+ if inspect.iscoroutinefunction(current):
171
+ return True
172
+ if callable(current) and not inspect.isfunction(current) and not inspect.ismethod(current):
173
+ return inspect.iscoroutinefunction(type(current).__call__)
174
+ return False
175
+
176
+
177
+ def _validate_arg_type(
178
+ param_name: str,
179
+ value: Any,
180
+ ann: Any,
181
+ schema: Mapping[str, Any],
182
+ ) -> None:
183
+ """Validate a runtime parameter value recursively against declared type annotation and JSON schema."""
184
+ origin = get_origin(ann)
185
+ args = get_args(ann)
186
+ schema_type = schema.get("type")
187
+ is_nullable = (
188
+ schema.get("nullable", False)
189
+ or schema_type == "null"
190
+ or (isinstance(schema_type, (list, tuple)) and "null" in schema_type)
191
+ or (origin in (Union, UnionType) and type(None) in args)
192
+ )
193
+
194
+ if value is None:
195
+ if is_nullable:
196
+ return
197
+ raise TypeError(f"Parameter '{param_name}' cannot be None.")
198
+
199
+ # Check enum values (guarding bool == int equivalence in Python)
200
+ if "enum" in schema:
201
+ enum_vals = schema["enum"]
202
+ raw_val = value.value if isinstance(value, enum.Enum) else value
203
+ if not any(v == raw_val and isinstance(raw_val, bool) is isinstance(v, bool) for v in enum_vals):
204
+ raise ValueError(
205
+ f"Parameter '{param_name}' must be one of {enum_vals}, got {value!r}"
206
+ )
207
+ return
208
+
209
+ # Check union types
210
+ union_schemas = schema.get("anyOf") or schema.get("oneOf")
211
+ if isinstance(union_schemas, Sequence):
212
+ matched = False
213
+ for s in union_schemas:
214
+ if isinstance(s, Mapping):
215
+ try:
216
+ _validate_arg_type(param_name, value, Any, s)
217
+ matched = True
218
+ break
219
+ except (TypeError, ValueError):
220
+ continue
221
+ if not matched:
222
+ raise TypeError(f"Parameter '{param_name}' does not match any allowed Union type.")
223
+ return
224
+
225
+ expected_type = schema.get("type")
226
+ if expected_type == "boolean":
227
+ if not isinstance(value, bool):
228
+ raise TypeError(f"Parameter '{param_name}' must be a boolean, got {type(value).__name__}")
229
+ elif expected_type == "integer":
230
+ if isinstance(value, bool) or not isinstance(value, int):
231
+ raise TypeError(f"Parameter '{param_name}' must be an integer, got {type(value).__name__}")
232
+ elif expected_type == "number":
233
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
234
+ raise TypeError(f"Parameter '{param_name}' must be a number, got {type(value).__name__}")
235
+ if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
236
+ raise ValueError(f"Parameter '{param_name}' must be a finite number, got {value!r}")
237
+ elif expected_type == "string":
238
+ if not isinstance(value, str):
239
+ raise TypeError(f"Parameter '{param_name}' must be a string, got {type(value).__name__}")
240
+ elif expected_type == "array":
241
+ if not isinstance(value, (list, tuple, set)):
242
+ raise TypeError(f"Parameter '{param_name}' must be an array, got {type(value).__name__}")
243
+ item_schema = schema.get("items")
244
+ if isinstance(item_schema, Mapping):
245
+ for idx, elem in enumerate(value):
246
+ _validate_arg_type(f"{param_name}[{idx}]", elem, Any, item_schema)
247
+ elif expected_type == "object":
248
+ if not isinstance(value, Mapping):
249
+ raise TypeError(f"Parameter '{param_name}' must be an object/dict, got {type(value).__name__}")
250
+ validate_tool_arguments(schema, value, param_name)
251
+
252
+
253
+ def validate_tool_arguments(
254
+ schema: Mapping[str, Any],
255
+ arguments: Mapping[str, Any],
256
+ tool_name: str = "",
257
+ ) -> None:
258
+ """Validate a tool argument mapping against a JSON Schema definition."""
259
+ if not isinstance(arguments, Mapping):
260
+ label = f"tool '{tool_name}'" if tool_name else "tool"
261
+ raise TypeError(f"Expected object/dict arguments for {label}, got {type(arguments).__name__}")
262
+
263
+ properties = schema.get("properties")
264
+ prop_map: Mapping[str, Any] = properties if isinstance(properties, Mapping) else {}
265
+ required = schema.get("required")
266
+ if isinstance(required, Sequence) and not isinstance(required, (str, bytes)):
267
+ for req in required:
268
+ if isinstance(req, str) and req not in arguments:
269
+ label = f" for '{tool_name}'" if tool_name else ""
270
+ raise TypeError(f"Missing required parameter '{req}'{label}.")
271
+
272
+ if schema.get("additionalProperties") is False:
273
+ allowed_keys = set(prop_map.keys())
274
+ for key in arguments:
275
+ if key not in allowed_keys:
276
+ raise TypeError(
277
+ f"Unexpected parameter '{key}'. Allowed parameters: {sorted(allowed_keys)}"
278
+ )
279
+
280
+ for key, val in arguments.items():
281
+ if key in prop_map and isinstance(prop_map[key], Mapping):
282
+ _validate_arg_type(str(key), val, Any, prop_map[key])
283
+
284
+
285
+ class FunctionTool(BaseTool):
286
+ """
287
+ Adapter converting a standard Python callable into a BaseTool instance
288
+ with parameter schema inference, input validation, and asynchronous execution.
289
+ """
290
+
291
+ def __init__(
292
+ self,
293
+ fn: Callable[..., Any] | None = None,
294
+ name: str | None = None,
295
+ description: str | None = None,
296
+ parameters: dict[str, Any] | None = None,
297
+ *,
298
+ func: Callable[..., Any] | None = None,
299
+ ) -> None:
300
+ target_fn = fn or func
301
+ if target_fn is None:
302
+ raise ValueError("FunctionTool requires a callable function.")
303
+ self._fn = target_fn
304
+ underlying, hint_target = _unwrap_partial_and_callable(target_fn)
305
+
306
+ raw_name = str(
307
+ name
308
+ or getattr(target_fn, "__name__", None)
309
+ or getattr(underlying, "__name__", None)
310
+ or type(underlying).__name__
311
+ or "tool"
312
+ )
313
+ if not _TOOL_NAME_PATTERN.fullmatch(raw_name):
314
+ raise ValueError(f"Invalid tool name '{raw_name}'. Must match ^[a-zA-Z0-9_-]+$")
315
+
316
+ self._name: str = raw_name
317
+ doc = inspect.getdoc(target_fn) or inspect.getdoc(underlying) or f"Execute {self._name}"
318
+ self._description = description or doc.strip()
319
+ self._sig = inspect.signature(target_fn)
320
+ self._hints = (
321
+ get_type_hints(hint_target)
322
+ if hasattr(hint_target, "__annotations__")
323
+ else {}
324
+ )
325
+
326
+ if parameters is not None:
327
+ self._parameters = parameters
328
+ else:
329
+ self._parameters = self._infer_parameters(target_fn, self._sig, self._hints)
330
+
331
+ @property
332
+ def name(self) -> str:
333
+ return self._name
334
+
335
+ @property
336
+ def description(self) -> str:
337
+ return self._description
338
+
339
+ @property
340
+ def parameters(self) -> dict[str, Any]:
341
+ return self._parameters
342
+
343
+ async def handle(self, *args: Any, **kwargs: Any) -> Any:
344
+ bound_kwargs = kwargs.copy()
345
+ params_list = list(self._sig.parameters.values())
346
+ positional_params = [
347
+ p
348
+ for p in params_list
349
+ if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
350
+ ]
351
+
352
+ # 1. Check for missing required parameters (accounting for supplied positional args)
353
+ supplied_pos_names = {p.name for p in positional_params[: len(args)]}
354
+ defaulted_names = {
355
+ p.name for p in params_list if p.default is not inspect.Parameter.empty
356
+ }
357
+ for req in self._parameters.get("required", []):
358
+ if (
359
+ req not in bound_kwargs
360
+ and req not in supplied_pos_names
361
+ and req not in defaulted_names
362
+ ):
363
+ raise TypeError(f"Missing required parameter '{req}' for tool '{self.name}'.")
364
+
365
+ # 2. Check for unexpected parameters if callable does not accept **kwargs
366
+ has_var_kw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params_list)
367
+ if not has_var_kw:
368
+ allowed = set(self._sig.parameters.keys())
369
+ for k in bound_kwargs:
370
+ if k not in allowed:
371
+ raise TypeError(
372
+ f"Unexpected parameter '{k}' for tool '{self.name}'. Allowed parameters: {sorted(allowed)}"
373
+ )
374
+
375
+ # 3. Validate parameter types & coerce Enum arguments
376
+ for k, v in list(bound_kwargs.items()):
377
+ param = self._sig.parameters.get(k)
378
+ if param is not None:
379
+ ann = self._hints.get(k, param.annotation)
380
+ schema = self._parameters.get("properties", {}).get(k, {})
381
+ _validate_arg_type(k, v, ann, schema)
382
+ enum_cls = _extract_enum_type(ann)
383
+ if enum_cls is not None and v is not None and not isinstance(v, enum_cls):
384
+ matched = [
385
+ member
386
+ for member in enum_cls
387
+ if member.value == v and isinstance(member.value, bool) is isinstance(v, bool)
388
+ ]
389
+ if matched:
390
+ bound_kwargs[k] = matched[0]
391
+
392
+ # 4. Safely bind arguments, mapping POSITIONAL_ONLY parameters passed via kwargs
393
+ pos_only_params = [
394
+ p for p in params_list if p.kind == inspect.Parameter.POSITIONAL_ONLY
395
+ ]
396
+ bind_pos_args = list(args)
397
+ bind_kwargs = bound_kwargs.copy()
398
+ if len(bind_pos_args) < len(pos_only_params):
399
+ for p in pos_only_params[len(bind_pos_args) :]:
400
+ if p.name in bind_kwargs:
401
+ bind_pos_args.append(bind_kwargs.pop(p.name))
402
+ elif p.default is not inspect.Parameter.empty:
403
+ bind_pos_args.append(p.default)
404
+ else:
405
+ break
406
+
407
+ try:
408
+ bound = self._sig.bind(*bind_pos_args, **bind_kwargs)
409
+ bound.apply_defaults()
410
+ except TypeError as exc:
411
+ raise TypeError(f"Argument binding error for tool '{self.name}': {exc}") from exc
412
+
413
+ # 5. Execute: await async callables directly, offload sync callables to worker thread
414
+ if is_async_callable(self._fn):
415
+ return await self._fn(*bound.args, **bound.kwargs)
416
+ return await asyncio.to_thread(self._fn, *bound.args, **bound.kwargs)
417
+
418
+ @staticmethod
419
+ def _infer_parameters(
420
+ fn: Callable[..., Any],
421
+ sig: inspect.Signature,
422
+ hints: dict[str, Any],
423
+ ) -> dict[str, Any]:
424
+ """Infer JSON schema parameters from signature and type hints."""
425
+ properties: dict[str, Any] = {}
426
+ required: list[str] = []
427
+
428
+ for param_name, param in sig.parameters.items():
429
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
430
+ continue
431
+
432
+ ann = hints.get(param_name, param.annotation)
433
+ schema = _infer_param_schema(ann)
434
+ schema["description"] = f"Parameter '{param_name}'"
435
+ properties[param_name] = schema
436
+
437
+ # A parameter is required if it has no default and is not explicitly optional/nullable
438
+ if param.default is inspect.Parameter.empty and not schema.get("nullable", False):
439
+ required.append(param_name)
440
+
441
+ return {
442
+ "type": "object",
443
+ "properties": properties,
444
+ "required": required,
445
+ }
446
+
447
+
448
+ @overload
449
+ def tool(
450
+ fn: Callable[..., Any],
451
+ *,
452
+ name: str | None = None,
453
+ description: str | None = None,
454
+ parameters: dict[str, Any] | None = None,
455
+ ) -> FunctionTool: ...
456
+
457
+
458
+ @overload
459
+ def tool(
460
+ fn: None = None,
461
+ *,
462
+ name: str | None = None,
463
+ description: str | None = None,
464
+ parameters: dict[str, Any] | None = None,
465
+ ) -> Callable[[Callable[..., Any]], FunctionTool]: ...
466
+
467
+
468
+ def tool(
469
+ fn: Callable[..., Any] | None = None,
470
+ *,
471
+ name: str | None = None,
472
+ description: str | None = None,
473
+ parameters: dict[str, Any] | None = None,
474
+ ) -> FunctionTool | Callable[[Callable[..., Any]], FunctionTool]:
475
+ """
476
+ Decorator converting a Python function into a BaseTool instance.
477
+ """
478
+ def decorator(target: Callable[..., Any]) -> FunctionTool:
479
+ return FunctionTool(
480
+ target,
481
+ name=name,
482
+ description=description,
483
+ parameters=parameters,
484
+ )
485
+
486
+ if fn is not None:
487
+ return decorator(fn)
488
+ return decorator
489
+ return decorator