chuk-artifacts 0.1.1__py3-none-any.whl → 0.1.3__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,196 @@
1
+ # -*- coding: utf-8 -*-
2
+ # chuk_artifacts/session/session_manager.py
3
+ """
4
+ Clean session manager for grid architecture.
5
+
6
+ Simple rules:
7
+ - Always have a session (auto-allocate if needed)
8
+ - Grid paths: grid/{sandbox_id}/{session_id}/{artifact_id}
9
+ - No legacy compatibility, clean implementation
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import uuid
15
+ import time
16
+ import json
17
+ import asyncio
18
+ import logging
19
+ from datetime import datetime, timedelta
20
+ from typing import Optional, Dict, Any, List, AsyncContextManager, Callable
21
+ from dataclasses import dataclass, asdict
22
+
23
+ from ..exceptions import SessionError, ArtifactStoreError
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _DEFAULT_SESSION_TTL_HOURS = 24
28
+
29
+
30
+ @dataclass
31
+ class SessionMetadata:
32
+ """Session metadata for grid operations."""
33
+ session_id: str
34
+ sandbox_id: str
35
+ user_id: Optional[str] = None
36
+ created_at: str = None
37
+ expires_at: str = None
38
+ status: str = "active"
39
+ artifact_count: int = 0
40
+ total_bytes: int = 0
41
+
42
+ def __post_init__(self):
43
+ if self.created_at is None:
44
+ self.created_at = datetime.utcnow().isoformat() + "Z"
45
+
46
+ def is_expired(self) -> bool:
47
+ """Check if session has expired."""
48
+ if not self.expires_at:
49
+ return False
50
+ expires = datetime.fromisoformat(self.expires_at.replace("Z", ""))
51
+ return datetime.utcnow() > expires
52
+
53
+ def to_dict(self) -> Dict[str, Any]:
54
+ return asdict(self)
55
+
56
+ @classmethod
57
+ def from_dict(cls, data: Dict[str, Any]) -> 'SessionMetadata':
58
+ return cls(**data)
59
+
60
+
61
+ class SessionManager:
62
+ """Simple session manager for grid architecture."""
63
+
64
+ def __init__(
65
+ self,
66
+ sandbox_id: str,
67
+ session_factory: Callable[[], AsyncContextManager],
68
+ default_ttl_hours: int = _DEFAULT_SESSION_TTL_HOURS,
69
+ ):
70
+ self.sandbox_id = sandbox_id
71
+ self.session_factory = session_factory
72
+ self.default_ttl_hours = default_ttl_hours
73
+
74
+ # Simple in-memory cache
75
+ self._session_cache: Dict[str, SessionMetadata] = {}
76
+ self._cache_lock = asyncio.Lock()
77
+
78
+ logger.info(f"SessionManager initialized for sandbox: {sandbox_id}")
79
+
80
+ async def allocate_session(
81
+ self,
82
+ session_id: Optional[str] = None,
83
+ user_id: Optional[str] = None,
84
+ ttl_hours: Optional[int] = None,
85
+ ) -> str:
86
+ """Allocate or validate a session."""
87
+ ttl_hours = ttl_hours or self.default_ttl_hours
88
+
89
+ if session_id:
90
+ # Validate existing session
91
+ metadata = await self._get_session_metadata(session_id)
92
+ if metadata and not metadata.is_expired():
93
+ await self._touch_session(session_id)
94
+ return session_id
95
+
96
+ # Create new session
97
+ if not session_id:
98
+ session_id = self._generate_session_id(user_id)
99
+
100
+ expires_at = (datetime.utcnow() + timedelta(hours=ttl_hours)).isoformat() + "Z"
101
+
102
+ metadata = SessionMetadata(
103
+ session_id=session_id,
104
+ sandbox_id=self.sandbox_id,
105
+ user_id=user_id,
106
+ expires_at=expires_at,
107
+ )
108
+
109
+ await self._store_session_metadata(metadata)
110
+
111
+ async with self._cache_lock:
112
+ self._session_cache[session_id] = metadata
113
+
114
+ logger.info(f"Session allocated: {session_id} (user: {user_id})")
115
+ return session_id
116
+
117
+ async def validate_session(self, session_id: str) -> bool:
118
+ """Check if session is valid."""
119
+ try:
120
+ metadata = await self._get_session_metadata(session_id)
121
+ if metadata and not metadata.is_expired():
122
+ await self._touch_session(session_id)
123
+ return True
124
+ return False
125
+ except Exception:
126
+ return False
127
+
128
+ async def get_session_info(self, session_id: str) -> Optional[Dict[str, Any]]:
129
+ """Get session information."""
130
+ metadata = await self._get_session_metadata(session_id)
131
+ return metadata.to_dict() if metadata else None
132
+
133
+ def get_canonical_prefix(self, session_id: str) -> str:
134
+ """Get grid path prefix."""
135
+ return f"grid/{self.sandbox_id}/{session_id}/"
136
+
137
+ def generate_artifact_key(self, session_id: str, artifact_id: str) -> str:
138
+ """Generate grid artifact key."""
139
+ return f"grid/{self.sandbox_id}/{session_id}/{artifact_id}"
140
+
141
+ def _generate_session_id(self, user_id: Optional[str] = None) -> str:
142
+ """Generate session ID."""
143
+ timestamp = int(time.time())
144
+ unique = uuid.uuid4().hex[:8]
145
+
146
+ if user_id:
147
+ safe_user = "".join(c for c in user_id if c.isalnum())[:8]
148
+ return f"sess-{safe_user}-{timestamp}-{unique}"
149
+ else:
150
+ return f"sess-{timestamp}-{unique}"
151
+
152
+ async def _get_session_metadata(self, session_id: str) -> Optional[SessionMetadata]:
153
+ """Get session metadata."""
154
+ # Check cache
155
+ async with self._cache_lock:
156
+ if session_id in self._session_cache:
157
+ return self._session_cache[session_id]
158
+
159
+ # Query session provider
160
+ try:
161
+ session_ctx_mgr = self.session_factory()
162
+ async with session_ctx_mgr as session:
163
+ raw_data = await session.get(f"session:{session_id}")
164
+ if raw_data:
165
+ data = json.loads(raw_data)
166
+ metadata = SessionMetadata.from_dict(data)
167
+
168
+ # Cache it
169
+ async with self._cache_lock:
170
+ self._session_cache[session_id] = metadata
171
+
172
+ return metadata
173
+ except Exception as e:
174
+ logger.warning(f"Failed to get session {session_id}: {e}")
175
+
176
+ return None
177
+
178
+ async def _store_session_metadata(self, metadata: SessionMetadata) -> None:
179
+ """Store session metadata."""
180
+ try:
181
+ session_ctx_mgr = self.session_factory()
182
+ async with session_ctx_mgr as session:
183
+ key = f"session:{metadata.session_id}"
184
+ ttl_seconds = int((datetime.fromisoformat(metadata.expires_at.replace("Z", "")) - datetime.utcnow()).total_seconds())
185
+ data = json.dumps(metadata.to_dict())
186
+
187
+ await session.setex(key, ttl_seconds, data)
188
+ except Exception as e:
189
+ raise SessionError(f"Session storage failed: {e}") from e
190
+
191
+ async def _touch_session(self, session_id: str) -> None:
192
+ """Update last accessed time."""
193
+ metadata = await self._get_session_metadata(session_id)
194
+ if metadata:
195
+ # Simple touch - could update last_accessed if we add that field
196
+ await self._store_session_metadata(metadata)
@@ -1,8 +1,7 @@
1
1
  # -*- coding: utf-8 -*-
2
- # chuk_artifacts/session_operations.py (SECURE VERSION)
2
+ # chuk_artifacts/session/session_operations.py
3
3
  """
4
4
  Session-based file operations with strict session isolation.
5
- NO cross-session operations allowed for security.
6
5
  """
7
6
 
8
7
  from __future__ import annotations
@@ -11,8 +10,8 @@ import uuid, hashlib, json, logging
11
10
  from datetime import datetime
12
11
  from typing import Any, Dict, Optional, Union, List
13
12
 
14
- from .base import BaseOperations
15
- from .exceptions import (
13
+ from ..base import BaseOperations
14
+ from ..exceptions import (
16
15
  ArtifactStoreError, ArtifactNotFoundError, ArtifactExpiredError,
17
16
  ProviderError, SessionError
18
17
  )
@@ -36,27 +35,6 @@ class SessionOperations(BaseOperations):
36
35
  ) -> Dict[str, Any]:
37
36
  """
38
37
  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
38
  """
61
39
  self._check_closed()
62
40
 
@@ -86,7 +64,7 @@ class SessionOperations(BaseOperations):
86
64
 
87
65
  if updates:
88
66
  # Use the metadata operations to update
89
- from .metadata import MetadataOperations
67
+ from ..metadata import MetadataOperations
90
68
  metadata_ops = MetadataOperations(self._artifact_store)
91
69
  return await metadata_ops.update_metadata(artifact_id, **updates)
92
70
 
@@ -96,12 +74,14 @@ class SessionOperations(BaseOperations):
96
74
  raise
97
75
  except Exception as e:
98
76
  logger.error(
99
- "File move failed",
77
+ "File move failed for artifact %s: %s",
78
+ artifact_id,
79
+ str(e),
100
80
  extra={
101
81
  "artifact_id": artifact_id,
102
- "new_filename": new_filename,
82
+ "new_file_name": new_filename, # FIXED: Renamed from 'new_filename'
103
83
  "new_session_id": new_session_id,
104
- "error": str(e)
84
+ "operation": "move_file"
105
85
  }
106
86
  )
107
87
  raise ProviderError(f"Move operation failed: {e}") from e
@@ -117,29 +97,6 @@ class SessionOperations(BaseOperations):
117
97
  ) -> str:
118
98
  """
119
99
  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
100
  """
144
101
  self._check_closed()
145
102
 
@@ -180,7 +137,7 @@ class SessionOperations(BaseOperations):
180
137
  copy_meta["copy_within_session"] = original_session
181
138
 
182
139
  # Store the copy using core operations
183
- from .core import CoreStorageOperations
140
+ from ..core import CoreStorageOperations
184
141
  core_ops = CoreStorageOperations(self._artifact_store)
185
142
 
186
143
  new_artifact_id = await core_ops.store(
@@ -193,12 +150,15 @@ class SessionOperations(BaseOperations):
193
150
  )
194
151
 
195
152
  logger.info(
196
- "File copied within session",
153
+ "File copied within session: %s -> %s",
154
+ artifact_id,
155
+ new_artifact_id,
197
156
  extra={
198
157
  "source_artifact_id": artifact_id,
199
158
  "new_artifact_id": new_artifact_id,
200
159
  "session": copy_session,
201
- "security_level": "same_session_only"
160
+ "security_level": "same_session_only",
161
+ "operation": "copy_file"
202
162
  }
203
163
  )
204
164
 
@@ -208,12 +168,14 @@ class SessionOperations(BaseOperations):
208
168
  raise
209
169
  except Exception as e:
210
170
  logger.error(
211
- "File copy failed",
171
+ "File copy failed for artifact %s: %s",
172
+ artifact_id,
173
+ str(e),
212
174
  extra={
213
175
  "artifact_id": artifact_id,
214
- "new_filename": new_filename,
176
+ "new_file_name": new_filename, # FIXED: Renamed from 'new_filename'
215
177
  "target_session_id": target_session_id,
216
- "error": str(e)
178
+ "operation": "copy_file"
217
179
  }
218
180
  )
219
181
  raise ProviderError(f"Copy operation failed: {e}") from e
@@ -227,9 +189,6 @@ class SessionOperations(BaseOperations):
227
189
  ) -> Union[str, bytes]:
228
190
  """
229
191
  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
192
  """
234
193
  self._check_closed()
235
194
 
@@ -249,8 +208,10 @@ class SessionOperations(BaseOperations):
249
208
  raise
250
209
  except Exception as e:
251
210
  logger.error(
252
- "File read failed",
253
- extra={"artifact_id": artifact_id, "error": str(e)}
211
+ "File read failed for artifact %s: %s",
212
+ artifact_id,
213
+ str(e),
214
+ extra={"artifact_id": artifact_id, "operation": "read_file"}
254
215
  )
255
216
  raise ProviderError(f"Read operation failed: {e}") from e
256
217
 
@@ -268,35 +229,6 @@ class SessionOperations(BaseOperations):
268
229
  ) -> str:
269
230
  """
270
231
  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
232
  """
301
233
  self._check_closed()
302
234
 
@@ -325,7 +257,7 @@ class SessionOperations(BaseOperations):
325
257
  session_id = session_id or existing_session
326
258
 
327
259
  # Delete old version (within same session)
328
- from .metadata import MetadataOperations
260
+ from ..metadata import MetadataOperations
329
261
  metadata_ops = MetadataOperations(self._artifact_store)
330
262
  await metadata_ops.delete(overwrite_artifact_id)
331
263
 
@@ -333,7 +265,7 @@ class SessionOperations(BaseOperations):
333
265
  pass # Original doesn't exist, proceed with new creation
334
266
 
335
267
  # Store new content using core operations
336
- from .core import CoreStorageOperations
268
+ from ..core import CoreStorageOperations
337
269
  core_ops = CoreStorageOperations(self._artifact_store)
338
270
 
339
271
  write_meta = {**(meta or {})}
@@ -351,28 +283,35 @@ class SessionOperations(BaseOperations):
351
283
  meta=write_meta
352
284
  )
353
285
 
286
+ # FIXED: Use separate variables for logging to avoid 'filename' conflict
354
287
  logger.info(
355
- "File written successfully",
288
+ "File written successfully: %s (artifact_id: %s)",
289
+ filename,
290
+ artifact_id,
356
291
  extra={
357
292
  "artifact_id": artifact_id,
358
- "filename": filename,
293
+ "file_name": filename, # FIXED: Renamed from 'filename'
359
294
  "bytes": len(data),
360
295
  "overwrite": bool(overwrite_artifact_id),
361
296
  "session_id": session_id,
362
- "security_level": "session_isolated"
297
+ "security_level": "session_isolated",
298
+ "operation": "write_file"
363
299
  }
364
300
  )
365
301
 
366
302
  return artifact_id
367
303
 
368
304
  except Exception as e:
305
+ # FIXED: Use separate variables for logging to avoid 'filename' conflict
369
306
  logger.error(
370
- "File write failed",
307
+ "File write failed for %s: %s",
308
+ filename,
309
+ str(e),
371
310
  extra={
372
- "filename": filename,
311
+ "file_name": filename, # FIXED: Renamed from 'filename'
373
312
  "overwrite_artifact_id": overwrite_artifact_id,
374
313
  "session_id": session_id,
375
- "error": str(e)
314
+ "operation": "write_file"
376
315
  }
377
316
  )
378
317
  raise ProviderError(f"Write operation failed: {e}") from e
@@ -385,52 +324,34 @@ class SessionOperations(BaseOperations):
385
324
  ) -> List[Dict[str, Any]]:
386
325
  """
387
326
  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
327
  """
392
328
  try:
393
- from .metadata import MetadataOperations
329
+ from ..metadata import MetadataOperations
394
330
  metadata_ops = MetadataOperations(self._artifact_store)
395
331
  return await metadata_ops.list_by_prefix(session_id, directory_prefix, limit)
396
332
  except Exception as e:
397
333
  logger.error(
398
- "Directory listing failed",
334
+ "Directory listing failed for session %s: %s",
335
+ session_id,
336
+ str(e),
399
337
  extra={
400
338
  "session_id": session_id,
401
339
  "directory_prefix": directory_prefix,
402
- "error": str(e)
340
+ "operation": "get_directory_contents"
403
341
  }
404
342
  )
405
343
  raise ProviderError(f"Directory listing failed: {e}") from e
406
344
 
407
345
  async def _retrieve_data(self, artifact_id: str) -> bytes:
408
346
  """Helper to retrieve artifact data using core operations."""
409
- from .core import CoreStorageOperations
347
+ from ..core import CoreStorageOperations
410
348
  core_ops = CoreStorageOperations(self._artifact_store)
411
349
  return await core_ops.retrieve(artifact_id)
412
350
 
413
- # NEW: Session security validation helper
351
+ # Session security validation helper
414
352
  async def _validate_session_access(self, artifact_id: str, expected_session_id: str = None) -> Dict[str, Any]:
415
353
  """
416
354
  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
355
  """
435
356
  record = await self._get_record(artifact_id)
436
357
  actual_session = record.get("session_id")