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.
Files changed (250) hide show
  1. atlas/__init__.py +40 -0
  2. atlas/application/__init__.py +7 -0
  3. atlas/application/chat/__init__.py +7 -0
  4. atlas/application/chat/agent/__init__.py +10 -0
  5. atlas/application/chat/agent/act_loop.py +179 -0
  6. atlas/application/chat/agent/factory.py +142 -0
  7. atlas/application/chat/agent/protocols.py +46 -0
  8. atlas/application/chat/agent/react_loop.py +338 -0
  9. atlas/application/chat/agent/think_act_loop.py +171 -0
  10. atlas/application/chat/approval_manager.py +151 -0
  11. atlas/application/chat/elicitation_manager.py +191 -0
  12. atlas/application/chat/events/__init__.py +1 -0
  13. atlas/application/chat/events/agent_event_relay.py +112 -0
  14. atlas/application/chat/modes/__init__.py +1 -0
  15. atlas/application/chat/modes/agent.py +125 -0
  16. atlas/application/chat/modes/plain.py +74 -0
  17. atlas/application/chat/modes/rag.py +81 -0
  18. atlas/application/chat/modes/tools.py +179 -0
  19. atlas/application/chat/orchestrator.py +213 -0
  20. atlas/application/chat/policies/__init__.py +1 -0
  21. atlas/application/chat/policies/tool_authorization.py +99 -0
  22. atlas/application/chat/preprocessors/__init__.py +1 -0
  23. atlas/application/chat/preprocessors/message_builder.py +92 -0
  24. atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
  25. atlas/application/chat/service.py +454 -0
  26. atlas/application/chat/utilities/__init__.py +6 -0
  27. atlas/application/chat/utilities/error_handler.py +367 -0
  28. atlas/application/chat/utilities/event_notifier.py +546 -0
  29. atlas/application/chat/utilities/file_processor.py +613 -0
  30. atlas/application/chat/utilities/tool_executor.py +789 -0
  31. atlas/atlas_chat_cli.py +347 -0
  32. atlas/atlas_client.py +238 -0
  33. atlas/core/__init__.py +0 -0
  34. atlas/core/auth.py +205 -0
  35. atlas/core/authorization_manager.py +27 -0
  36. atlas/core/capabilities.py +123 -0
  37. atlas/core/compliance.py +215 -0
  38. atlas/core/domain_whitelist.py +147 -0
  39. atlas/core/domain_whitelist_middleware.py +82 -0
  40. atlas/core/http_client.py +28 -0
  41. atlas/core/log_sanitizer.py +102 -0
  42. atlas/core/metrics_logger.py +59 -0
  43. atlas/core/middleware.py +131 -0
  44. atlas/core/otel_config.py +242 -0
  45. atlas/core/prompt_risk.py +200 -0
  46. atlas/core/rate_limit.py +0 -0
  47. atlas/core/rate_limit_middleware.py +64 -0
  48. atlas/core/security_headers_middleware.py +51 -0
  49. atlas/domain/__init__.py +37 -0
  50. atlas/domain/chat/__init__.py +1 -0
  51. atlas/domain/chat/dtos.py +85 -0
  52. atlas/domain/errors.py +96 -0
  53. atlas/domain/messages/__init__.py +12 -0
  54. atlas/domain/messages/models.py +160 -0
  55. atlas/domain/rag_mcp_service.py +664 -0
  56. atlas/domain/sessions/__init__.py +7 -0
  57. atlas/domain/sessions/models.py +36 -0
  58. atlas/domain/unified_rag_service.py +371 -0
  59. atlas/infrastructure/__init__.py +10 -0
  60. atlas/infrastructure/app_factory.py +135 -0
  61. atlas/infrastructure/events/__init__.py +1 -0
  62. atlas/infrastructure/events/cli_event_publisher.py +140 -0
  63. atlas/infrastructure/events/websocket_publisher.py +140 -0
  64. atlas/infrastructure/sessions/in_memory_repository.py +56 -0
  65. atlas/infrastructure/transport/__init__.py +7 -0
  66. atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
  67. atlas/init_cli.py +226 -0
  68. atlas/interfaces/__init__.py +15 -0
  69. atlas/interfaces/events.py +134 -0
  70. atlas/interfaces/llm.py +54 -0
  71. atlas/interfaces/rag.py +40 -0
  72. atlas/interfaces/sessions.py +75 -0
  73. atlas/interfaces/tools.py +57 -0
  74. atlas/interfaces/transport.py +24 -0
  75. atlas/main.py +564 -0
  76. atlas/mcp/api_key_demo/README.md +76 -0
  77. atlas/mcp/api_key_demo/main.py +172 -0
  78. atlas/mcp/api_key_demo/run.sh +56 -0
  79. atlas/mcp/basictable/main.py +147 -0
  80. atlas/mcp/calculator/main.py +149 -0
  81. atlas/mcp/code-executor/execution_engine.py +98 -0
  82. atlas/mcp/code-executor/execution_environment.py +95 -0
  83. atlas/mcp/code-executor/main.py +528 -0
  84. atlas/mcp/code-executor/result_processing.py +276 -0
  85. atlas/mcp/code-executor/script_generation.py +195 -0
  86. atlas/mcp/code-executor/security_checker.py +140 -0
  87. atlas/mcp/corporate_cars/main.py +437 -0
  88. atlas/mcp/csv_reporter/main.py +545 -0
  89. atlas/mcp/duckduckgo/main.py +182 -0
  90. atlas/mcp/elicitation_demo/README.md +171 -0
  91. atlas/mcp/elicitation_demo/main.py +262 -0
  92. atlas/mcp/env-demo/README.md +158 -0
  93. atlas/mcp/env-demo/main.py +199 -0
  94. atlas/mcp/file_size_test/main.py +284 -0
  95. atlas/mcp/filesystem/main.py +348 -0
  96. atlas/mcp/image_demo/main.py +113 -0
  97. atlas/mcp/image_demo/requirements.txt +4 -0
  98. atlas/mcp/logging_demo/README.md +72 -0
  99. atlas/mcp/logging_demo/main.py +103 -0
  100. atlas/mcp/many_tools_demo/main.py +50 -0
  101. atlas/mcp/order_database/__init__.py +0 -0
  102. atlas/mcp/order_database/main.py +369 -0
  103. atlas/mcp/order_database/signal_data.csv +1001 -0
  104. atlas/mcp/pdfbasic/main.py +394 -0
  105. atlas/mcp/pptx_generator/main.py +760 -0
  106. atlas/mcp/pptx_generator/requirements.txt +13 -0
  107. atlas/mcp/pptx_generator/run_test.sh +1 -0
  108. atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
  109. atlas/mcp/progress_demo/main.py +167 -0
  110. atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
  111. atlas/mcp/progress_updates_demo/README.md +120 -0
  112. atlas/mcp/progress_updates_demo/main.py +497 -0
  113. atlas/mcp/prompts/main.py +222 -0
  114. atlas/mcp/public_demo/main.py +189 -0
  115. atlas/mcp/sampling_demo/README.md +169 -0
  116. atlas/mcp/sampling_demo/main.py +234 -0
  117. atlas/mcp/thinking/main.py +77 -0
  118. atlas/mcp/tool_planner/main.py +240 -0
  119. atlas/mcp/ui-demo/badmesh.png +0 -0
  120. atlas/mcp/ui-demo/main.py +383 -0
  121. atlas/mcp/ui-demo/templates/button_demo.html +32 -0
  122. atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
  123. atlas/mcp/ui-demo/templates/form_demo.html +28 -0
  124. atlas/mcp/username-override-demo/README.md +320 -0
  125. atlas/mcp/username-override-demo/main.py +308 -0
  126. atlas/modules/__init__.py +0 -0
  127. atlas/modules/config/__init__.py +34 -0
  128. atlas/modules/config/cli.py +231 -0
  129. atlas/modules/config/config_manager.py +1096 -0
  130. atlas/modules/file_storage/__init__.py +22 -0
  131. atlas/modules/file_storage/cli.py +330 -0
  132. atlas/modules/file_storage/content_extractor.py +290 -0
  133. atlas/modules/file_storage/manager.py +295 -0
  134. atlas/modules/file_storage/mock_s3_client.py +402 -0
  135. atlas/modules/file_storage/s3_client.py +417 -0
  136. atlas/modules/llm/__init__.py +19 -0
  137. atlas/modules/llm/caller.py +287 -0
  138. atlas/modules/llm/litellm_caller.py +675 -0
  139. atlas/modules/llm/models.py +19 -0
  140. atlas/modules/mcp_tools/__init__.py +17 -0
  141. atlas/modules/mcp_tools/client.py +2123 -0
  142. atlas/modules/mcp_tools/token_storage.py +556 -0
  143. atlas/modules/prompts/prompt_provider.py +130 -0
  144. atlas/modules/rag/__init__.py +24 -0
  145. atlas/modules/rag/atlas_rag_client.py +336 -0
  146. atlas/modules/rag/client.py +129 -0
  147. atlas/routes/admin_routes.py +865 -0
  148. atlas/routes/config_routes.py +484 -0
  149. atlas/routes/feedback_routes.py +361 -0
  150. atlas/routes/files_routes.py +274 -0
  151. atlas/routes/health_routes.py +40 -0
  152. atlas/routes/mcp_auth_routes.py +223 -0
  153. atlas/server_cli.py +164 -0
  154. atlas/tests/conftest.py +20 -0
  155. atlas/tests/integration/test_mcp_auth_integration.py +152 -0
  156. atlas/tests/manual_test_sampling.py +87 -0
  157. atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
  158. atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
  159. atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
  160. atlas/tests/test_agent_roa.py +135 -0
  161. atlas/tests/test_app_factory_smoke.py +47 -0
  162. atlas/tests/test_approval_manager.py +439 -0
  163. atlas/tests/test_atlas_client.py +188 -0
  164. atlas/tests/test_atlas_rag_client.py +447 -0
  165. atlas/tests/test_atlas_rag_integration.py +224 -0
  166. atlas/tests/test_attach_file_flow.py +287 -0
  167. atlas/tests/test_auth_utils.py +165 -0
  168. atlas/tests/test_backend_public_url.py +185 -0
  169. atlas/tests/test_banner_logging.py +287 -0
  170. atlas/tests/test_capability_tokens_and_injection.py +203 -0
  171. atlas/tests/test_compliance_level.py +54 -0
  172. atlas/tests/test_compliance_manager.py +253 -0
  173. atlas/tests/test_config_manager.py +617 -0
  174. atlas/tests/test_config_manager_paths.py +12 -0
  175. atlas/tests/test_core_auth.py +18 -0
  176. atlas/tests/test_core_utils.py +190 -0
  177. atlas/tests/test_docker_env_sync.py +202 -0
  178. atlas/tests/test_domain_errors.py +329 -0
  179. atlas/tests/test_domain_whitelist.py +359 -0
  180. atlas/tests/test_elicitation_manager.py +408 -0
  181. atlas/tests/test_elicitation_routing.py +296 -0
  182. atlas/tests/test_env_demo_server.py +88 -0
  183. atlas/tests/test_error_classification.py +113 -0
  184. atlas/tests/test_error_flow_integration.py +116 -0
  185. atlas/tests/test_feedback_routes.py +333 -0
  186. atlas/tests/test_file_content_extraction.py +1134 -0
  187. atlas/tests/test_file_extraction_routes.py +158 -0
  188. atlas/tests/test_file_library.py +107 -0
  189. atlas/tests/test_file_manager_unit.py +18 -0
  190. atlas/tests/test_health_route.py +49 -0
  191. atlas/tests/test_http_client_stub.py +8 -0
  192. atlas/tests/test_imports_smoke.py +30 -0
  193. atlas/tests/test_interfaces_llm_response.py +9 -0
  194. atlas/tests/test_issue_access_denied_fix.py +136 -0
  195. atlas/tests/test_llm_env_expansion.py +836 -0
  196. atlas/tests/test_log_level_sensitive_data.py +285 -0
  197. atlas/tests/test_mcp_auth_routes.py +341 -0
  198. atlas/tests/test_mcp_client_auth.py +331 -0
  199. atlas/tests/test_mcp_data_injection.py +270 -0
  200. atlas/tests/test_mcp_get_authorized_servers.py +95 -0
  201. atlas/tests/test_mcp_hot_reload.py +512 -0
  202. atlas/tests/test_mcp_image_content.py +424 -0
  203. atlas/tests/test_mcp_logging.py +172 -0
  204. atlas/tests/test_mcp_progress_updates.py +313 -0
  205. atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
  206. atlas/tests/test_mcp_prompts_server.py +39 -0
  207. atlas/tests/test_mcp_tool_result_parsing.py +296 -0
  208. atlas/tests/test_metrics_logger.py +56 -0
  209. atlas/tests/test_middleware_auth.py +379 -0
  210. atlas/tests/test_prompt_risk_and_acl.py +141 -0
  211. atlas/tests/test_rag_mcp_aggregator.py +204 -0
  212. atlas/tests/test_rag_mcp_service.py +224 -0
  213. atlas/tests/test_rate_limit_middleware.py +45 -0
  214. atlas/tests/test_routes_config_smoke.py +60 -0
  215. atlas/tests/test_routes_files_download_token.py +41 -0
  216. atlas/tests/test_routes_files_health.py +18 -0
  217. atlas/tests/test_runtime_imports.py +53 -0
  218. atlas/tests/test_sampling_integration.py +482 -0
  219. atlas/tests/test_security_admin_routes.py +61 -0
  220. atlas/tests/test_security_capability_tokens.py +65 -0
  221. atlas/tests/test_security_file_stats_scope.py +21 -0
  222. atlas/tests/test_security_header_injection.py +191 -0
  223. atlas/tests/test_security_headers_and_filename.py +63 -0
  224. atlas/tests/test_shared_session_repository.py +101 -0
  225. atlas/tests/test_system_prompt_loading.py +181 -0
  226. atlas/tests/test_token_storage.py +505 -0
  227. atlas/tests/test_tool_approval_config.py +93 -0
  228. atlas/tests/test_tool_approval_utils.py +356 -0
  229. atlas/tests/test_tool_authorization_group_filtering.py +223 -0
  230. atlas/tests/test_tool_details_in_config.py +108 -0
  231. atlas/tests/test_tool_planner.py +300 -0
  232. atlas/tests/test_unified_rag_service.py +398 -0
  233. atlas/tests/test_username_override_in_approval.py +258 -0
  234. atlas/tests/test_websocket_auth_header.py +168 -0
  235. atlas/version.py +6 -0
  236. atlas_chat-0.1.0.data/data/.env.example +253 -0
  237. atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
  238. atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
  239. atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
  240. atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
  241. atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
  242. atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
  243. atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
  244. atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
  245. atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
  246. atlas_chat-0.1.0.dist-info/METADATA +236 -0
  247. atlas_chat-0.1.0.dist-info/RECORD +250 -0
  248. atlas_chat-0.1.0.dist-info/WHEEL +5 -0
  249. atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
  250. 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