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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|