chuk-artifacts 0.1.0__py3-none-any.whl → 0.1.1__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,445 @@
1
+ # -*- coding: utf-8 -*-
2
+ # chuk_artifacts/session_operations.py (SECURE VERSION)
3
+ """
4
+ Session-based file operations with strict session isolation.
5
+ NO cross-session operations allowed for security.
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
+ Parameters
41
+ ----------
42
+ artifact_id : str
43
+ Source artifact ID
44
+ new_filename : str, optional
45
+ New filename (renames the file)
46
+ new_session_id : str, optional
47
+ Target session (BLOCKED - always raises error if different)
48
+ new_meta : dict, optional
49
+ Additional metadata to merge
50
+
51
+ Returns
52
+ -------
53
+ dict
54
+ Updated artifact metadata
55
+
56
+ Raises
57
+ ------
58
+ ArtifactStoreError
59
+ If trying to move across sessions (always blocked)
60
+ """
61
+ self._check_closed()
62
+
63
+ try:
64
+ # Get current metadata
65
+ record = await self._get_record(artifact_id)
66
+ current_session = record.get("session_id")
67
+
68
+ # STRICT SECURITY: Block ALL cross-session moves
69
+ if new_session_id and new_session_id != current_session:
70
+ raise ArtifactStoreError(
71
+ f"Cross-session moves are not permitted for security reasons. "
72
+ f"Artifact {artifact_id} belongs to session '{current_session}', "
73
+ f"cannot move to session '{new_session_id}'. Use copy operations within "
74
+ f"the same session only."
75
+ )
76
+
77
+ # Update metadata fields (only filename and meta allowed)
78
+ updates = {}
79
+ if new_filename:
80
+ updates["filename"] = new_filename
81
+ if new_meta:
82
+ existing_meta = record.get("meta", {})
83
+ existing_meta.update(new_meta)
84
+ updates["new_meta"] = existing_meta
85
+ updates["merge"] = True
86
+
87
+ if updates:
88
+ # Use the metadata operations to update
89
+ from .metadata import MetadataOperations
90
+ metadata_ops = MetadataOperations(self._artifact_store)
91
+ return await metadata_ops.update_metadata(artifact_id, **updates)
92
+
93
+ return record
94
+
95
+ except (ArtifactNotFoundError, ArtifactExpiredError):
96
+ raise
97
+ except Exception as e:
98
+ logger.error(
99
+ "File move failed",
100
+ extra={
101
+ "artifact_id": artifact_id,
102
+ "new_filename": new_filename,
103
+ "new_session_id": new_session_id,
104
+ "error": str(e)
105
+ }
106
+ )
107
+ raise ProviderError(f"Move operation failed: {e}") from e
108
+
109
+ async def copy_file(
110
+ self,
111
+ artifact_id: str,
112
+ *,
113
+ new_filename: str = None,
114
+ target_session_id: str = None,
115
+ new_meta: Dict[str, Any] = None,
116
+ summary: str = None
117
+ ) -> str:
118
+ """
119
+ Copy a file WITHIN THE SAME SESSION only.
120
+
121
+ Parameters
122
+ ----------
123
+ artifact_id : str
124
+ Source artifact ID
125
+ new_filename : str, optional
126
+ Filename for the copy (defaults to original + "_copy")
127
+ target_session_id : str, optional
128
+ Target session (BLOCKED - must be same as source session)
129
+ new_meta : dict, optional
130
+ Additional metadata to merge
131
+ summary : str, optional
132
+ New summary for the copy
133
+
134
+ Returns
135
+ -------
136
+ str
137
+ New artifact ID of the copy
138
+
139
+ Raises
140
+ ------
141
+ ArtifactStoreError
142
+ If trying to copy across sessions (always blocked)
143
+ """
144
+ self._check_closed()
145
+
146
+ try:
147
+ # Get original metadata first to check session
148
+ original_meta = await self._get_record(artifact_id)
149
+ original_session = original_meta.get("session_id")
150
+
151
+ # STRICT SECURITY: Block ALL cross-session copies
152
+ if target_session_id and target_session_id != original_session:
153
+ raise ArtifactStoreError(
154
+ f"Cross-session copies are not permitted for security reasons. "
155
+ f"Artifact {artifact_id} belongs to session '{original_session}', "
156
+ f"cannot copy to session '{target_session_id}'. Files can only be "
157
+ f"copied within the same session."
158
+ )
159
+
160
+ # Ensure target session is the same as source
161
+ copy_session = original_session # Always use source session
162
+
163
+ # Get original data
164
+ original_data = await self._retrieve_data(artifact_id)
165
+
166
+ # Prepare copy metadata
167
+ copy_filename = new_filename or (
168
+ (original_meta.get("filename", "file") or "file") + "_copy"
169
+ )
170
+ copy_summary = summary or f"Copy of {original_meta.get('summary', 'artifact')}"
171
+
172
+ # Merge metadata
173
+ copy_meta = {**original_meta.get("meta", {})}
174
+ if new_meta:
175
+ copy_meta.update(new_meta)
176
+
177
+ # Add copy tracking
178
+ copy_meta["copied_from"] = artifact_id
179
+ copy_meta["copy_timestamp"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
180
+ copy_meta["copy_within_session"] = original_session
181
+
182
+ # Store the copy using core operations
183
+ from .core import CoreStorageOperations
184
+ core_ops = CoreStorageOperations(self._artifact_store)
185
+
186
+ new_artifact_id = await core_ops.store(
187
+ data=original_data,
188
+ mime=original_meta["mime"],
189
+ summary=copy_summary,
190
+ filename=copy_filename,
191
+ session_id=copy_session, # Always same session
192
+ meta=copy_meta
193
+ )
194
+
195
+ logger.info(
196
+ "File copied within session",
197
+ extra={
198
+ "source_artifact_id": artifact_id,
199
+ "new_artifact_id": new_artifact_id,
200
+ "session": copy_session,
201
+ "security_level": "same_session_only"
202
+ }
203
+ )
204
+
205
+ return new_artifact_id
206
+
207
+ except (ArtifactNotFoundError, ArtifactExpiredError):
208
+ raise
209
+ except Exception as e:
210
+ logger.error(
211
+ "File copy failed",
212
+ extra={
213
+ "artifact_id": artifact_id,
214
+ "new_filename": new_filename,
215
+ "target_session_id": target_session_id,
216
+ "error": str(e)
217
+ }
218
+ )
219
+ raise ProviderError(f"Copy operation failed: {e}") from e
220
+
221
+ async def read_file(
222
+ self,
223
+ artifact_id: str,
224
+ *,
225
+ encoding: str = "utf-8",
226
+ as_text: bool = True
227
+ ) -> Union[str, bytes]:
228
+ """
229
+ Read file content directly.
230
+
231
+ Note: This operation inherently respects session boundaries since
232
+ you can only read files you have artifact IDs for.
233
+ """
234
+ self._check_closed()
235
+
236
+ try:
237
+ data = await self._retrieve_data(artifact_id)
238
+
239
+ if as_text:
240
+ try:
241
+ return data.decode(encoding)
242
+ except UnicodeDecodeError as e:
243
+ logger.warning(f"Failed to decode with {encoding}: {e}")
244
+ raise ProviderError(f"Cannot decode file as text with {encoding} encoding") from e
245
+ else:
246
+ return data
247
+
248
+ except (ArtifactNotFoundError, ArtifactExpiredError):
249
+ raise
250
+ except Exception as e:
251
+ logger.error(
252
+ "File read failed",
253
+ extra={"artifact_id": artifact_id, "error": str(e)}
254
+ )
255
+ raise ProviderError(f"Read operation failed: {e}") from e
256
+
257
+ async def write_file(
258
+ self,
259
+ content: Union[str, bytes],
260
+ *,
261
+ filename: str,
262
+ mime: str = "text/plain",
263
+ summary: str = "",
264
+ session_id: str = None,
265
+ meta: Dict[str, Any] = None,
266
+ encoding: str = "utf-8",
267
+ overwrite_artifact_id: str = None
268
+ ) -> str:
269
+ """
270
+ Write content to a new file or overwrite existing WITHIN THE SAME SESSION.
271
+
272
+ Parameters
273
+ ----------
274
+ content : str or bytes
275
+ Content to write
276
+ filename : str
277
+ Filename for the new file
278
+ mime : str, optional
279
+ MIME type (default: text/plain)
280
+ summary : str, optional
281
+ File summary
282
+ session_id : str, optional
283
+ Session for the file
284
+ meta : dict, optional
285
+ Additional metadata
286
+ encoding : str, optional
287
+ Text encoding for string content (default: utf-8)
288
+ overwrite_artifact_id : str, optional
289
+ If provided, overwrite this existing artifact (must be in same session)
290
+
291
+ Returns
292
+ -------
293
+ str
294
+ Artifact ID of the written file
295
+
296
+ Raises
297
+ ------
298
+ ArtifactStoreError
299
+ If trying to overwrite a file in a different session
300
+ """
301
+ self._check_closed()
302
+
303
+ try:
304
+ # Convert content to bytes if needed
305
+ if isinstance(content, str):
306
+ data = content.encode(encoding)
307
+ else:
308
+ data = content
309
+
310
+ # Handle overwrite case with session security check
311
+ if overwrite_artifact_id:
312
+ try:
313
+ existing_meta = await self._get_record(overwrite_artifact_id)
314
+ existing_session = existing_meta.get("session_id")
315
+
316
+ # STRICT SECURITY: Can only overwrite files in the same session
317
+ if session_id and session_id != existing_session:
318
+ raise ArtifactStoreError(
319
+ f"Cross-session overwrite not permitted. Artifact {overwrite_artifact_id} "
320
+ f"belongs to session '{existing_session}', cannot overwrite from "
321
+ f"session '{session_id}'. Overwrite operations must be within the same session."
322
+ )
323
+
324
+ # Use the existing session if no session_id provided
325
+ session_id = session_id or existing_session
326
+
327
+ # Delete old version (within same session)
328
+ from .metadata import MetadataOperations
329
+ metadata_ops = MetadataOperations(self._artifact_store)
330
+ await metadata_ops.delete(overwrite_artifact_id)
331
+
332
+ except (ArtifactNotFoundError, ArtifactExpiredError):
333
+ pass # Original doesn't exist, proceed with new creation
334
+
335
+ # Store new content using core operations
336
+ from .core import CoreStorageOperations
337
+ core_ops = CoreStorageOperations(self._artifact_store)
338
+
339
+ write_meta = {**(meta or {})}
340
+ if overwrite_artifact_id:
341
+ write_meta["overwrote"] = overwrite_artifact_id
342
+ write_meta["overwrite_timestamp"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
343
+ write_meta["overwrite_within_session"] = session_id
344
+
345
+ artifact_id = await core_ops.store(
346
+ data=data,
347
+ mime=mime,
348
+ summary=summary or f"Written file: {filename}",
349
+ filename=filename,
350
+ session_id=session_id,
351
+ meta=write_meta
352
+ )
353
+
354
+ logger.info(
355
+ "File written successfully",
356
+ extra={
357
+ "artifact_id": artifact_id,
358
+ "filename": filename,
359
+ "bytes": len(data),
360
+ "overwrite": bool(overwrite_artifact_id),
361
+ "session_id": session_id,
362
+ "security_level": "session_isolated"
363
+ }
364
+ )
365
+
366
+ return artifact_id
367
+
368
+ except Exception as e:
369
+ logger.error(
370
+ "File write failed",
371
+ extra={
372
+ "filename": filename,
373
+ "overwrite_artifact_id": overwrite_artifact_id,
374
+ "session_id": session_id,
375
+ "error": str(e)
376
+ }
377
+ )
378
+ raise ProviderError(f"Write operation failed: {e}") from e
379
+
380
+ async def get_directory_contents(
381
+ self,
382
+ session_id: str,
383
+ directory_prefix: str = "",
384
+ limit: int = 100
385
+ ) -> List[Dict[str, Any]]:
386
+ """
387
+ List files in a directory-like structure within a session.
388
+
389
+ This operation is inherently session-safe since it requires
390
+ explicit session_id parameter.
391
+ """
392
+ try:
393
+ from .metadata import MetadataOperations
394
+ metadata_ops = MetadataOperations(self._artifact_store)
395
+ return await metadata_ops.list_by_prefix(session_id, directory_prefix, limit)
396
+ except Exception as e:
397
+ logger.error(
398
+ "Directory listing failed",
399
+ extra={
400
+ "session_id": session_id,
401
+ "directory_prefix": directory_prefix,
402
+ "error": str(e)
403
+ }
404
+ )
405
+ raise ProviderError(f"Directory listing failed: {e}") from e
406
+
407
+ async def _retrieve_data(self, artifact_id: str) -> bytes:
408
+ """Helper to retrieve artifact data using core operations."""
409
+ from .core import CoreStorageOperations
410
+ core_ops = CoreStorageOperations(self._artifact_store)
411
+ return await core_ops.retrieve(artifact_id)
412
+
413
+ # NEW: Session security validation helper
414
+ async def _validate_session_access(self, artifact_id: str, expected_session_id: str = None) -> Dict[str, Any]:
415
+ """
416
+ Validate that an artifact belongs to the expected session.
417
+
418
+ Parameters
419
+ ----------
420
+ artifact_id : str
421
+ Artifact to check
422
+ expected_session_id : str, optional
423
+ Expected session ID (if None, just returns the session)
424
+
425
+ Returns
426
+ -------
427
+ dict
428
+ Artifact metadata
429
+
430
+ Raises
431
+ ------
432
+ ArtifactStoreError
433
+ If artifact belongs to different session
434
+ """
435
+ record = await self._get_record(artifact_id)
436
+ actual_session = record.get("session_id")
437
+
438
+ if expected_session_id and actual_session != expected_session_id:
439
+ raise ArtifactStoreError(
440
+ f"Session access violation: Artifact {artifact_id} belongs to "
441
+ f"session '{actual_session}', but access was attempted from "
442
+ f"session '{expected_session_id}'. Cross-session access is not permitted."
443
+ )
444
+
445
+ 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
  # ─────────────────────────────────────────────────────────────────