chuk-artifacts 0.1.0__py3-none-any.whl → 0.1.2__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.
@@ -0,0 +1,367 @@
1
+ # -*- coding: utf-8 -*-
2
+ # chuk_artifacts/session_operations.py (LOGGING FIX)
3
+ """
4
+ Session-based file operations with strict session isolation.
5
+ FIXED: Resolved logging conflict with 'filename' parameter.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid, hashlib, json, logging
11
+ from datetime import datetime
12
+ from typing import Any, Dict, Optional, Union, List
13
+
14
+ from .base import BaseOperations
15
+ from .exceptions import (
16
+ ArtifactStoreError, ArtifactNotFoundError, ArtifactExpiredError,
17
+ ProviderError, SessionError
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _ANON_PREFIX = "anon"
23
+ _DEFAULT_TTL = 900
24
+
25
+
26
+ class SessionOperations(BaseOperations):
27
+ """Session-based file operations with strict session isolation."""
28
+
29
+ async def move_file(
30
+ self,
31
+ artifact_id: str,
32
+ *,
33
+ new_filename: str = None,
34
+ new_session_id: str = None,
35
+ new_meta: Dict[str, Any] = None
36
+ ) -> Dict[str, Any]:
37
+ """
38
+ Move a file within the SAME session or rename it.
39
+ """
40
+ self._check_closed()
41
+
42
+ try:
43
+ # Get current metadata
44
+ record = await self._get_record(artifact_id)
45
+ current_session = record.get("session_id")
46
+
47
+ # STRICT SECURITY: Block ALL cross-session moves
48
+ if new_session_id and new_session_id != current_session:
49
+ raise ArtifactStoreError(
50
+ f"Cross-session moves are not permitted for security reasons. "
51
+ f"Artifact {artifact_id} belongs to session '{current_session}', "
52
+ f"cannot move to session '{new_session_id}'. Use copy operations within "
53
+ f"the same session only."
54
+ )
55
+
56
+ # Update metadata fields (only filename and meta allowed)
57
+ updates = {}
58
+ if new_filename:
59
+ updates["filename"] = new_filename
60
+ if new_meta:
61
+ existing_meta = record.get("meta", {})
62
+ existing_meta.update(new_meta)
63
+ updates["new_meta"] = existing_meta
64
+ updates["merge"] = True
65
+
66
+ if updates:
67
+ # Use the metadata operations to update
68
+ from .metadata import MetadataOperations
69
+ metadata_ops = MetadataOperations(self._artifact_store)
70
+ return await metadata_ops.update_metadata(artifact_id, **updates)
71
+
72
+ return record
73
+
74
+ except (ArtifactNotFoundError, ArtifactExpiredError):
75
+ raise
76
+ except Exception as e:
77
+ logger.error(
78
+ "File move failed for artifact %s: %s",
79
+ artifact_id,
80
+ str(e),
81
+ extra={
82
+ "artifact_id": artifact_id,
83
+ "new_file_name": new_filename, # FIXED: Renamed from 'new_filename'
84
+ "new_session_id": new_session_id,
85
+ "operation": "move_file"
86
+ }
87
+ )
88
+ raise ProviderError(f"Move operation failed: {e}") from e
89
+
90
+ async def copy_file(
91
+ self,
92
+ artifact_id: str,
93
+ *,
94
+ new_filename: str = None,
95
+ target_session_id: str = None,
96
+ new_meta: Dict[str, Any] = None,
97
+ summary: str = None
98
+ ) -> str:
99
+ """
100
+ Copy a file WITHIN THE SAME SESSION only.
101
+ """
102
+ self._check_closed()
103
+
104
+ try:
105
+ # Get original metadata first to check session
106
+ original_meta = await self._get_record(artifact_id)
107
+ original_session = original_meta.get("session_id")
108
+
109
+ # STRICT SECURITY: Block ALL cross-session copies
110
+ if target_session_id and target_session_id != original_session:
111
+ raise ArtifactStoreError(
112
+ f"Cross-session copies are not permitted for security reasons. "
113
+ f"Artifact {artifact_id} belongs to session '{original_session}', "
114
+ f"cannot copy to session '{target_session_id}'. Files can only be "
115
+ f"copied within the same session."
116
+ )
117
+
118
+ # Ensure target session is the same as source
119
+ copy_session = original_session # Always use source session
120
+
121
+ # Get original data
122
+ original_data = await self._retrieve_data(artifact_id)
123
+
124
+ # Prepare copy metadata
125
+ copy_filename = new_filename or (
126
+ (original_meta.get("filename", "file") or "file") + "_copy"
127
+ )
128
+ copy_summary = summary or f"Copy of {original_meta.get('summary', 'artifact')}"
129
+
130
+ # Merge metadata
131
+ copy_meta = {**original_meta.get("meta", {})}
132
+ if new_meta:
133
+ copy_meta.update(new_meta)
134
+
135
+ # Add copy tracking
136
+ copy_meta["copied_from"] = artifact_id
137
+ copy_meta["copy_timestamp"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
138
+ copy_meta["copy_within_session"] = original_session
139
+
140
+ # Store the copy using core operations
141
+ from .core import CoreStorageOperations
142
+ core_ops = CoreStorageOperations(self._artifact_store)
143
+
144
+ new_artifact_id = await core_ops.store(
145
+ data=original_data,
146
+ mime=original_meta["mime"],
147
+ summary=copy_summary,
148
+ filename=copy_filename,
149
+ session_id=copy_session, # Always same session
150
+ meta=copy_meta
151
+ )
152
+
153
+ logger.info(
154
+ "File copied within session: %s -> %s",
155
+ artifact_id,
156
+ new_artifact_id,
157
+ extra={
158
+ "source_artifact_id": artifact_id,
159
+ "new_artifact_id": new_artifact_id,
160
+ "session": copy_session,
161
+ "security_level": "same_session_only",
162
+ "operation": "copy_file"
163
+ }
164
+ )
165
+
166
+ return new_artifact_id
167
+
168
+ except (ArtifactNotFoundError, ArtifactExpiredError):
169
+ raise
170
+ except Exception as e:
171
+ logger.error(
172
+ "File copy failed for artifact %s: %s",
173
+ artifact_id,
174
+ str(e),
175
+ extra={
176
+ "artifact_id": artifact_id,
177
+ "new_file_name": new_filename, # FIXED: Renamed from 'new_filename'
178
+ "target_session_id": target_session_id,
179
+ "operation": "copy_file"
180
+ }
181
+ )
182
+ raise ProviderError(f"Copy operation failed: {e}") from e
183
+
184
+ async def read_file(
185
+ self,
186
+ artifact_id: str,
187
+ *,
188
+ encoding: str = "utf-8",
189
+ as_text: bool = True
190
+ ) -> Union[str, bytes]:
191
+ """
192
+ Read file content directly.
193
+ """
194
+ self._check_closed()
195
+
196
+ try:
197
+ data = await self._retrieve_data(artifact_id)
198
+
199
+ if as_text:
200
+ try:
201
+ return data.decode(encoding)
202
+ except UnicodeDecodeError as e:
203
+ logger.warning(f"Failed to decode with {encoding}: {e}")
204
+ raise ProviderError(f"Cannot decode file as text with {encoding} encoding") from e
205
+ else:
206
+ return data
207
+
208
+ except (ArtifactNotFoundError, ArtifactExpiredError):
209
+ raise
210
+ except Exception as e:
211
+ logger.error(
212
+ "File read failed for artifact %s: %s",
213
+ artifact_id,
214
+ str(e),
215
+ extra={"artifact_id": artifact_id, "operation": "read_file"}
216
+ )
217
+ raise ProviderError(f"Read operation failed: {e}") from e
218
+
219
+ async def write_file(
220
+ self,
221
+ content: Union[str, bytes],
222
+ *,
223
+ filename: str,
224
+ mime: str = "text/plain",
225
+ summary: str = "",
226
+ session_id: str = None,
227
+ meta: Dict[str, Any] = None,
228
+ encoding: str = "utf-8",
229
+ overwrite_artifact_id: str = None
230
+ ) -> str:
231
+ """
232
+ Write content to a new file or overwrite existing WITHIN THE SAME SESSION.
233
+ """
234
+ self._check_closed()
235
+
236
+ try:
237
+ # Convert content to bytes if needed
238
+ if isinstance(content, str):
239
+ data = content.encode(encoding)
240
+ else:
241
+ data = content
242
+
243
+ # Handle overwrite case with session security check
244
+ if overwrite_artifact_id:
245
+ try:
246
+ existing_meta = await self._get_record(overwrite_artifact_id)
247
+ existing_session = existing_meta.get("session_id")
248
+
249
+ # STRICT SECURITY: Can only overwrite files in the same session
250
+ if session_id and session_id != existing_session:
251
+ raise ArtifactStoreError(
252
+ f"Cross-session overwrite not permitted. Artifact {overwrite_artifact_id} "
253
+ f"belongs to session '{existing_session}', cannot overwrite from "
254
+ f"session '{session_id}'. Overwrite operations must be within the same session."
255
+ )
256
+
257
+ # Use the existing session if no session_id provided
258
+ session_id = session_id or existing_session
259
+
260
+ # Delete old version (within same session)
261
+ from .metadata import MetadataOperations
262
+ metadata_ops = MetadataOperations(self._artifact_store)
263
+ await metadata_ops.delete(overwrite_artifact_id)
264
+
265
+ except (ArtifactNotFoundError, ArtifactExpiredError):
266
+ pass # Original doesn't exist, proceed with new creation
267
+
268
+ # Store new content using core operations
269
+ from .core import CoreStorageOperations
270
+ core_ops = CoreStorageOperations(self._artifact_store)
271
+
272
+ write_meta = {**(meta or {})}
273
+ if overwrite_artifact_id:
274
+ write_meta["overwrote"] = overwrite_artifact_id
275
+ write_meta["overwrite_timestamp"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
276
+ write_meta["overwrite_within_session"] = session_id
277
+
278
+ artifact_id = await core_ops.store(
279
+ data=data,
280
+ mime=mime,
281
+ summary=summary or f"Written file: {filename}",
282
+ filename=filename,
283
+ session_id=session_id,
284
+ meta=write_meta
285
+ )
286
+
287
+ # FIXED: Use separate variables for logging to avoid 'filename' conflict
288
+ logger.info(
289
+ "File written successfully: %s (artifact_id: %s)",
290
+ filename,
291
+ artifact_id,
292
+ extra={
293
+ "artifact_id": artifact_id,
294
+ "file_name": filename, # FIXED: Renamed from 'filename'
295
+ "bytes": len(data),
296
+ "overwrite": bool(overwrite_artifact_id),
297
+ "session_id": session_id,
298
+ "security_level": "session_isolated",
299
+ "operation": "write_file"
300
+ }
301
+ )
302
+
303
+ return artifact_id
304
+
305
+ except Exception as e:
306
+ # FIXED: Use separate variables for logging to avoid 'filename' conflict
307
+ logger.error(
308
+ "File write failed for %s: %s",
309
+ filename,
310
+ str(e),
311
+ extra={
312
+ "file_name": filename, # FIXED: Renamed from 'filename'
313
+ "overwrite_artifact_id": overwrite_artifact_id,
314
+ "session_id": session_id,
315
+ "operation": "write_file"
316
+ }
317
+ )
318
+ raise ProviderError(f"Write operation failed: {e}") from e
319
+
320
+ async def get_directory_contents(
321
+ self,
322
+ session_id: str,
323
+ directory_prefix: str = "",
324
+ limit: int = 100
325
+ ) -> List[Dict[str, Any]]:
326
+ """
327
+ List files in a directory-like structure within a session.
328
+ """
329
+ try:
330
+ from .metadata import MetadataOperations
331
+ metadata_ops = MetadataOperations(self._artifact_store)
332
+ return await metadata_ops.list_by_prefix(session_id, directory_prefix, limit)
333
+ except Exception as e:
334
+ logger.error(
335
+ "Directory listing failed for session %s: %s",
336
+ session_id,
337
+ str(e),
338
+ extra={
339
+ "session_id": session_id,
340
+ "directory_prefix": directory_prefix,
341
+ "operation": "get_directory_contents"
342
+ }
343
+ )
344
+ raise ProviderError(f"Directory listing failed: {e}") from e
345
+
346
+ async def _retrieve_data(self, artifact_id: str) -> bytes:
347
+ """Helper to retrieve artifact data using core operations."""
348
+ from .core import CoreStorageOperations
349
+ core_ops = CoreStorageOperations(self._artifact_store)
350
+ return await core_ops.retrieve(artifact_id)
351
+
352
+ # Session security validation helper
353
+ async def _validate_session_access(self, artifact_id: str, expected_session_id: str = None) -> Dict[str, Any]:
354
+ """
355
+ Validate that an artifact belongs to the expected session.
356
+ """
357
+ record = await self._get_record(artifact_id)
358
+ actual_session = record.get("session_id")
359
+
360
+ if expected_session_id and actual_session != expected_session_id:
361
+ raise ArtifactStoreError(
362
+ f"Session access violation: Artifact {artifact_id} belongs to "
363
+ f"session '{actual_session}', but access was attempted from "
364
+ f"session '{expected_session_id}'. Cross-session access is not permitted."
365
+ )
366
+
367
+ return record
chuk_artifacts/store.py CHANGED
@@ -1,13 +1,13 @@
1
1
  # -*- coding: utf-8 -*-
2
- # chuk_artifacts/store.py
2
+ # chuk_artifacts/store.py (ENHANCED)
3
3
  """
4
- Asynchronous, object-store-backed artefact manager with proper modularization.
4
+ Asynchronous, object-store-backed artefact manager with MCP server support.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
9
  import os, logging
10
- from typing import Any, Dict, List, Callable, AsyncContextManager, Optional
10
+ from typing import Any, Dict, List, Callable, AsyncContextManager, Optional, Union
11
11
 
12
12
  try:
13
13
  import aioboto3
@@ -51,15 +51,9 @@ def _default_session_factory() -> Callable[[], AsyncContextManager]:
51
51
  # ─────────────────────────────────────────────────────────────────────
52
52
  class ArtifactStore:
53
53
  """
54
- FINAL FIXED: Asynchronous artifact storage with modularized operations.
54
+ Asynchronous artifact storage with MCP server support.
55
55
 
56
- The circular reference issue has been resolved by fixing BaseOperations.
57
- Now properly delegates operations to specialized modules:
58
- - CoreStorageOperations: store() and retrieve()
59
- - PresignedURLOperations: presign*() methods
60
- - MetadataOperations: metadata(), exists(), delete()
61
- - BatchOperations: store_batch()
62
- - AdminOperations: validate_configuration(), get_stats()
56
+ Enhanced with MCP-specific operations for file management within sessions.
63
57
  """
64
58
 
65
59
  def __init__(
@@ -129,22 +123,23 @@ class ArtifactStore:
129
123
  self._session_provider_name = session_provider or "memory"
130
124
  self._closed = False
131
125
 
132
- # Initialize operation modules (import here to avoid circular dependencies)
133
- # FIXED: Now works correctly with the fixed BaseOperations
126
+ # Initialize operation modules
134
127
  from .core import CoreStorageOperations
135
128
  from .presigned import PresignedURLOperations
136
129
  from .metadata import MetadataOperations
137
130
  from .batch import BatchOperations
138
131
  from .admin import AdminOperations
132
+ from .session_operations import SessionOperations
139
133
 
140
134
  self._core = CoreStorageOperations(self)
141
135
  self._presigned = PresignedURLOperations(self)
142
136
  self._metadata = MetadataOperations(self)
143
137
  self._batch = BatchOperations(self)
144
138
  self._admin = AdminOperations(self)
139
+ self._session = SessionOperations(self)
145
140
 
146
141
  logger.info(
147
- "ArtifactStore initialized with fixed modular operations",
142
+ "ArtifactStore initialized with session operations support",
148
143
  extra={
149
144
  "bucket": bucket,
150
145
  "storage_provider": self._storage_provider_name,
@@ -284,15 +279,20 @@ class ArtifactStore:
284
279
  summary: str = None,
285
280
  meta: Dict[str, Any] = None,
286
281
  filename: str = None,
287
- ttl: int = None
282
+ ttl: int = None,
283
+ # NEW: MCP-specific parameters
284
+ new_meta: Dict[str, Any] = None,
285
+ merge: bool = True
288
286
  ) -> Dict[str, Any]:
289
- """Update artifact metadata without changing the stored data."""
287
+ """Update artifact metadata with MCP server compatibility."""
290
288
  return await self._metadata.update_metadata(
291
289
  artifact_id,
292
290
  summary=summary,
293
291
  meta=meta,
294
292
  filename=filename,
295
- ttl=ttl
293
+ ttl=ttl,
294
+ new_meta=new_meta,
295
+ merge=merge
296
296
  )
297
297
 
298
298
  async def extend_ttl(self, artifact_id: str, additional_seconds: int) -> Dict[str, Any]:
@@ -303,6 +303,15 @@ class ArtifactStore:
303
303
  """List artifacts for a specific session."""
304
304
  return await self._metadata.list_by_session(session_id, limit)
305
305
 
306
+ async def list_by_prefix(
307
+ self,
308
+ session_id: str,
309
+ prefix: str = "",
310
+ limit: int = 100
311
+ ) -> List[Dict[str, Any]]:
312
+ """List artifacts in a session with filename prefix filtering."""
313
+ return await self._metadata.list_by_prefix(session_id, prefix, limit)
314
+
306
315
  # ─────────────────────────────────────────────────────────────────
307
316
  # Batch operations (delegated to BatchOperations)
308
317
  # ─────────────────────────────────────────────────────────────────
@@ -328,6 +337,95 @@ class ArtifactStore:
328
337
  """Get storage statistics."""
329
338
  return await self._admin.get_stats()
330
339
 
340
+ # ─────────────────────────────────────────────────────────────────
341
+ # Session-based file operations (delegated to SessionOperations)
342
+ # ─────────────────────────────────────────────────────────────────
343
+
344
+ async def move_file(
345
+ self,
346
+ artifact_id: str,
347
+ *,
348
+ new_filename: str = None,
349
+ new_session_id: str = None,
350
+ new_meta: Dict[str, Any] = None
351
+ ) -> Dict[str, Any]:
352
+ """Move a file within sessions or rename it."""
353
+ return await self._session.move_file(
354
+ artifact_id,
355
+ new_filename=new_filename,
356
+ new_session_id=new_session_id,
357
+ new_meta=new_meta
358
+ )
359
+
360
+ async def copy_file(
361
+ self,
362
+ artifact_id: str,
363
+ *,
364
+ new_filename: str = None,
365
+ target_session_id: str = None,
366
+ new_meta: Dict[str, Any] = None,
367
+ summary: str = None
368
+ ) -> str:
369
+ """Copy a file within or across sessions."""
370
+ return await self._session.copy_file(
371
+ artifact_id,
372
+ new_filename=new_filename,
373
+ target_session_id=target_session_id,
374
+ new_meta=new_meta,
375
+ summary=summary
376
+ )
377
+
378
+ async def read_file(
379
+ self,
380
+ artifact_id: str,
381
+ *,
382
+ encoding: str = "utf-8",
383
+ as_text: bool = True
384
+ ) -> Union[str, bytes]:
385
+ """Read file content directly."""
386
+ return await self._session.read_file(
387
+ artifact_id,
388
+ encoding=encoding,
389
+ as_text=as_text
390
+ )
391
+
392
+ async def write_file(
393
+ self,
394
+ content: Union[str, bytes],
395
+ *,
396
+ filename: str,
397
+ mime: str = "text/plain",
398
+ summary: str = "",
399
+ session_id: str = None,
400
+ meta: Dict[str, Any] = None,
401
+ encoding: str = "utf-8",
402
+ overwrite_artifact_id: str = None
403
+ ) -> str:
404
+ """Write content to a new file or overwrite existing."""
405
+ return await self._session.write_file(
406
+ content,
407
+ filename=filename,
408
+ mime=mime,
409
+ summary=summary,
410
+ session_id=session_id,
411
+ meta=meta,
412
+ encoding=encoding,
413
+ overwrite_artifact_id=overwrite_artifact_id
414
+ )
415
+
416
+ async def get_directory_contents(
417
+ self,
418
+ session_id: str,
419
+ directory_prefix: str = "",
420
+ limit: int = 100
421
+ ) -> List[Dict[str, Any]]:
422
+ """List files in a directory-like structure within a session."""
423
+ return await self._session.get_directory_contents(
424
+ session_id,
425
+ directory_prefix,
426
+ limit
427
+ )
428
+
331
429
  # ─────────────────────────────────────────────────────────────────
332
430
  # Resource management
333
431
  # ─────────────────────────────────────────────────────────────────