chuk-artifacts 0.1.2__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.
- chuk_artifacts/admin.py +24 -18
- chuk_artifacts/core.py +94 -120
- chuk_artifacts/metadata.py +139 -240
- chuk_artifacts/presigned.py +59 -23
- chuk_artifacts/session/__init__.py +0 -0
- chuk_artifacts/session/session_manager.py +196 -0
- chuk_artifacts/{session_operations.py → session/session_operations.py} +9 -10
- chuk_artifacts/store.py +353 -267
- {chuk_artifacts-0.1.2.dist-info → chuk_artifacts-0.1.3.dist-info}/METADATA +200 -191
- {chuk_artifacts-0.1.2.dist-info → chuk_artifacts-0.1.3.dist-info}/RECORD +13 -11
- {chuk_artifacts-0.1.2.dist-info → chuk_artifacts-0.1.3.dist-info}/WHEEL +0 -0
- {chuk_artifacts-0.1.2.dist-info → chuk_artifacts-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {chuk_artifacts-0.1.2.dist-info → chuk_artifacts-0.1.3.dist-info}/top_level.txt +0 -0
@@ -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
|
2
|
+
# chuk_artifacts/session/session_operations.py
|
3
3
|
"""
|
4
4
|
Session-based file operations with strict session isolation.
|
5
|
-
FIXED: Resolved logging conflict with 'filename' parameter.
|
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
|
15
|
-
from
|
13
|
+
from ..base import BaseOperations
|
14
|
+
from ..exceptions import (
|
16
15
|
ArtifactStoreError, ArtifactNotFoundError, ArtifactExpiredError,
|
17
16
|
ProviderError, SessionError
|
18
17
|
)
|
@@ -65,7 +64,7 @@ class SessionOperations(BaseOperations):
|
|
65
64
|
|
66
65
|
if updates:
|
67
66
|
# Use the metadata operations to update
|
68
|
-
from
|
67
|
+
from ..metadata import MetadataOperations
|
69
68
|
metadata_ops = MetadataOperations(self._artifact_store)
|
70
69
|
return await metadata_ops.update_metadata(artifact_id, **updates)
|
71
70
|
|
@@ -138,7 +137,7 @@ class SessionOperations(BaseOperations):
|
|
138
137
|
copy_meta["copy_within_session"] = original_session
|
139
138
|
|
140
139
|
# Store the copy using core operations
|
141
|
-
from
|
140
|
+
from ..core import CoreStorageOperations
|
142
141
|
core_ops = CoreStorageOperations(self._artifact_store)
|
143
142
|
|
144
143
|
new_artifact_id = await core_ops.store(
|
@@ -258,7 +257,7 @@ class SessionOperations(BaseOperations):
|
|
258
257
|
session_id = session_id or existing_session
|
259
258
|
|
260
259
|
# Delete old version (within same session)
|
261
|
-
from
|
260
|
+
from ..metadata import MetadataOperations
|
262
261
|
metadata_ops = MetadataOperations(self._artifact_store)
|
263
262
|
await metadata_ops.delete(overwrite_artifact_id)
|
264
263
|
|
@@ -266,7 +265,7 @@ class SessionOperations(BaseOperations):
|
|
266
265
|
pass # Original doesn't exist, proceed with new creation
|
267
266
|
|
268
267
|
# Store new content using core operations
|
269
|
-
from
|
268
|
+
from ..core import CoreStorageOperations
|
270
269
|
core_ops = CoreStorageOperations(self._artifact_store)
|
271
270
|
|
272
271
|
write_meta = {**(meta or {})}
|
@@ -327,7 +326,7 @@ class SessionOperations(BaseOperations):
|
|
327
326
|
List files in a directory-like structure within a session.
|
328
327
|
"""
|
329
328
|
try:
|
330
|
-
from
|
329
|
+
from ..metadata import MetadataOperations
|
331
330
|
metadata_ops = MetadataOperations(self._artifact_store)
|
332
331
|
return await metadata_ops.list_by_prefix(session_id, directory_prefix, limit)
|
333
332
|
except Exception as e:
|
@@ -345,7 +344,7 @@ class SessionOperations(BaseOperations):
|
|
345
344
|
|
346
345
|
async def _retrieve_data(self, artifact_id: str) -> bytes:
|
347
346
|
"""Helper to retrieve artifact data using core operations."""
|
348
|
-
from
|
347
|
+
from ..core import CoreStorageOperations
|
349
348
|
core_ops = CoreStorageOperations(self._artifact_store)
|
350
349
|
return await core_ops.retrieve(artifact_id)
|
351
350
|
|