agentle 0.9.4__py3-none-any.whl → 0.9.28__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.
- agentle/agents/agent.py +175 -10
- agentle/agents/agent_run_output.py +8 -1
- agentle/agents/apis/__init__.py +79 -6
- agentle/agents/apis/api.py +342 -73
- agentle/agents/apis/api_key_authentication.py +43 -0
- agentle/agents/apis/api_key_location.py +11 -0
- agentle/agents/apis/api_metrics.py +16 -0
- agentle/agents/apis/auth_type.py +17 -0
- agentle/agents/apis/authentication.py +32 -0
- agentle/agents/apis/authentication_base.py +42 -0
- agentle/agents/apis/authentication_config.py +117 -0
- agentle/agents/apis/basic_authentication.py +34 -0
- agentle/agents/apis/bearer_authentication.py +52 -0
- agentle/agents/apis/cache_strategy.py +12 -0
- agentle/agents/apis/circuit_breaker.py +69 -0
- agentle/agents/apis/circuit_breaker_error.py +7 -0
- agentle/agents/apis/circuit_breaker_state.py +11 -0
- agentle/agents/apis/endpoint.py +413 -254
- agentle/agents/apis/file_upload.py +23 -0
- agentle/agents/apis/hmac_authentication.py +56 -0
- agentle/agents/apis/no_authentication.py +27 -0
- agentle/agents/apis/oauth2_authentication.py +111 -0
- agentle/agents/apis/oauth2_grant_type.py +12 -0
- agentle/agents/apis/object_schema.py +86 -1
- agentle/agents/apis/params/__init__.py +10 -1
- agentle/agents/apis/params/boolean_param.py +44 -0
- agentle/agents/apis/params/number_param.py +56 -0
- agentle/agents/apis/rate_limit_error.py +7 -0
- agentle/agents/apis/rate_limiter.py +57 -0
- agentle/agents/apis/request_config.py +126 -4
- agentle/agents/apis/request_hook.py +16 -0
- agentle/agents/apis/response_cache.py +49 -0
- agentle/agents/apis/retry_strategy.py +12 -0
- agentle/agents/whatsapp/human_delay_calculator.py +462 -0
- agentle/agents/whatsapp/models/audio_message.py +6 -4
- agentle/agents/whatsapp/models/key.py +2 -2
- agentle/agents/whatsapp/models/whatsapp_bot_config.py +375 -21
- agentle/agents/whatsapp/models/whatsapp_response_base.py +31 -0
- agentle/agents/whatsapp/models/whatsapp_webhook_payload.py +5 -1
- agentle/agents/whatsapp/providers/base/whatsapp_provider.py +51 -0
- agentle/agents/whatsapp/providers/evolution/evolution_api_provider.py +237 -10
- agentle/agents/whatsapp/providers/meta/meta_whatsapp_provider.py +126 -0
- agentle/agents/whatsapp/v2/batch_processor_manager.py +4 -0
- agentle/agents/whatsapp/v2/bot_config.py +188 -0
- agentle/agents/whatsapp/v2/message_limit.py +9 -0
- agentle/agents/whatsapp/v2/payload.py +0 -0
- agentle/agents/whatsapp/v2/whatsapp_bot.py +13 -0
- agentle/agents/whatsapp/v2/whatsapp_cloud_api_provider.py +0 -0
- agentle/agents/whatsapp/v2/whatsapp_provider.py +0 -0
- agentle/agents/whatsapp/whatsapp_bot.py +827 -45
- agentle/generations/providers/google/adapters/generate_generate_content_response_to_generation_adapter.py +13 -10
- agentle/generations/providers/google/google_generation_provider.py +35 -5
- agentle/generations/providers/openrouter/_adapters/openrouter_message_to_generated_assistant_message_adapter.py +35 -1
- agentle/mcp/servers/stdio_mcp_server.py +23 -4
- agentle/parsing/parsers/docx.py +8 -0
- agentle/parsing/parsers/file_parser.py +4 -0
- agentle/parsing/parsers/pdf.py +7 -1
- agentle/storage/__init__.py +11 -0
- agentle/storage/file_storage_manager.py +44 -0
- agentle/storage/local_file_storage_manager.py +122 -0
- agentle/storage/s3_file_storage_manager.py +124 -0
- agentle/tts/audio_format.py +6 -0
- agentle/tts/elevenlabs_tts_provider.py +108 -0
- agentle/tts/output_format_type.py +26 -0
- agentle/tts/speech_config.py +14 -0
- agentle/tts/speech_result.py +15 -0
- agentle/tts/tts_provider.py +16 -0
- agentle/tts/voice_settings.py +30 -0
- agentle/utils/parse_streaming_json.py +39 -13
- agentle/voice_cloning/__init__.py +0 -0
- agentle/voice_cloning/voice_cloner.py +0 -0
- agentle/web/extractor.py +282 -148
- {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/METADATA +1 -1
- {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/RECORD +78 -39
- agentle/tts/real_time/definitions/audio_data.py +0 -20
- agentle/tts/real_time/definitions/speech_config.py +0 -27
- agentle/tts/real_time/definitions/speech_result.py +0 -14
- agentle/tts/real_time/definitions/tts_stream_chunk.py +0 -15
- agentle/tts/real_time/definitions/voice_gender.py +0 -9
- agentle/tts/real_time/definitions/voice_info.py +0 -18
- agentle/tts/real_time/real_time_speech_to_text_provider.py +0 -66
- /agentle/{tts/real_time → agents/whatsapp/v2}/__init__.py +0 -0
- /agentle/{tts/real_time/definitions/__init__.py → agents/whatsapp/v2/in_memory_batch_processor_manager.py} +0 -0
- {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/WHEEL +0 -0
- {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/licenses/LICENSE +0 -0
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import datetime
|
|
4
4
|
import logging
|
|
5
5
|
import uuid
|
|
6
|
-
from collections.abc import AsyncIterator
|
|
6
|
+
from collections.abc import AsyncGenerator, AsyncIterator
|
|
7
7
|
from logging import Logger
|
|
8
8
|
from typing import TYPE_CHECKING, Any, Literal, cast, overload
|
|
9
9
|
|
|
@@ -88,11 +88,11 @@ class GenerateGenerateContentResponseToGenerationAdapter[T](
|
|
|
88
88
|
@overload
|
|
89
89
|
def adapt(
|
|
90
90
|
self, _f: AsyncIterator["GenerateContentResponse"]
|
|
91
|
-
) ->
|
|
91
|
+
) -> AsyncGenerator[Generation[T], None]: ...
|
|
92
92
|
|
|
93
93
|
def adapt(
|
|
94
94
|
self, _f: "GenerateContentResponse | AsyncIterator[GenerateContentResponse]"
|
|
95
|
-
) -> Generation[T] |
|
|
95
|
+
) -> Generation[T] | AsyncGenerator[Generation[T], None]:
|
|
96
96
|
"""
|
|
97
97
|
Convert Google response(s) to Agentle Generation object(s).
|
|
98
98
|
|
|
@@ -214,7 +214,7 @@ class GenerateGenerateContentResponseToGenerationAdapter[T](
|
|
|
214
214
|
|
|
215
215
|
async def _adapt_streaming(
|
|
216
216
|
self, response_stream: AsyncIterator["GenerateContentResponse"]
|
|
217
|
-
) ->
|
|
217
|
+
) -> AsyncGenerator[Generation[T], None]:
|
|
218
218
|
"""Adapt a streaming response with proper text accumulation."""
|
|
219
219
|
generation_id = self.preferred_id or uuid.uuid4()
|
|
220
220
|
created_time = datetime.datetime.now()
|
|
@@ -253,16 +253,19 @@ class GenerateGenerateContentResponseToGenerationAdapter[T](
|
|
|
253
253
|
_all_parts.extend(_parts)
|
|
254
254
|
|
|
255
255
|
if _optional_model is not None:
|
|
256
|
-
|
|
257
|
-
|
|
256
|
+
# Parse streaming JSON and update final_parsed
|
|
257
|
+
accumulated_json_text = "".join([str(p.text) for p in _all_parts])
|
|
258
|
+
parsed_optional_model = parse_streaming_json(
|
|
259
|
+
accumulated_json_text,
|
|
258
260
|
model=_optional_model,
|
|
259
261
|
)
|
|
260
|
-
|
|
262
|
+
# Cast the optional model back to T for use in the generation
|
|
263
|
+
final_parsed = cast(T, parsed_optional_model)
|
|
261
264
|
else:
|
|
262
|
-
|
|
265
|
+
final_parsed = None
|
|
263
266
|
|
|
264
|
-
#
|
|
265
|
-
|
|
267
|
+
# Also check if chunk has parsed attribute from Google API
|
|
268
|
+
elif hasattr(chunk, "parsed") and chunk.parsed is not None:
|
|
266
269
|
final_parsed = cast(T | None, chunk.parsed)
|
|
267
270
|
|
|
268
271
|
# Extract usage (usually only in final chunk)
|
|
@@ -182,6 +182,37 @@ class GoogleGenerationProvider(GenerationProvider):
|
|
|
182
182
|
"""
|
|
183
183
|
return "google"
|
|
184
184
|
|
|
185
|
+
@overload
|
|
186
|
+
def stream_async[T](
|
|
187
|
+
self,
|
|
188
|
+
*,
|
|
189
|
+
model: str | ModelKind | None = None,
|
|
190
|
+
messages: Sequence[Message],
|
|
191
|
+
response_schema: type[T],
|
|
192
|
+
generation_config: GenerationConfig | GenerationConfigDict | None = None,
|
|
193
|
+
) -> AsyncGenerator[Generation[T], None]: ...
|
|
194
|
+
|
|
195
|
+
@overload
|
|
196
|
+
def stream_async(
|
|
197
|
+
self,
|
|
198
|
+
*,
|
|
199
|
+
model: str | ModelKind | None = None,
|
|
200
|
+
messages: Sequence[Message],
|
|
201
|
+
response_schema: None = None,
|
|
202
|
+
generation_config: GenerationConfig | GenerationConfigDict | None = None,
|
|
203
|
+
tools: Sequence[Tool],
|
|
204
|
+
) -> AsyncGenerator[Generation[WithoutStructuredOutput], None]: ...
|
|
205
|
+
|
|
206
|
+
@overload
|
|
207
|
+
def stream_async(
|
|
208
|
+
self,
|
|
209
|
+
*,
|
|
210
|
+
model: str | ModelKind | None = None,
|
|
211
|
+
messages: Sequence[Message],
|
|
212
|
+
response_schema: None = None,
|
|
213
|
+
generation_config: GenerationConfig | GenerationConfigDict | None = None,
|
|
214
|
+
) -> AsyncGenerator[Generation[WithoutStructuredOutput], None]: ...
|
|
215
|
+
|
|
185
216
|
async def stream_async[T = WithoutStructuredOutput](
|
|
186
217
|
self,
|
|
187
218
|
*,
|
|
@@ -190,7 +221,7 @@ class GoogleGenerationProvider(GenerationProvider):
|
|
|
190
221
|
response_schema: type[T] | None = None,
|
|
191
222
|
generation_config: GenerationConfig | GenerationConfigDict | None = None,
|
|
192
223
|
tools: Sequence[Tool] | None = None,
|
|
193
|
-
) -> AsyncGenerator[Generation[
|
|
224
|
+
) -> AsyncGenerator[Generation[T], None]:
|
|
194
225
|
from google.genai import types
|
|
195
226
|
|
|
196
227
|
if self._normalize_generation_config(generation_config).n > 1:
|
|
@@ -260,6 +291,7 @@ class GoogleGenerationProvider(GenerationProvider):
|
|
|
260
291
|
tools=_tools,
|
|
261
292
|
max_output_tokens=_generation_config.max_output_tokens,
|
|
262
293
|
response_schema=response_schema if bool(response_schema) else None,
|
|
294
|
+
response_mime_type="application/json" if bool(response_schema) else None,
|
|
263
295
|
automatic_function_calling=types.AutomaticFunctionCallingConfig(
|
|
264
296
|
disable=disable_function_calling,
|
|
265
297
|
maximum_remote_calls=maximum_remote_calls,
|
|
@@ -295,10 +327,8 @@ class GoogleGenerationProvider(GenerationProvider):
|
|
|
295
327
|
raise
|
|
296
328
|
|
|
297
329
|
# Create the response
|
|
298
|
-
response = GenerateGenerateContentResponseToGenerationAdapter[
|
|
299
|
-
|
|
300
|
-
](
|
|
301
|
-
response_schema=None,
|
|
330
|
+
response = GenerateGenerateContentResponseToGenerationAdapter[T](
|
|
331
|
+
response_schema=response_schema,
|
|
302
332
|
model=used_model,
|
|
303
333
|
).adapt(generate_content_response_stream)
|
|
304
334
|
|
|
@@ -84,11 +84,45 @@ class OpenRouterMessageToGeneratedAssistantMessageAdapter[T](
|
|
|
84
84
|
tool_parts: list[ToolExecutionSuggestion] = []
|
|
85
85
|
for tool_call in tool_calls_data:
|
|
86
86
|
function_data = tool_call.get("function", {})
|
|
87
|
+
|
|
88
|
+
# Parse arguments with error handling for malformed JSON
|
|
89
|
+
args_str = str(function_data.get("arguments", "{}"))
|
|
90
|
+
args: dict[str, object] = {}
|
|
91
|
+
try:
|
|
92
|
+
args = json.loads(args_str)
|
|
93
|
+
except json.JSONDecodeError as e:
|
|
94
|
+
# Log the error and try to extract the first valid JSON object
|
|
95
|
+
import logging
|
|
96
|
+
|
|
97
|
+
logger = logging.getLogger(__name__)
|
|
98
|
+
logger.warning(
|
|
99
|
+
f"Malformed JSON in tool call arguments: {e}. "
|
|
100
|
+
+ "Attempting to parse first valid JSON object. "
|
|
101
|
+
+ f"Raw arguments: {args_str[:200]}..."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Try to find the first complete JSON object
|
|
105
|
+
try:
|
|
106
|
+
# Use JSONDecoder to parse incrementally
|
|
107
|
+
decoder = json.JSONDecoder()
|
|
108
|
+
args, idx = decoder.raw_decode(args_str)
|
|
109
|
+
if idx < len(args_str.strip()):
|
|
110
|
+
logger.warning(
|
|
111
|
+
f"Extra data found after position {idx}. "
|
|
112
|
+
+ "Using first valid JSON object only."
|
|
113
|
+
)
|
|
114
|
+
except (json.JSONDecodeError, ValueError) as e2:
|
|
115
|
+
logger.error(
|
|
116
|
+
f"Failed to parse tool call arguments even with recovery: {e2}. "
|
|
117
|
+
+ "Using empty dict."
|
|
118
|
+
)
|
|
119
|
+
args = {}
|
|
120
|
+
|
|
87
121
|
tool_parts.append(
|
|
88
122
|
ToolExecutionSuggestion(
|
|
89
123
|
id=str(tool_call.get("id", "")),
|
|
90
124
|
tool_name=str(function_data.get("name", "")),
|
|
91
|
-
args=
|
|
125
|
+
args=args,
|
|
92
126
|
)
|
|
93
127
|
)
|
|
94
128
|
|
|
@@ -451,12 +451,19 @@ class StdioMCPServer(MCPServerProtocol):
|
|
|
451
451
|
self._logger.warning("Server closed stdout")
|
|
452
452
|
break
|
|
453
453
|
|
|
454
|
+
message_str = ""
|
|
454
455
|
try:
|
|
455
456
|
message_str = line.decode("utf-8").strip()
|
|
456
457
|
if not message_str:
|
|
457
458
|
continue
|
|
458
459
|
|
|
459
460
|
self._logger.debug(f"<- RECV: {message_str}")
|
|
461
|
+
|
|
462
|
+
# Skip lines that don't look like JSON (common with MCP servers that log to stdout)
|
|
463
|
+
if not message_str.startswith("{"):
|
|
464
|
+
self._logger.debug(f"Skipping non-JSON line: {message_str}")
|
|
465
|
+
continue
|
|
466
|
+
|
|
460
467
|
message = json.loads(message_str)
|
|
461
468
|
|
|
462
469
|
if "id" in message:
|
|
@@ -468,8 +475,10 @@ class StdioMCPServer(MCPServerProtocol):
|
|
|
468
475
|
else:
|
|
469
476
|
self._logger.warning(f"Unknown message type: {message}")
|
|
470
477
|
|
|
471
|
-
except json.JSONDecodeError
|
|
472
|
-
self._logger.
|
|
478
|
+
except json.JSONDecodeError:
|
|
479
|
+
self._logger.debug(
|
|
480
|
+
f"Skipping invalid JSON line: {message_str[:100]}"
|
|
481
|
+
)
|
|
473
482
|
except UnicodeDecodeError as e:
|
|
474
483
|
self._logger.error(f"Failed to decode UTF-8: {e}")
|
|
475
484
|
|
|
@@ -762,7 +771,12 @@ class StdioMCPServer(MCPServerProtocol):
|
|
|
762
771
|
|
|
763
772
|
async def _send_request(self, request: _JsonRpcRequest) -> _JsonRpcResponse:
|
|
764
773
|
"""Send a request to the server and wait for response."""
|
|
765
|
-
|
|
774
|
+
# Allow requests during INITIALIZING state (for the initialize request itself)
|
|
775
|
+
if (
|
|
776
|
+
self._connection_state
|
|
777
|
+
not in [ConnectionState.CONNECTED, ConnectionState.INITIALIZING]
|
|
778
|
+
or not self._stdin
|
|
779
|
+
):
|
|
766
780
|
raise ConnectionError("Server not connected")
|
|
767
781
|
|
|
768
782
|
request_id = request["id"]
|
|
@@ -798,7 +812,12 @@ class StdioMCPServer(MCPServerProtocol):
|
|
|
798
812
|
|
|
799
813
|
async def _send_notification(self, notification: _JsonRpcNotification) -> None:
|
|
800
814
|
"""Send a notification to the server."""
|
|
801
|
-
|
|
815
|
+
# Allow notifications during INITIALIZING state (for the initialized notification)
|
|
816
|
+
if (
|
|
817
|
+
self._connection_state
|
|
818
|
+
not in [ConnectionState.CONNECTED, ConnectionState.INITIALIZING]
|
|
819
|
+
or not self._stdin
|
|
820
|
+
):
|
|
802
821
|
raise ConnectionError("Server not connected")
|
|
803
822
|
|
|
804
823
|
try:
|
agentle/parsing/parsers/docx.py
CHANGED
|
@@ -20,6 +20,7 @@ from typing import Literal, Any
|
|
|
20
20
|
from rsb.models.field import Field
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
from agentle.generations.models.generation.generation_config import GenerationConfig
|
|
23
24
|
from agentle.generations.models.message_parts.file import FilePart
|
|
24
25
|
from agentle.generations.models.structured_outputs_store.visual_media_description import (
|
|
25
26
|
VisualMediaDescription,
|
|
@@ -182,6 +183,9 @@ class DocxFileParser(DocumentParser):
|
|
|
182
183
|
Note: When this is enabled, most other configuration options are ignored as the AI handles all processing.
|
|
183
184
|
"""
|
|
184
185
|
|
|
186
|
+
max_output_tokens: int | None = Field(default=None)
|
|
187
|
+
"""Maximum number of tokens to generate in the response."""
|
|
188
|
+
|
|
185
189
|
async def parse_async(
|
|
186
190
|
self,
|
|
187
191
|
document_path: str,
|
|
@@ -517,6 +521,9 @@ class DocxFileParser(DocumentParser):
|
|
|
517
521
|
"Output clear, concise descriptions suitable for a 'Visual Content' section."
|
|
518
522
|
),
|
|
519
523
|
response_schema=VisualMediaDescription,
|
|
524
|
+
generation_config=GenerationConfig(
|
|
525
|
+
max_output_tokens=self.max_output_tokens
|
|
526
|
+
),
|
|
520
527
|
)
|
|
521
528
|
page_description = agent_response.parsed.md
|
|
522
529
|
image_cache[page_hash] = (page_description, "")
|
|
@@ -663,6 +670,7 @@ class DocxFileParser(DocumentParser):
|
|
|
663
670
|
model=self.model,
|
|
664
671
|
use_native_pdf_processing=True,
|
|
665
672
|
strategy=self.strategy,
|
|
673
|
+
max_output_tokens=self.max_output_tokens,
|
|
666
674
|
)
|
|
667
675
|
|
|
668
676
|
logger.debug("Delegating to PDFFileParser with native processing")
|
|
@@ -261,6 +261,9 @@ class FileParser(DocumentParser):
|
|
|
261
261
|
Note: When this is enabled, most other configuration options are ignored as the AI handles all processing.
|
|
262
262
|
"""
|
|
263
263
|
|
|
264
|
+
max_output_tokens: int | None = Field(default=None)
|
|
265
|
+
"""Maximum number of tokens to generate in the response."""
|
|
266
|
+
|
|
264
267
|
async def parse_async(self, document_path: str) -> ParsedFile:
|
|
265
268
|
"""
|
|
266
269
|
Asynchronously parse a document using the appropriate parser for its file type.
|
|
@@ -378,4 +381,5 @@ class FileParser(DocumentParser):
|
|
|
378
381
|
use_native_docx_processing=self.use_native_docx_processing,
|
|
379
382
|
strategy=self.strategy,
|
|
380
383
|
model=self.model,
|
|
384
|
+
max_output_tokens=self.max_output_tokens,
|
|
381
385
|
).parse_async(document_path=str(resolved_path))
|
agentle/parsing/parsers/pdf.py
CHANGED
|
@@ -258,6 +258,9 @@ class PDFFileParser(DocumentParser):
|
|
|
258
258
|
# Metrics state
|
|
259
259
|
last_parse_metrics: PDFParseMetrics | None = None
|
|
260
260
|
|
|
261
|
+
max_output_tokens: int | None = Field(default=None)
|
|
262
|
+
"""Maximum number of tokens to generate in the response."""
|
|
263
|
+
|
|
261
264
|
async def parse_async(self, document_path: str) -> ParsedFile:
|
|
262
265
|
"""
|
|
263
266
|
Asynchronously parse a PDF document and convert it to a structured representation.
|
|
@@ -739,6 +742,9 @@ class PDFFileParser(DocumentParser):
|
|
|
739
742
|
file_part,
|
|
740
743
|
developer_prompt=developer_prompt,
|
|
741
744
|
response_schema=VisualMediaDescription,
|
|
745
|
+
generation_config=GenerationConfig(
|
|
746
|
+
max_output_tokens=self.max_output_tokens
|
|
747
|
+
),
|
|
742
748
|
),
|
|
743
749
|
timeout=self.image_description_timeout,
|
|
744
750
|
)
|
|
@@ -855,7 +861,7 @@ class PDFFileParser(DocumentParser):
|
|
|
855
861
|
prompt=[pdf_file_part, prompt],
|
|
856
862
|
response_schema=PDFPageExtraction,
|
|
857
863
|
generation_config=GenerationConfig(
|
|
858
|
-
timeout_s=300.0,
|
|
864
|
+
timeout_s=300.0, max_output_tokens=self.max_output_tokens
|
|
859
865
|
),
|
|
860
866
|
model=self.model,
|
|
861
867
|
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Storage module for file management."""
|
|
2
|
+
|
|
3
|
+
from agentle.storage.file_storage_manager import FileStorageManager
|
|
4
|
+
from agentle.storage.local_file_storage_manager import LocalFileStorageManager
|
|
5
|
+
from agentle.storage.s3_file_storage_manager import S3FileStorageManager
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"FileStorageManager",
|
|
9
|
+
"LocalFileStorageManager",
|
|
10
|
+
"S3FileStorageManager",
|
|
11
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Abstract file storage manager interface."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FileStorageManager(ABC):
|
|
7
|
+
"""Interface for file storage management."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
async def upload_file(self, file_data: bytes, filename: str, mime_type: str) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Upload file to storage and return public URL.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
file_data: The file content as bytes
|
|
16
|
+
filename: The filename to use for storage
|
|
17
|
+
mime_type: The MIME type of the file
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Public URL to access the uploaded file
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
FileStorageError: If upload fails
|
|
24
|
+
"""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def delete_file(self, file_url: str) -> bool:
|
|
29
|
+
"""
|
|
30
|
+
Delete file from storage by URL.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
file_url: The public URL of the file to delete
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True if deletion was successful, False otherwise
|
|
37
|
+
"""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FileStorageError(Exception):
|
|
42
|
+
"""Exception raised for file storage operations."""
|
|
43
|
+
|
|
44
|
+
pass
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Local file storage manager implementation."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agentle.storage.file_storage_manager import FileStorageError, FileStorageManager
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LocalFileStorageManager(FileStorageManager):
|
|
15
|
+
"""Local filesystem implementation of file storage manager."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
storage_dir: str | Path = "./storage",
|
|
20
|
+
base_url: str = "http://localhost:8000",
|
|
21
|
+
create_dirs: bool = True,
|
|
22
|
+
):
|
|
23
|
+
"""
|
|
24
|
+
Initialize local file storage manager.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
storage_dir: Directory to store files
|
|
28
|
+
base_url: Base URL for accessing files (e.g., "http://localhost:8000")
|
|
29
|
+
create_dirs: Whether to create storage directory if it doesn't exist
|
|
30
|
+
"""
|
|
31
|
+
self.storage_dir = Path(storage_dir)
|
|
32
|
+
self.base_url = base_url.rstrip("/")
|
|
33
|
+
|
|
34
|
+
if create_dirs:
|
|
35
|
+
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
logger.info(f"Created storage directory: {self.storage_dir}")
|
|
37
|
+
|
|
38
|
+
logger.info(f"LocalFileStorageManager initialized: {self.storage_dir}")
|
|
39
|
+
|
|
40
|
+
async def upload_file(self, file_data: bytes, filename: str, mime_type: str) -> str:
|
|
41
|
+
"""Upload file to local storage and return public URL."""
|
|
42
|
+
try:
|
|
43
|
+
# Ensure filename is safe
|
|
44
|
+
safe_filename = self._make_filename_safe(filename)
|
|
45
|
+
file_path = self.storage_dir / safe_filename
|
|
46
|
+
|
|
47
|
+
logger.debug(
|
|
48
|
+
f"Uploading file to local storage: {file_path} ({len(file_data)} bytes)"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Write file to disk
|
|
52
|
+
with open(file_path, "wb") as f:
|
|
53
|
+
f.write(file_data)
|
|
54
|
+
|
|
55
|
+
# Generate public URL
|
|
56
|
+
url = f"{self.base_url}/files/{safe_filename}"
|
|
57
|
+
|
|
58
|
+
logger.info(f"File uploaded successfully: {url}")
|
|
59
|
+
return url
|
|
60
|
+
|
|
61
|
+
except OSError as e:
|
|
62
|
+
error_msg = f"Failed to write file to local storage: {e}"
|
|
63
|
+
logger.error(error_msg)
|
|
64
|
+
raise FileStorageError(error_msg) from e
|
|
65
|
+
except Exception as e:
|
|
66
|
+
error_msg = f"Unexpected error uploading to local storage: {e}"
|
|
67
|
+
logger.error(error_msg)
|
|
68
|
+
raise FileStorageError(error_msg) from e
|
|
69
|
+
|
|
70
|
+
async def delete_file(self, file_url: str) -> bool:
|
|
71
|
+
"""Delete file from local storage by URL."""
|
|
72
|
+
try:
|
|
73
|
+
# Extract filename from URL
|
|
74
|
+
if "/files/" in file_url:
|
|
75
|
+
filename = file_url.split("/files/")[-1]
|
|
76
|
+
else:
|
|
77
|
+
logger.warning(f"Could not extract filename from URL: {file_url}")
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
file_path = self.storage_dir / filename
|
|
81
|
+
|
|
82
|
+
if not file_path.exists():
|
|
83
|
+
logger.warning(f"File not found: {file_path}")
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
logger.debug(f"Deleting file from local storage: {file_path}")
|
|
87
|
+
|
|
88
|
+
file_path.unlink()
|
|
89
|
+
|
|
90
|
+
logger.info(f"File deleted successfully: {filename}")
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
except OSError as e:
|
|
94
|
+
logger.error(f"Failed to delete file from local storage: {e}")
|
|
95
|
+
return False
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error(f"Unexpected error deleting from local storage: {e}")
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
def _make_filename_safe(self, filename: str) -> str:
|
|
101
|
+
"""Make filename safe for filesystem."""
|
|
102
|
+
# Remove or replace unsafe characters
|
|
103
|
+
safe_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_"
|
|
104
|
+
safe_filename = "".join(c if c in safe_chars else "_" for c in filename)
|
|
105
|
+
|
|
106
|
+
# Ensure it's not empty and has reasonable length
|
|
107
|
+
if not safe_filename or len(safe_filename) > 255:
|
|
108
|
+
timestamp = int(time.time())
|
|
109
|
+
safe_filename = f"file_{timestamp}"
|
|
110
|
+
|
|
111
|
+
return safe_filename
|
|
112
|
+
|
|
113
|
+
def get_storage_info(self) -> dict[str, Any]:
|
|
114
|
+
"""Get information about the storage configuration."""
|
|
115
|
+
return {
|
|
116
|
+
"storage_dir": str(self.storage_dir),
|
|
117
|
+
"base_url": self.base_url,
|
|
118
|
+
"exists": self.storage_dir.exists(),
|
|
119
|
+
"is_writable": os.access(self.storage_dir, os.W_OK)
|
|
120
|
+
if self.storage_dir.exists()
|
|
121
|
+
else False,
|
|
122
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""S3 file storage manager implementation."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import boto3
|
|
8
|
+
from botocore.exceptions import ClientError
|
|
9
|
+
|
|
10
|
+
has_boto3 = True
|
|
11
|
+
except ImportError:
|
|
12
|
+
boto3 = None # type: ignore
|
|
13
|
+
ClientError = Exception # type: ignore
|
|
14
|
+
has_boto3 = False
|
|
15
|
+
|
|
16
|
+
from agentle.storage.file_storage_manager import FileStorageManager, FileStorageError
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class S3FileStorageManager(FileStorageManager):
|
|
22
|
+
"""S3 implementation of file storage manager."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
bucket_name: str,
|
|
27
|
+
region: str = "us-east-1",
|
|
28
|
+
aws_access_key_id: str | None = None,
|
|
29
|
+
aws_secret_access_key: str | None = None,
|
|
30
|
+
endpoint_url: str | None = None,
|
|
31
|
+
):
|
|
32
|
+
"""
|
|
33
|
+
Initialize S3 file storage manager.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
bucket_name: S3 bucket name
|
|
37
|
+
region: AWS region
|
|
38
|
+
aws_access_key_id: AWS access key (optional, uses default credentials if not provided)
|
|
39
|
+
aws_secret_access_key: AWS secret key (optional, uses default credentials if not provided)
|
|
40
|
+
endpoint_url: Custom S3 endpoint URL (for S3-compatible services like MinIO)
|
|
41
|
+
"""
|
|
42
|
+
if not has_boto3 or boto3 is None:
|
|
43
|
+
raise ImportError(
|
|
44
|
+
"boto3 is required for S3FileStorageManager. "
|
|
45
|
+
+ "Install it with: pip install boto3"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
self.bucket_name = bucket_name
|
|
49
|
+
self.region = region
|
|
50
|
+
|
|
51
|
+
# Configure S3 client
|
|
52
|
+
client_kwargs: dict[str, Any] = {"region_name": region}
|
|
53
|
+
|
|
54
|
+
if aws_access_key_id and aws_secret_access_key:
|
|
55
|
+
client_kwargs.update(
|
|
56
|
+
{
|
|
57
|
+
"aws_access_key_id": aws_access_key_id,
|
|
58
|
+
"aws_secret_access_key": aws_secret_access_key,
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if endpoint_url:
|
|
63
|
+
client_kwargs["endpoint_url"] = endpoint_url
|
|
64
|
+
|
|
65
|
+
self.s3_client = boto3.client("s3", **client_kwargs)
|
|
66
|
+
|
|
67
|
+
logger.info(f"S3FileStorageManager initialized for bucket: {bucket_name}")
|
|
68
|
+
|
|
69
|
+
async def upload_file(self, file_data: bytes, filename: str, mime_type: str) -> str:
|
|
70
|
+
"""Upload file to S3 and return public URL."""
|
|
71
|
+
try:
|
|
72
|
+
logger.debug(f"Uploading file to S3: {filename} ({len(file_data)} bytes)")
|
|
73
|
+
|
|
74
|
+
self.s3_client.put_object(
|
|
75
|
+
Bucket=self.bucket_name,
|
|
76
|
+
Key=filename,
|
|
77
|
+
Body=file_data,
|
|
78
|
+
ContentType=mime_type,
|
|
79
|
+
ACL="public-read", # Make file publicly accessible
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Generate public URL
|
|
83
|
+
if self.s3_client.meta.region_name == "us-east-1":
|
|
84
|
+
url = f"https://{self.bucket_name}.s3.amazonaws.com/{filename}"
|
|
85
|
+
else:
|
|
86
|
+
url = f"https://{self.bucket_name}.s3.{self.region}.amazonaws.com/{filename}"
|
|
87
|
+
|
|
88
|
+
logger.info(f"File uploaded successfully: {url}")
|
|
89
|
+
return url
|
|
90
|
+
|
|
91
|
+
except ClientError as e:
|
|
92
|
+
error_msg = f"Failed to upload file to S3: {e}"
|
|
93
|
+
logger.error(error_msg)
|
|
94
|
+
raise FileStorageError(error_msg) from e
|
|
95
|
+
except Exception as e:
|
|
96
|
+
error_msg = f"Unexpected error uploading to S3: {e}"
|
|
97
|
+
logger.error(error_msg)
|
|
98
|
+
raise FileStorageError(error_msg) from e
|
|
99
|
+
|
|
100
|
+
async def delete_file(self, file_url: str) -> bool:
|
|
101
|
+
"""Delete file from S3 by URL."""
|
|
102
|
+
try:
|
|
103
|
+
# Extract key from URL
|
|
104
|
+
if f"s3.{self.region}.amazonaws.com" in file_url:
|
|
105
|
+
key = file_url.split(f"s3.{self.region}.amazonaws.com/")[-1]
|
|
106
|
+
elif "s3.amazonaws.com" in file_url:
|
|
107
|
+
key = file_url.split("s3.amazonaws.com/")[-1]
|
|
108
|
+
else:
|
|
109
|
+
logger.warning(f"Could not extract S3 key from URL: {file_url}")
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
logger.debug(f"Deleting file from S3: {key}")
|
|
113
|
+
|
|
114
|
+
self.s3_client.delete_object(Bucket=self.bucket_name, Key=key)
|
|
115
|
+
|
|
116
|
+
logger.info(f"File deleted successfully: {key}")
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
except ClientError as e:
|
|
120
|
+
logger.error(f"Failed to delete file from S3: {e}")
|
|
121
|
+
return False
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.error(f"Unexpected error deleting from S3: {e}")
|
|
124
|
+
return False
|