atlas-chat 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.
- atlas/__init__.py +40 -0
- atlas/application/__init__.py +7 -0
- atlas/application/chat/__init__.py +7 -0
- atlas/application/chat/agent/__init__.py +10 -0
- atlas/application/chat/agent/act_loop.py +179 -0
- atlas/application/chat/agent/factory.py +142 -0
- atlas/application/chat/agent/protocols.py +46 -0
- atlas/application/chat/agent/react_loop.py +338 -0
- atlas/application/chat/agent/think_act_loop.py +171 -0
- atlas/application/chat/approval_manager.py +151 -0
- atlas/application/chat/elicitation_manager.py +191 -0
- atlas/application/chat/events/__init__.py +1 -0
- atlas/application/chat/events/agent_event_relay.py +112 -0
- atlas/application/chat/modes/__init__.py +1 -0
- atlas/application/chat/modes/agent.py +125 -0
- atlas/application/chat/modes/plain.py +74 -0
- atlas/application/chat/modes/rag.py +81 -0
- atlas/application/chat/modes/tools.py +179 -0
- atlas/application/chat/orchestrator.py +213 -0
- atlas/application/chat/policies/__init__.py +1 -0
- atlas/application/chat/policies/tool_authorization.py +99 -0
- atlas/application/chat/preprocessors/__init__.py +1 -0
- atlas/application/chat/preprocessors/message_builder.py +92 -0
- atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
- atlas/application/chat/service.py +454 -0
- atlas/application/chat/utilities/__init__.py +6 -0
- atlas/application/chat/utilities/error_handler.py +367 -0
- atlas/application/chat/utilities/event_notifier.py +546 -0
- atlas/application/chat/utilities/file_processor.py +613 -0
- atlas/application/chat/utilities/tool_executor.py +789 -0
- atlas/atlas_chat_cli.py +347 -0
- atlas/atlas_client.py +238 -0
- atlas/core/__init__.py +0 -0
- atlas/core/auth.py +205 -0
- atlas/core/authorization_manager.py +27 -0
- atlas/core/capabilities.py +123 -0
- atlas/core/compliance.py +215 -0
- atlas/core/domain_whitelist.py +147 -0
- atlas/core/domain_whitelist_middleware.py +82 -0
- atlas/core/http_client.py +28 -0
- atlas/core/log_sanitizer.py +102 -0
- atlas/core/metrics_logger.py +59 -0
- atlas/core/middleware.py +131 -0
- atlas/core/otel_config.py +242 -0
- atlas/core/prompt_risk.py +200 -0
- atlas/core/rate_limit.py +0 -0
- atlas/core/rate_limit_middleware.py +64 -0
- atlas/core/security_headers_middleware.py +51 -0
- atlas/domain/__init__.py +37 -0
- atlas/domain/chat/__init__.py +1 -0
- atlas/domain/chat/dtos.py +85 -0
- atlas/domain/errors.py +96 -0
- atlas/domain/messages/__init__.py +12 -0
- atlas/domain/messages/models.py +160 -0
- atlas/domain/rag_mcp_service.py +664 -0
- atlas/domain/sessions/__init__.py +7 -0
- atlas/domain/sessions/models.py +36 -0
- atlas/domain/unified_rag_service.py +371 -0
- atlas/infrastructure/__init__.py +10 -0
- atlas/infrastructure/app_factory.py +135 -0
- atlas/infrastructure/events/__init__.py +1 -0
- atlas/infrastructure/events/cli_event_publisher.py +140 -0
- atlas/infrastructure/events/websocket_publisher.py +140 -0
- atlas/infrastructure/sessions/in_memory_repository.py +56 -0
- atlas/infrastructure/transport/__init__.py +7 -0
- atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
- atlas/init_cli.py +226 -0
- atlas/interfaces/__init__.py +15 -0
- atlas/interfaces/events.py +134 -0
- atlas/interfaces/llm.py +54 -0
- atlas/interfaces/rag.py +40 -0
- atlas/interfaces/sessions.py +75 -0
- atlas/interfaces/tools.py +57 -0
- atlas/interfaces/transport.py +24 -0
- atlas/main.py +564 -0
- atlas/mcp/api_key_demo/README.md +76 -0
- atlas/mcp/api_key_demo/main.py +172 -0
- atlas/mcp/api_key_demo/run.sh +56 -0
- atlas/mcp/basictable/main.py +147 -0
- atlas/mcp/calculator/main.py +149 -0
- atlas/mcp/code-executor/execution_engine.py +98 -0
- atlas/mcp/code-executor/execution_environment.py +95 -0
- atlas/mcp/code-executor/main.py +528 -0
- atlas/mcp/code-executor/result_processing.py +276 -0
- atlas/mcp/code-executor/script_generation.py +195 -0
- atlas/mcp/code-executor/security_checker.py +140 -0
- atlas/mcp/corporate_cars/main.py +437 -0
- atlas/mcp/csv_reporter/main.py +545 -0
- atlas/mcp/duckduckgo/main.py +182 -0
- atlas/mcp/elicitation_demo/README.md +171 -0
- atlas/mcp/elicitation_demo/main.py +262 -0
- atlas/mcp/env-demo/README.md +158 -0
- atlas/mcp/env-demo/main.py +199 -0
- atlas/mcp/file_size_test/main.py +284 -0
- atlas/mcp/filesystem/main.py +348 -0
- atlas/mcp/image_demo/main.py +113 -0
- atlas/mcp/image_demo/requirements.txt +4 -0
- atlas/mcp/logging_demo/README.md +72 -0
- atlas/mcp/logging_demo/main.py +103 -0
- atlas/mcp/many_tools_demo/main.py +50 -0
- atlas/mcp/order_database/__init__.py +0 -0
- atlas/mcp/order_database/main.py +369 -0
- atlas/mcp/order_database/signal_data.csv +1001 -0
- atlas/mcp/pdfbasic/main.py +394 -0
- atlas/mcp/pptx_generator/main.py +760 -0
- atlas/mcp/pptx_generator/requirements.txt +13 -0
- atlas/mcp/pptx_generator/run_test.sh +1 -0
- atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
- atlas/mcp/progress_demo/main.py +167 -0
- atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
- atlas/mcp/progress_updates_demo/README.md +120 -0
- atlas/mcp/progress_updates_demo/main.py +497 -0
- atlas/mcp/prompts/main.py +222 -0
- atlas/mcp/public_demo/main.py +189 -0
- atlas/mcp/sampling_demo/README.md +169 -0
- atlas/mcp/sampling_demo/main.py +234 -0
- atlas/mcp/thinking/main.py +77 -0
- atlas/mcp/tool_planner/main.py +240 -0
- atlas/mcp/ui-demo/badmesh.png +0 -0
- atlas/mcp/ui-demo/main.py +383 -0
- atlas/mcp/ui-demo/templates/button_demo.html +32 -0
- atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
- atlas/mcp/ui-demo/templates/form_demo.html +28 -0
- atlas/mcp/username-override-demo/README.md +320 -0
- atlas/mcp/username-override-demo/main.py +308 -0
- atlas/modules/__init__.py +0 -0
- atlas/modules/config/__init__.py +34 -0
- atlas/modules/config/cli.py +231 -0
- atlas/modules/config/config_manager.py +1096 -0
- atlas/modules/file_storage/__init__.py +22 -0
- atlas/modules/file_storage/cli.py +330 -0
- atlas/modules/file_storage/content_extractor.py +290 -0
- atlas/modules/file_storage/manager.py +295 -0
- atlas/modules/file_storage/mock_s3_client.py +402 -0
- atlas/modules/file_storage/s3_client.py +417 -0
- atlas/modules/llm/__init__.py +19 -0
- atlas/modules/llm/caller.py +287 -0
- atlas/modules/llm/litellm_caller.py +675 -0
- atlas/modules/llm/models.py +19 -0
- atlas/modules/mcp_tools/__init__.py +17 -0
- atlas/modules/mcp_tools/client.py +2123 -0
- atlas/modules/mcp_tools/token_storage.py +556 -0
- atlas/modules/prompts/prompt_provider.py +130 -0
- atlas/modules/rag/__init__.py +24 -0
- atlas/modules/rag/atlas_rag_client.py +336 -0
- atlas/modules/rag/client.py +129 -0
- atlas/routes/admin_routes.py +865 -0
- atlas/routes/config_routes.py +484 -0
- atlas/routes/feedback_routes.py +361 -0
- atlas/routes/files_routes.py +274 -0
- atlas/routes/health_routes.py +40 -0
- atlas/routes/mcp_auth_routes.py +223 -0
- atlas/server_cli.py +164 -0
- atlas/tests/conftest.py +20 -0
- atlas/tests/integration/test_mcp_auth_integration.py +152 -0
- atlas/tests/manual_test_sampling.py +87 -0
- atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
- atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
- atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
- atlas/tests/test_agent_roa.py +135 -0
- atlas/tests/test_app_factory_smoke.py +47 -0
- atlas/tests/test_approval_manager.py +439 -0
- atlas/tests/test_atlas_client.py +188 -0
- atlas/tests/test_atlas_rag_client.py +447 -0
- atlas/tests/test_atlas_rag_integration.py +224 -0
- atlas/tests/test_attach_file_flow.py +287 -0
- atlas/tests/test_auth_utils.py +165 -0
- atlas/tests/test_backend_public_url.py +185 -0
- atlas/tests/test_banner_logging.py +287 -0
- atlas/tests/test_capability_tokens_and_injection.py +203 -0
- atlas/tests/test_compliance_level.py +54 -0
- atlas/tests/test_compliance_manager.py +253 -0
- atlas/tests/test_config_manager.py +617 -0
- atlas/tests/test_config_manager_paths.py +12 -0
- atlas/tests/test_core_auth.py +18 -0
- atlas/tests/test_core_utils.py +190 -0
- atlas/tests/test_docker_env_sync.py +202 -0
- atlas/tests/test_domain_errors.py +329 -0
- atlas/tests/test_domain_whitelist.py +359 -0
- atlas/tests/test_elicitation_manager.py +408 -0
- atlas/tests/test_elicitation_routing.py +296 -0
- atlas/tests/test_env_demo_server.py +88 -0
- atlas/tests/test_error_classification.py +113 -0
- atlas/tests/test_error_flow_integration.py +116 -0
- atlas/tests/test_feedback_routes.py +333 -0
- atlas/tests/test_file_content_extraction.py +1134 -0
- atlas/tests/test_file_extraction_routes.py +158 -0
- atlas/tests/test_file_library.py +107 -0
- atlas/tests/test_file_manager_unit.py +18 -0
- atlas/tests/test_health_route.py +49 -0
- atlas/tests/test_http_client_stub.py +8 -0
- atlas/tests/test_imports_smoke.py +30 -0
- atlas/tests/test_interfaces_llm_response.py +9 -0
- atlas/tests/test_issue_access_denied_fix.py +136 -0
- atlas/tests/test_llm_env_expansion.py +836 -0
- atlas/tests/test_log_level_sensitive_data.py +285 -0
- atlas/tests/test_mcp_auth_routes.py +341 -0
- atlas/tests/test_mcp_client_auth.py +331 -0
- atlas/tests/test_mcp_data_injection.py +270 -0
- atlas/tests/test_mcp_get_authorized_servers.py +95 -0
- atlas/tests/test_mcp_hot_reload.py +512 -0
- atlas/tests/test_mcp_image_content.py +424 -0
- atlas/tests/test_mcp_logging.py +172 -0
- atlas/tests/test_mcp_progress_updates.py +313 -0
- atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
- atlas/tests/test_mcp_prompts_server.py +39 -0
- atlas/tests/test_mcp_tool_result_parsing.py +296 -0
- atlas/tests/test_metrics_logger.py +56 -0
- atlas/tests/test_middleware_auth.py +379 -0
- atlas/tests/test_prompt_risk_and_acl.py +141 -0
- atlas/tests/test_rag_mcp_aggregator.py +204 -0
- atlas/tests/test_rag_mcp_service.py +224 -0
- atlas/tests/test_rate_limit_middleware.py +45 -0
- atlas/tests/test_routes_config_smoke.py +60 -0
- atlas/tests/test_routes_files_download_token.py +41 -0
- atlas/tests/test_routes_files_health.py +18 -0
- atlas/tests/test_runtime_imports.py +53 -0
- atlas/tests/test_sampling_integration.py +482 -0
- atlas/tests/test_security_admin_routes.py +61 -0
- atlas/tests/test_security_capability_tokens.py +65 -0
- atlas/tests/test_security_file_stats_scope.py +21 -0
- atlas/tests/test_security_header_injection.py +191 -0
- atlas/tests/test_security_headers_and_filename.py +63 -0
- atlas/tests/test_shared_session_repository.py +101 -0
- atlas/tests/test_system_prompt_loading.py +181 -0
- atlas/tests/test_token_storage.py +505 -0
- atlas/tests/test_tool_approval_config.py +93 -0
- atlas/tests/test_tool_approval_utils.py +356 -0
- atlas/tests/test_tool_authorization_group_filtering.py +223 -0
- atlas/tests/test_tool_details_in_config.py +108 -0
- atlas/tests/test_tool_planner.py +300 -0
- atlas/tests/test_unified_rag_service.py +398 -0
- atlas/tests/test_username_override_in_approval.py +258 -0
- atlas/tests/test_websocket_auth_header.py +168 -0
- atlas/version.py +6 -0
- atlas_chat-0.1.0.data/data/.env.example +253 -0
- atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
- atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
- atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
- atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
- atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
- atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
- atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
- atlas_chat-0.1.0.dist-info/METADATA +236 -0
- atlas_chat-0.1.0.dist-info/RECORD +250 -0
- atlas_chat-0.1.0.dist-info/WHEEL +5 -0
- atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
- atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mock S3 Storage Client using FastAPI TestClient.
|
|
3
|
+
|
|
4
|
+
This client provides the same interface as S3StorageClient but uses the
|
|
5
|
+
S3 mock server via TestClient, eliminating the need for Docker/MinIO in development.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
from atlas.core.log_sanitizer import sanitize_for_logging
|
|
17
|
+
from atlas.core.metrics_logger import log_metric
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MockS3StorageClient:
|
|
23
|
+
"""Mock S3 client using FastAPI TestClient for in-process testing."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
s3_bucket_name: str = None,
|
|
28
|
+
):
|
|
29
|
+
"""Initialize the mock S3 client."""
|
|
30
|
+
from atlas.modules.config import config_manager
|
|
31
|
+
|
|
32
|
+
self.bucket_name = s3_bucket_name or config_manager.app_settings.s3_bucket_name
|
|
33
|
+
self.endpoint_url = "in-process-mock" # For health check compatibility
|
|
34
|
+
self.region = "us-east-1" # For health check compatibility
|
|
35
|
+
self._client = None # Lazy initialization
|
|
36
|
+
|
|
37
|
+
logger.info(f"MockS3StorageClient initialized with bucket: {self.bucket_name}")
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def client(self):
|
|
41
|
+
"""Lazy-load the TestClient to avoid circular imports."""
|
|
42
|
+
if self._client is None:
|
|
43
|
+
import importlib.util
|
|
44
|
+
import sys
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
from fastapi.testclient import TestClient
|
|
48
|
+
|
|
49
|
+
# Get the S3 mock path
|
|
50
|
+
mock_path = Path(__file__).parent.parent.parent.parent / "mocks" / "s3-mock"
|
|
51
|
+
main_py_path = mock_path / "main.py"
|
|
52
|
+
|
|
53
|
+
# Add mock directory to sys.path for relative imports (storage, s3_xml)
|
|
54
|
+
mock_path_str = str(mock_path)
|
|
55
|
+
if mock_path_str not in sys.path:
|
|
56
|
+
sys.path.insert(0, mock_path_str)
|
|
57
|
+
|
|
58
|
+
# Import the main.py module explicitly with a unique name
|
|
59
|
+
spec = importlib.util.spec_from_file_location("s3_mock_app", main_py_path)
|
|
60
|
+
s3_mock_module = importlib.util.module_from_spec(spec)
|
|
61
|
+
|
|
62
|
+
# Add to sys.modules so relative imports work
|
|
63
|
+
sys.modules['s3_mock_app'] = s3_mock_module
|
|
64
|
+
spec.loader.exec_module(s3_mock_module)
|
|
65
|
+
|
|
66
|
+
self._client = TestClient(s3_mock_module.get_app())
|
|
67
|
+
logger.info("TestClient for S3 mock initialized")
|
|
68
|
+
|
|
69
|
+
return self._client
|
|
70
|
+
|
|
71
|
+
def _generate_s3_key(self, user_email: str, filename: str, source_type: str = "user") -> str:
|
|
72
|
+
"""Generate an S3-style key with user isolation."""
|
|
73
|
+
timestamp = int(time.time())
|
|
74
|
+
unique_id = str(uuid.uuid4())[:8]
|
|
75
|
+
safe_filename = filename.replace(" ", "_").replace("/", "_")
|
|
76
|
+
|
|
77
|
+
if source_type == "tool":
|
|
78
|
+
return f"users/{user_email}/generated/{timestamp}_{unique_id}_{safe_filename}"
|
|
79
|
+
else:
|
|
80
|
+
return f"users/{user_email}/uploads/{timestamp}_{unique_id}_{safe_filename}"
|
|
81
|
+
|
|
82
|
+
def _calculate_etag(self, content_bytes: bytes) -> str:
|
|
83
|
+
"""Calculate ETag for file content."""
|
|
84
|
+
return hashlib.md5(content_bytes, usedforsecurity=False).hexdigest()
|
|
85
|
+
|
|
86
|
+
async def upload_file(
|
|
87
|
+
self,
|
|
88
|
+
user_email: str,
|
|
89
|
+
filename: str,
|
|
90
|
+
content_base64: str,
|
|
91
|
+
content_type: str = "application/octet-stream",
|
|
92
|
+
tags: Optional[Dict[str, str]] = None,
|
|
93
|
+
source_type: str = "user"
|
|
94
|
+
) -> Dict[str, Any]:
|
|
95
|
+
"""
|
|
96
|
+
Upload a file to mock S3 storage.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
user_email: Email of the user uploading the file
|
|
100
|
+
filename: Original filename
|
|
101
|
+
content_base64: Base64 encoded file content
|
|
102
|
+
content_type: MIME type of the file
|
|
103
|
+
tags: Additional metadata tags
|
|
104
|
+
source_type: Type of file ("user" or "tool")
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Dictionary containing file metadata including the S3 key
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
# Decode base64 content
|
|
111
|
+
content_bytes = base64.b64decode(content_base64)
|
|
112
|
+
|
|
113
|
+
# Generate S3 key
|
|
114
|
+
s3_key = self._generate_s3_key(user_email, filename, source_type)
|
|
115
|
+
|
|
116
|
+
# Prepare tags
|
|
117
|
+
file_tags = tags or {}
|
|
118
|
+
file_tags["source"] = source_type
|
|
119
|
+
file_tags["user_email"] = user_email
|
|
120
|
+
file_tags["original_filename"] = filename
|
|
121
|
+
|
|
122
|
+
# Convert tags to query param format (URL-encode values for safety)
|
|
123
|
+
tag_param = "&".join([f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in file_tags.items()])
|
|
124
|
+
|
|
125
|
+
# Upload via TestClient
|
|
126
|
+
headers = {
|
|
127
|
+
"Content-Type": content_type,
|
|
128
|
+
"x-amz-meta-user_email": user_email,
|
|
129
|
+
"x-amz-meta-original_filename": filename,
|
|
130
|
+
"x-amz-meta-source_type": source_type
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
response = self.client.put(
|
|
134
|
+
f"/{self.bucket_name}/{s3_key}",
|
|
135
|
+
content=content_bytes,
|
|
136
|
+
headers=headers,
|
|
137
|
+
params={"tagging": tag_param}
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if response.status_code != 200:
|
|
141
|
+
raise Exception(f"Upload failed: {response.text}")
|
|
142
|
+
|
|
143
|
+
etag = response.headers.get("ETag", "").strip('"')
|
|
144
|
+
|
|
145
|
+
result = {
|
|
146
|
+
"key": s3_key,
|
|
147
|
+
"filename": filename,
|
|
148
|
+
"size": len(content_bytes),
|
|
149
|
+
"content_type": content_type,
|
|
150
|
+
"last_modified": None, # Mock doesn't need exact timestamp
|
|
151
|
+
"etag": etag,
|
|
152
|
+
"tags": file_tags,
|
|
153
|
+
"user_email": user_email
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
category = "generated" if "/generated/" in s3_key else ("uploads" if "/uploads/" in s3_key else "other")
|
|
157
|
+
logger.info(
|
|
158
|
+
"File uploaded successfully: category=%s, size=%d bytes, content_type=%s, user=%s",
|
|
159
|
+
category,
|
|
160
|
+
len(content_bytes),
|
|
161
|
+
sanitize_for_logging(content_type),
|
|
162
|
+
sanitize_for_logging(user_email),
|
|
163
|
+
)
|
|
164
|
+
logger.debug("Uploaded file key (sanitized): %s", sanitize_for_logging(s3_key))
|
|
165
|
+
|
|
166
|
+
log_metric("file_stored", user_email, file_size=len(content_bytes), content_type=content_type, category=category)
|
|
167
|
+
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.error(f"Error uploading file to mock S3: {str(e)}")
|
|
172
|
+
raise
|
|
173
|
+
|
|
174
|
+
async def get_file(self, user_email: str, file_key: str) -> Dict[str, Any]:
|
|
175
|
+
"""
|
|
176
|
+
Get a file from mock S3 storage.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
user_email: Email of the user requesting the file
|
|
180
|
+
file_key: S3 key of the file to retrieve
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Dictionary containing file data and metadata
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
# Verify user has access to this file
|
|
187
|
+
if not file_key.startswith(f"users/{user_email}/"):
|
|
188
|
+
logger.warning(f"Access denied: {sanitize_for_logging(user_email)} attempted to access {sanitize_for_logging(file_key)}")
|
|
189
|
+
raise Exception("Access denied to file")
|
|
190
|
+
|
|
191
|
+
# Get object via TestClient
|
|
192
|
+
response = self.client.get(f"/{self.bucket_name}/{file_key}")
|
|
193
|
+
|
|
194
|
+
if response.status_code == 404:
|
|
195
|
+
logger.warning(f"File not found: {sanitize_for_logging(file_key)} for user {sanitize_for_logging(user_email)}")
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
if response.status_code != 200:
|
|
199
|
+
raise Exception(f"Get failed: {response.text}")
|
|
200
|
+
|
|
201
|
+
# Read file content
|
|
202
|
+
content_bytes = response.content
|
|
203
|
+
content_base64 = base64.b64encode(content_bytes).decode()
|
|
204
|
+
|
|
205
|
+
# Get tags
|
|
206
|
+
tags_response = self.client.get(f"/{self.bucket_name}/{file_key}", params={"tagging": ""})
|
|
207
|
+
tags = {}
|
|
208
|
+
if tags_response.status_code == 200:
|
|
209
|
+
# Parse XML tags (simplified - just extract from response)
|
|
210
|
+
import xml.etree.ElementTree as ET
|
|
211
|
+
try:
|
|
212
|
+
root = ET.fromstring(tags_response.text)
|
|
213
|
+
for tag_elem in root.findall(".//Tag"):
|
|
214
|
+
key_elem = tag_elem.find("Key")
|
|
215
|
+
value_elem = tag_elem.find("Value")
|
|
216
|
+
if key_elem is not None and value_elem is not None:
|
|
217
|
+
tags[key_elem.text] = value_elem.text
|
|
218
|
+
except ET.ParseError:
|
|
219
|
+
# Failed to parse tags XML; tags will be left empty. This is non-fatal as tags are optional.
|
|
220
|
+
logger.warning(f"Failed to parse tags XML for file {sanitize_for_logging(file_key)}", exc_info=True)
|
|
221
|
+
|
|
222
|
+
# Extract filename from metadata headers
|
|
223
|
+
filename = response.headers.get("x-amz-meta-original_filename", file_key.split('/')[-1])
|
|
224
|
+
|
|
225
|
+
result = {
|
|
226
|
+
"key": file_key,
|
|
227
|
+
"filename": filename,
|
|
228
|
+
"content_base64": content_base64,
|
|
229
|
+
"content_type": response.headers.get("Content-Type", "application/octet-stream"),
|
|
230
|
+
"size": len(content_bytes),
|
|
231
|
+
"last_modified": None,
|
|
232
|
+
"etag": response.headers.get("ETag", "").strip('"'),
|
|
233
|
+
"tags": tags
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
category = "generated" if "/generated/" in file_key else ("uploads" if "/uploads/" in file_key else "other")
|
|
237
|
+
logger.info(
|
|
238
|
+
"File retrieved successfully: category=%s, size=%d bytes, content_type=%s, user=%s",
|
|
239
|
+
category,
|
|
240
|
+
len(content_bytes),
|
|
241
|
+
sanitize_for_logging(response.headers.get("Content-Type", "application/octet-stream")),
|
|
242
|
+
sanitize_for_logging(user_email),
|
|
243
|
+
)
|
|
244
|
+
logger.debug("Retrieved file key (sanitized): %s", sanitize_for_logging(file_key))
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.error(f"Error getting file from mock S3: {str(e)}")
|
|
249
|
+
raise
|
|
250
|
+
|
|
251
|
+
async def list_files(
|
|
252
|
+
self,
|
|
253
|
+
user_email: str,
|
|
254
|
+
file_type: Optional[str] = None,
|
|
255
|
+
limit: int = 100
|
|
256
|
+
) -> List[Dict[str, Any]]:
|
|
257
|
+
"""
|
|
258
|
+
List files for a user.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
user_email: Email of the user
|
|
262
|
+
file_type: Optional filter by file type ("user" or "tool")
|
|
263
|
+
limit: Maximum number of files to return
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
List of file metadata dictionaries
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
# Determine prefix
|
|
270
|
+
prefix = f"users/{user_email}/"
|
|
271
|
+
if file_type == "tool":
|
|
272
|
+
prefix = f"users/{user_email}/generated/"
|
|
273
|
+
elif file_type == "user":
|
|
274
|
+
prefix = f"users/{user_email}/uploads/"
|
|
275
|
+
|
|
276
|
+
# List via TestClient
|
|
277
|
+
response = self.client.get(
|
|
278
|
+
f"/{self.bucket_name}",
|
|
279
|
+
params={"list-type": "2", "prefix": prefix, "max-keys": str(limit)}
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if response.status_code != 200:
|
|
283
|
+
raise Exception(f"List failed: {response.text}")
|
|
284
|
+
|
|
285
|
+
# Parse XML response
|
|
286
|
+
import xml.etree.ElementTree as ET
|
|
287
|
+
root = ET.fromstring(response.text)
|
|
288
|
+
ns = {'s3': 'http://s3.amazonaws.com/doc/2006-03-01/'}
|
|
289
|
+
contents = root.findall(".//s3:Contents", ns) or root.findall(".//Contents")
|
|
290
|
+
|
|
291
|
+
files = []
|
|
292
|
+
for content in contents:
|
|
293
|
+
key_elem = content.find("s3:Key", ns)
|
|
294
|
+
if key_elem is None:
|
|
295
|
+
key_elem = content.find("Key")
|
|
296
|
+
size_elem = content.find("s3:Size", ns) or content.find("Size")
|
|
297
|
+
etag_elem = content.find("s3:ETag", ns) or content.find("ETag")
|
|
298
|
+
|
|
299
|
+
if key_elem is None:
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
key = key_elem.text
|
|
303
|
+
size = int(size_elem.text) if size_elem is not None else 0
|
|
304
|
+
etag = etag_elem.text.strip('"') if etag_elem is not None else ""
|
|
305
|
+
|
|
306
|
+
# Get metadata via HEAD
|
|
307
|
+
head_response = self.client.head(f"/{self.bucket_name}/{key}")
|
|
308
|
+
filename = head_response.headers.get("x-amz-meta-original_filename", key.split('/')[-1])
|
|
309
|
+
content_type = head_response.headers.get("Content-Type", "application/octet-stream")
|
|
310
|
+
|
|
311
|
+
files.append({
|
|
312
|
+
"key": key,
|
|
313
|
+
"filename": filename,
|
|
314
|
+
"size": size,
|
|
315
|
+
"content_type": content_type,
|
|
316
|
+
"last_modified": None,
|
|
317
|
+
"etag": etag,
|
|
318
|
+
"tags": {},
|
|
319
|
+
"user_email": user_email
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
logger.info(f"Listed {len(files)} files for user {sanitize_for_logging(user_email)}")
|
|
323
|
+
return files
|
|
324
|
+
|
|
325
|
+
except Exception as e:
|
|
326
|
+
logger.error(f"Error listing files from mock S3: {str(e)}")
|
|
327
|
+
raise
|
|
328
|
+
|
|
329
|
+
async def delete_file(self, user_email: str, file_key: str) -> bool:
|
|
330
|
+
"""
|
|
331
|
+
Delete a file from mock S3 storage.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
user_email: Email of the user deleting the file
|
|
335
|
+
file_key: S3 key of the file to delete
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
True if deletion was successful
|
|
339
|
+
"""
|
|
340
|
+
try:
|
|
341
|
+
# Verify user has access to this file
|
|
342
|
+
if not file_key.startswith(f"users/{user_email}/"):
|
|
343
|
+
logger.warning(f"Access denied for deletion: {sanitize_for_logging(user_email)} attempted to delete {sanitize_for_logging(file_key)}")
|
|
344
|
+
raise Exception("Access denied to delete file")
|
|
345
|
+
|
|
346
|
+
# Delete via TestClient
|
|
347
|
+
response = self.client.delete(f"/{self.bucket_name}/{file_key}")
|
|
348
|
+
|
|
349
|
+
if response.status_code == 404:
|
|
350
|
+
logger.warning(f"File not found for deletion: {sanitize_for_logging(file_key)} for user {sanitize_for_logging(user_email)}")
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
if response.status_code != 204:
|
|
354
|
+
raise Exception(f"Delete failed: {response.text}")
|
|
355
|
+
|
|
356
|
+
logger.info(f"File deleted successfully: {sanitize_for_logging(file_key)} for user {sanitize_for_logging(user_email)}")
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
except Exception as e:
|
|
360
|
+
logger.error(f"Error deleting file from mock S3: {str(e)}")
|
|
361
|
+
raise
|
|
362
|
+
|
|
363
|
+
async def get_user_stats(self, user_email: str) -> Dict[str, Any]:
|
|
364
|
+
"""
|
|
365
|
+
Get file statistics for a user.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
user_email: Email of the user
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Dictionary containing file statistics
|
|
372
|
+
"""
|
|
373
|
+
try:
|
|
374
|
+
# List all user files
|
|
375
|
+
files = await self.list_files(user_email, limit=1000)
|
|
376
|
+
|
|
377
|
+
total_size = 0
|
|
378
|
+
upload_count = 0
|
|
379
|
+
generated_count = 0
|
|
380
|
+
|
|
381
|
+
for file_data in files:
|
|
382
|
+
total_size += file_data['size']
|
|
383
|
+
|
|
384
|
+
# Determine type from key path
|
|
385
|
+
if "/generated/" in file_data['key']:
|
|
386
|
+
generated_count += 1
|
|
387
|
+
else:
|
|
388
|
+
upload_count += 1
|
|
389
|
+
|
|
390
|
+
result = {
|
|
391
|
+
"total_files": len(files),
|
|
392
|
+
"total_size": total_size,
|
|
393
|
+
"upload_count": upload_count,
|
|
394
|
+
"generated_count": generated_count
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
logger.info(f"Got file stats for user {sanitize_for_logging(user_email)}: {result}")
|
|
398
|
+
return result
|
|
399
|
+
|
|
400
|
+
except Exception as e:
|
|
401
|
+
logger.error(f"Error getting user stats from mock S3: {str(e)}")
|
|
402
|
+
raise
|