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,384 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import asyncio
5
+ import datetime
6
+ import math
7
+ import operator
8
+ import zoneinfo
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ from swiftpy.ai.exceptions import AIConfigurationError
13
+ from swiftpy.ai.tools.base_tool import BaseTool, is_async_callable
14
+
15
+ # ==============================================================================
16
+ # 1. Calculator Tool (AST-Safe Evaluation, Resource Bounds, Zero eval)
17
+ # ==============================================================================
18
+
19
+ _SAFE_OPERATORS: dict[type[ast.AST], Callable[..., Any]] = {
20
+ ast.Add: operator.add,
21
+ ast.Sub: operator.sub,
22
+ ast.Mult: operator.mul,
23
+ ast.Div: operator.truediv,
24
+ ast.FloorDiv: operator.floordiv,
25
+ ast.Mod: operator.mod,
26
+ ast.Pow: operator.pow,
27
+ ast.USub: operator.neg,
28
+ ast.UAdd: operator.pos,
29
+ }
30
+
31
+
32
+ def _safe_sqrt(x: float) -> float:
33
+ if x < 0:
34
+ raise ValueError("Math domain error: square root of negative number is undefined in real numbers")
35
+ return math.sqrt(x)
36
+
37
+
38
+ _SAFE_FUNCTIONS: dict[str, Callable[..., Any]] = {
39
+ "abs": abs,
40
+ "round": round,
41
+ "sqrt": _safe_sqrt,
42
+ "sin": math.sin,
43
+ "cos": math.cos,
44
+ "tan": math.tan,
45
+ "ceil": math.ceil,
46
+ "floor": math.floor,
47
+ "log": math.log,
48
+ "exp": math.exp,
49
+ }
50
+
51
+
52
+ def _safe_eval_node(node: ast.AST, depth: int = 0, max_depth: int = 20) -> Any:
53
+ """Recursively evaluate an AST expression node using strictly whitelisted safe operations and bounds."""
54
+ if depth > max_depth:
55
+ raise ValueError(f"Expression AST depth exceeds maximum allowed limit ({max_depth})")
56
+
57
+ if isinstance(node, ast.Expression):
58
+ return _safe_eval_node(node.body, depth + 1, max_depth)
59
+
60
+ if isinstance(node, ast.Constant):
61
+ if isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
62
+ if math.isnan(node.value) or math.isinf(node.value):
63
+ raise ValueError("Non-finite numeric constants (NaN or Infinity) are not permitted")
64
+ return node.value
65
+ raise ValueError(f"Unsupported constant type in math expression: {type(node.value).__name__}")
66
+
67
+ if isinstance(node, ast.UnaryOp):
68
+ op = _SAFE_OPERATORS.get(type(node.op))
69
+ if op is None:
70
+ raise ValueError(f"Unsupported unary operator: {type(node.op).__name__}")
71
+ operand = _safe_eval_node(node.operand, depth + 1, max_depth)
72
+ res = op(operand)
73
+ if isinstance(res, complex):
74
+ raise TypeError("Complex numbers are not supported in math expressions")
75
+ if isinstance(res, float) and (math.isnan(res) or math.isinf(res)):
76
+ raise ValueError("Unary operation resulted in non-finite value (NaN or Infinity)")
77
+ return res
78
+
79
+ if isinstance(node, ast.BinOp):
80
+ op = _SAFE_OPERATORS.get(type(node.op))
81
+ if op is None:
82
+ raise ValueError(f"Unsupported binary operator: {type(node.op).__name__}")
83
+ left = _safe_eval_node(node.left, depth + 1, max_depth)
84
+ right = _safe_eval_node(node.right, depth + 1, max_depth)
85
+
86
+ # Guard against exponent explosion / resource exhaustion before evaluating pow()
87
+ if isinstance(node.op, ast.Pow):
88
+ if isinstance(left, bool) or isinstance(right, bool):
89
+ raise TypeError("Boolean operands are not permitted in math expressions")
90
+ if right > 1000 or right < -1000:
91
+ raise ValueError("Exponent too large in power calculation (exceeds magnitude limit of 1000)")
92
+ if isinstance(left, (int, float)) and abs(left) > 1 and isinstance(right, (int, float)) and right > 0:
93
+ if abs(left) > 1e100 and right > 1:
94
+ raise ValueError("Base value too large for exponentiation")
95
+ if math.log10(abs(left)) * float(right) > 1000:
96
+ raise ValueError("Exponent too large in power calculation (result exceeds digit limit)")
97
+
98
+ try:
99
+ res = op(left, right)
100
+ except OverflowError as exc:
101
+ raise ValueError("Numeric calculation overflow") from exc
102
+
103
+ if isinstance(res, complex):
104
+ raise TypeError("Complex numbers are not supported in math expressions")
105
+ if isinstance(res, float) and (math.isnan(res) or math.isinf(res)):
106
+ raise ValueError("Binary operation resulted in non-finite value (NaN or Infinity)")
107
+ return res
108
+
109
+ if isinstance(node, ast.Call):
110
+ if not isinstance(node.func, ast.Name):
111
+ raise TypeError("Only direct math function calls are permitted (attribute and method access forbidden)")
112
+ if node.keywords:
113
+ raise ValueError("Keyword arguments are not permitted in math function calls")
114
+ if any(isinstance(arg, ast.Starred) for arg in node.args):
115
+ raise ValueError("Starred arguments are not permitted in math function calls")
116
+ fn_name = node.func.id.lower()
117
+ fn = _SAFE_FUNCTIONS.get(fn_name)
118
+ if fn is None:
119
+ raise ValueError(f"Function '{fn_name}' is not in the safe math allowlist")
120
+ args = [_safe_eval_node(arg, depth + 1, max_depth) for arg in node.args]
121
+ try:
122
+ res = fn(*args)
123
+ except (ValueError, OverflowError) as exc:
124
+ raise ValueError(f"Function '{fn_name}' failed: {exc}") from exc
125
+
126
+ if isinstance(res, complex):
127
+ raise TypeError("Complex numbers are not supported in math expressions")
128
+ if isinstance(res, float) and (math.isnan(res) or math.isinf(res)):
129
+ raise ValueError(f"Function '{fn_name}' produced a non-finite result (NaN or Infinity)")
130
+ return res
131
+
132
+ # Explicit rejection of unauthorized constructs (attributes, subscripts, comprehensions, etc.)
133
+ raise ValueError(f"Disallowed expression element '{type(node).__name__}'")
134
+
135
+
136
+ def _evaluate_math_expression(clean_expr: str, max_ast_depth: int) -> int | float:
137
+ """Parse and safely evaluate a math expression synchronously for threadpool offload."""
138
+ tree = ast.parse(clean_expr, mode="eval")
139
+ result = _safe_eval_node(tree, depth=0, max_depth=max_ast_depth)
140
+ if isinstance(result, complex):
141
+ raise TypeError("Complex numbers are not supported in math expressions")
142
+ if isinstance(result, float):
143
+ if result.is_integer():
144
+ return int(result)
145
+ return result
146
+ if isinstance(result, int) and not isinstance(result, bool):
147
+ return result
148
+ raise ValueError(f"Unsupported numeric result type: {type(result).__name__}")
149
+
150
+
151
+ class CalculatorTool(BaseTool):
152
+ """
153
+ Safely calculates mathematical expressions using abstract syntax trees with
154
+ strict resource and security bounds (zero eval).
155
+ """
156
+
157
+ def __init__(
158
+ self,
159
+ max_expression_length: int = 500,
160
+ max_ast_depth: int = 20,
161
+ ) -> None:
162
+ self._max_expression_length = max_expression_length
163
+ self._max_ast_depth = max_ast_depth
164
+
165
+ @property
166
+ def name(self) -> str:
167
+ return "calculator"
168
+
169
+ @property
170
+ def description(self) -> str:
171
+ return (
172
+ "Perform mathematical calculations and evaluate numeric expressions safely. "
173
+ "Supports basic arithmetic (+, -, *, /, //, %, **), powers, and functions like sqrt, abs, round."
174
+ )
175
+
176
+ @property
177
+ def parameters(self) -> dict[str, Any]:
178
+ return {
179
+ "type": "object",
180
+ "properties": {
181
+ "expression": {
182
+ "type": "string",
183
+ "description": "The mathematical expression to evaluate, e.g. '(15 * 4) + 120'.",
184
+ }
185
+ },
186
+ "required": ["expression"],
187
+ }
188
+
189
+ async def handle(self, expression: str = "", **kwargs: Any) -> Any:
190
+ # Validate expression argument type
191
+ raw_expr = expression if expression != "" else kwargs.get("expression", "")
192
+ if not isinstance(raw_expr, str):
193
+ raise TypeError(f"expression must be a string, got {type(raw_expr).__name__}")
194
+
195
+ clean_expr = raw_expr.strip()
196
+ if not clean_expr:
197
+ raise ValueError("Expression cannot be empty.")
198
+
199
+ if len(clean_expr) > self._max_expression_length:
200
+ return {
201
+ "expression": clean_expr[:50] + "...",
202
+ "error": f"Expression length ({len(clean_expr)}) exceeds maximum allowed limit ({self._max_expression_length})",
203
+ }
204
+
205
+ try:
206
+ result = await asyncio.to_thread(
207
+ _evaluate_math_expression,
208
+ clean_expr,
209
+ self._max_ast_depth,
210
+ )
211
+ return {"expression": clean_expr, "result": result}
212
+ except (SyntaxError, ZeroDivisionError, ValueError, TypeError, OverflowError) as exc:
213
+ return {"expression": clean_expr, "error": str(exc)}
214
+
215
+
216
+ # ==============================================================================
217
+ # 2. Date and Time Tool
218
+ # ==============================================================================
219
+
220
+
221
+ class DateTimeTool(BaseTool):
222
+ """
223
+ Provides the current date, time, weekday, and timestamp in UTC or specified timezone.
224
+ """
225
+
226
+ @property
227
+ def name(self) -> str:
228
+ return "date_time"
229
+
230
+ @property
231
+ def description(self) -> str:
232
+ return (
233
+ "Get the current date and time in UTC or a specified timezone with optional custom format. "
234
+ "Use this whenever you need to know today's date, current year, current time, or day of the week."
235
+ )
236
+
237
+ @property
238
+ def parameters(self) -> dict[str, Any]:
239
+ return {
240
+ "type": "object",
241
+ "properties": {
242
+ "timezone": {
243
+ "type": "string",
244
+ "description": "Timezone name (e.g. 'UTC', 'US/Eastern', 'Europe/London'). Defaults to 'UTC'.",
245
+ },
246
+ "format": {
247
+ "type": "string",
248
+ "description": "Optional strftime format string (e.g. '%Y-%m-%d', '%A, %B %d, %Y'). Defaults to ISO-8601.",
249
+ },
250
+ },
251
+ "required": [],
252
+ }
253
+
254
+ async def handle(
255
+ self,
256
+ timezone: str = "UTC",
257
+ format: str | None = None,
258
+ **kwargs: Any,
259
+ ) -> Any:
260
+ if not isinstance(timezone, str):
261
+ raise TypeError(f"timezone must be a string, got {type(timezone).__name__}")
262
+ if format is not None and not isinstance(format, str):
263
+ raise TypeError(f"format must be a string or None, got {type(format).__name__}")
264
+
265
+ tz: datetime.tzinfo = datetime.UTC
266
+ tz_name = timezone or "UTC"
267
+
268
+ if tz_name.upper() != "UTC":
269
+ try:
270
+ tz = zoneinfo.ZoneInfo(tz_name)
271
+ except (KeyError, ValueError):
272
+ tz = datetime.UTC
273
+ tz_name = "UTC"
274
+
275
+ now = datetime.datetime.now(tz)
276
+ try:
277
+ formatted = now.strftime(format) if format else now.isoformat()
278
+ except (ValueError, TypeError):
279
+ formatted = now.isoformat()
280
+
281
+ return {
282
+ "datetime": formatted,
283
+ "iso": now.isoformat(),
284
+ "date": now.strftime("%Y-%m-%d"),
285
+ "time": now.strftime("%H:%M:%S"),
286
+ "weekday": now.strftime("%A"),
287
+ "year": now.year,
288
+ "timezone": tz_name,
289
+ }
290
+
291
+
292
+ # ==============================================================================
293
+ # 3. Web Search Tool
294
+ # ==============================================================================
295
+
296
+
297
+ class WebSearchTool(BaseTool):
298
+ """
299
+ WebSearchTool provides a pluggable search integration with a configurable
300
+ search function and a built-in synthetic fallback for development and testing.
301
+ """
302
+
303
+ def __init__(
304
+ self,
305
+ search_fn: Callable[..., Any] | None = None,
306
+ timeout: float = 10.0,
307
+ ) -> None:
308
+ self._search_fn = search_fn
309
+ self._timeout = timeout
310
+
311
+ @property
312
+ def name(self) -> str:
313
+ return "web_search"
314
+
315
+ @property
316
+ def description(self) -> str:
317
+ return (
318
+ "Search the web for up-to-date information, news, current events, facts, or technical documentation."
319
+ )
320
+
321
+ @property
322
+ def parameters(self) -> dict[str, Any]:
323
+ return {
324
+ "type": "object",
325
+ "properties": {
326
+ "query": {
327
+ "type": "string",
328
+ "description": "The search query to look up on the web.",
329
+ },
330
+ "max_results": {
331
+ "type": "integer",
332
+ "description": "Maximum number of search results to return (default is 5).",
333
+ },
334
+ },
335
+ "required": ["query"],
336
+ }
337
+
338
+ async def handle(self, query: str = "", max_results: int = 5, **kwargs: Any) -> Any:
339
+ raw_query = query if query != "" else kwargs.get("query", "")
340
+ if not isinstance(raw_query, str):
341
+ raise TypeError(f"query must be a string, got {type(raw_query).__name__}")
342
+
343
+ if isinstance(max_results, bool) or not isinstance(max_results, int):
344
+ raise TypeError(f"max_results must be an integer, got {type(max_results).__name__}")
345
+ if max_results <= 0:
346
+ raise ValueError(f"max_results must be a positive integer > 0, got {max_results}")
347
+
348
+ clean_query = raw_query.strip()
349
+ if not clean_query:
350
+ return {"query": clean_query, "results": []}
351
+
352
+ if self._search_fn is None:
353
+ raise AIConfigurationError(
354
+ "WebSearchTool requires a configured 'search_fn' callable; "
355
+ "synthetic search results are disabled."
356
+ )
357
+
358
+ try:
359
+ if is_async_callable(self._search_fn):
360
+ coro = self._search_fn(clean_query, max_results=max_results)
361
+ raw_res = await asyncio.wait_for(coro, timeout=self._timeout)
362
+ else:
363
+ call_fn = self._search_fn
364
+ coro_sync = asyncio.to_thread(call_fn, clean_query, max_results=max_results)
365
+ raw_res = await asyncio.wait_for(coro_sync, timeout=self._timeout)
366
+
367
+ # Normalize results
368
+ if isinstance(raw_res, dict):
369
+ return raw_res
370
+ if isinstance(raw_res, list):
371
+ return {"query": clean_query, "results": raw_res[:max_results]}
372
+ return {"query": clean_query, "results": [{"content": str(raw_res)}]}
373
+ except TimeoutError:
374
+ return {
375
+ "query": clean_query,
376
+ "error": f"Search operation timed out after {self._timeout}s",
377
+ "results": [],
378
+ }
379
+ except Exception as exc: # noqa: BLE001
380
+ return {
381
+ "query": clean_query,
382
+ "error": f"Search function failed: {exc!s}",
383
+ "results": [],
384
+ }