chuk-artifacts 0.1.2__py3-none-any.whl → 0.1.4__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 +97 -19
- chuk_artifacts/base.py +9 -3
- chuk_artifacts/batch.py +44 -29
- chuk_artifacts/core.py +88 -113
- chuk_artifacts/metadata.py +141 -240
- chuk_artifacts/presigned.py +60 -23
- chuk_artifacts/store.py +393 -267
- {chuk_artifacts-0.1.2.dist-info → chuk_artifacts-0.1.4.dist-info}/METADATA +201 -192
- {chuk_artifacts-0.1.2.dist-info → chuk_artifacts-0.1.4.dist-info}/RECORD +12 -13
- chuk_artifacts/session_operations.py +0 -367
- {chuk_artifacts-0.1.2.dist-info → chuk_artifacts-0.1.4.dist-info}/WHEEL +0 -0
- {chuk_artifacts-0.1.2.dist-info → chuk_artifacts-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {chuk_artifacts-0.1.2.dist-info → chuk_artifacts-0.1.4.dist-info}/top_level.txt +0 -0
@@ -1,367 +0,0 @@
|
|
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
|
File without changes
|
File without changes
|
File without changes
|