chuk-artifacts 0.3__py3-none-any.whl → 0.4.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.
@@ -12,6 +12,7 @@ from dotenv import load_dotenv
12
12
 
13
13
  # Core classes
14
14
  from .store import ArtifactStore
15
+ from .models import ArtifactEnvelope
15
16
 
16
17
  # Exception classes
17
18
  from .exceptions import (
@@ -42,6 +43,9 @@ __all__ = [
42
43
  # Main class
43
44
  "ArtifactStore",
44
45
 
46
+ # Models
47
+ "ArtifactEnvelope",
48
+
45
49
  # Exceptions
46
50
  "ArtifactStoreError",
47
51
  "ArtifactNotFoundError",
@@ -146,4 +150,4 @@ def configure_logging(level: str = "INFO"):
146
150
  "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
147
151
  )
148
152
  handler.setFormatter(formatter)
149
- logger.addHandler(handler)
153
+ logger.addHandler(handler)
chuk_artifacts/core.py CHANGED
@@ -19,7 +19,7 @@ from typing import Any, Dict, Optional, TYPE_CHECKING
19
19
  if TYPE_CHECKING:
20
20
  from .store import ArtifactStore
21
21
 
22
- from .exceptions import ArtifactStoreError, ProviderError, SessionError
22
+ from .exceptions import ArtifactStoreError, ProviderError, SessionError, ArtifactNotFoundError
23
23
 
24
24
  logger = logging.getLogger(__name__)
25
25
 
@@ -102,44 +102,55 @@ class CoreStorageOperations:
102
102
  raise ProviderError(f"Storage failed: {e}") from e
103
103
 
104
104
  async def update_file(
105
- self,
106
- artifact_id: str,
107
- new_data: bytes,
108
- *,
109
- mime: Optional[str] = None,
110
- summary: Optional[str] = None,
111
- meta: Optional[Dict[str, Any]] = None,
112
- filename: Optional[str] = None,
113
- ttl: Optional[int] = None,
114
- ) -> None:
115
- """
116
- Update the contents of an existing artifact.
105
+ self,
106
+ artifact_id: str,
107
+ new_data: Optional[bytes] = None,
108
+ *,
109
+ mime: Optional[str] = None,
110
+ summary: Optional[str] = None,
111
+ meta: Optional[Dict[str, Any]] = None,
112
+ filename: Optional[str] = None,
113
+ ttl: Optional[int] = None,
114
+ ) -> bool:
115
+ """
116
+ Update an artifact's content, metadata, filename, summary, or mime type.
117
+
118
+ Parameters
119
+ ----------
120
+ artifact_id : str
121
+ ID of the artifact to update
122
+ new_data : bytes, optional
123
+ New data to overwrite the existing artifact
124
+ mime : str, optional
125
+ New MIME type
126
+ summary : str, optional
127
+ New summary
128
+ meta : dict, optional
129
+ Updated metadata
130
+ filename : str, optional
131
+ New filename
132
+ ttl : int, optional
133
+ New TTL
134
+
135
+ Returns
136
+ -------
137
+ bool
138
+ True if update was successful
139
+ """
140
+ if self.artifact_store._closed:
141
+ raise ArtifactStoreError("Store is closed")
117
142
 
118
- Parameters
119
- ----------
120
- artifact_id : str
121
- ID of the artifact to update.
122
- new_data : bytes
123
- New data to overwrite the existing artifact.
124
- mime : Optional[str]
125
- Optional new MIME type.
126
- summary : Optional[str]
127
- Optional new summary.
128
- meta : Optional[Dict[str, Any]]
129
- Optional updated metadata.
130
- filename : Optional[str]
131
- Optional new filename.
132
- ttl : Optional[int]
133
- Optional TTL to reset (if provided).
134
- """
135
- if self.artifact_store._closed:
136
- raise ArtifactStoreError("Store is closed")
143
+ if not any([new_data is not None, meta is not None, filename is not None,
144
+ summary is not None, mime is not None, ttl is not None]):
145
+ raise ValueError("At least one update parameter must be provided.")
137
146
 
138
- try:
139
- record = await self._get_record(artifact_id)
140
- key = record["key"]
141
- session_id = record["session_id"]
147
+ try:
148
+ record = await self._get_record(artifact_id)
149
+ key = record["key"]
150
+ session_id = record["session_id"]
142
151
 
152
+ # Update data if provided
153
+ if new_data is not None:
143
154
  # Overwrite in object storage
144
155
  await self._store_with_retry(
145
156
  new_data,
@@ -148,31 +159,37 @@ class CoreStorageOperations:
148
159
  filename or record.get("filename"),
149
160
  session_id,
150
161
  )
162
+
163
+ # Update size and hash in metadata
164
+ record["bytes"] = len(new_data)
165
+ record["sha256"] = hashlib.sha256(new_data).hexdigest()
151
166
 
152
- # Update metadata
153
- record.update({
154
- "mime": mime or record["mime"],
155
- "summary": summary or record["summary"],
156
- "meta": meta or record["meta"],
157
- "filename": filename or record.get("filename"),
158
- "bytes": len(new_data),
159
- "sha256": hashlib.sha256(new_data).hexdigest(),
160
- "updated_at": datetime.utcnow().isoformat() + "Z",
161
- "ttl": ttl or record["ttl"],
162
- })
167
+ # Update metadata fields
168
+ if mime is not None:
169
+ record["mime"] = mime
170
+ if summary is not None:
171
+ record["summary"] = summary
172
+ if filename is not None:
173
+ record["filename"] = filename
174
+ if meta is not None:
175
+ record["meta"] = meta
176
+ if ttl is not None:
177
+ record["ttl"] = ttl
163
178
 
164
- if ttl is not None:
165
- record["ttl"] = ttl
179
+ # Add update timestamp
180
+ record["updated_at"] = datetime.utcnow().isoformat() + "Z"
166
181
 
167
- session_ctx_mgr = self.artifact_store._session_factory()
168
- async with session_ctx_mgr as session:
169
- await session.setex(artifact_id, record["ttl"], json.dumps(record))
182
+ # Store updated metadata
183
+ session_ctx_mgr = self.artifact_store._session_factory()
184
+ async with session_ctx_mgr as session:
185
+ await session.setex(artifact_id, record["ttl"], json.dumps(record))
170
186
 
171
- logger.info("Artifact updated successfully", extra={"artifact_id": artifact_id})
187
+ logger.info("Artifact updated successfully", extra={"artifact_id": artifact_id})
188
+ return True
172
189
 
173
- except Exception as e:
174
- logger.error(f"Update failed for artifact {artifact_id}: {e}")
175
- raise ProviderError(f"Artifact update failed: {e}") from e
190
+ except Exception as e:
191
+ logger.error(f"Update failed for artifact {artifact_id}: {e}")
192
+ raise ProviderError(f"Artifact update failed: {e}") from e
176
193
 
177
194
  async def retrieve(self, artifact_id: str) -> bytes:
178
195
  """Retrieve artifact data."""
@@ -246,7 +263,7 @@ class CoreStorageOperations:
246
263
  raise last_exception
247
264
 
248
265
  async def _get_record(self, artifact_id: str) -> Dict[str, Any]:
249
- """Get artifact metadata."""
266
+ """Get artifact metadata record from session provider."""
250
267
  try:
251
268
  session_ctx_mgr = self.artifact_store._session_factory()
252
269
  async with session_ctx_mgr as session:
@@ -255,7 +272,7 @@ class CoreStorageOperations:
255
272
  raise SessionError(f"Session error for {artifact_id}: {e}") from e
256
273
 
257
274
  if raw is None:
258
- raise ProviderError(f"Artifact {artifact_id} not found")
275
+ raise ArtifactNotFoundError(f"Artifact {artifact_id} not found")
259
276
 
260
277
  try:
261
278
  return json.loads(raw)
chuk_artifacts/grid.py CHANGED
@@ -1,28 +1,161 @@
1
+ # -*- coding: utf-8 -*-
1
2
  # chuk_artifacts/grid.py
2
3
  """Utility helpers for grid-style paths.
3
4
 
4
- Pattern: grid/{sandbox_id}/{session_id}/{artifact_id}[/{subpath}]"""
5
+ Pattern: grid/{sandbox_id}/{session_id}/{artifact_id}[/{subpath}]
6
+
7
+ All components (sandbox_id, session_id, artifact_id) must be non-empty strings
8
+ to ensure proper grid organization and prevent path collisions.
9
+ """
5
10
 
6
11
  from typing import Optional, Dict
7
12
 
8
13
  _ROOT = "grid"
9
14
 
10
15
 
16
+ class GridError(ValueError):
17
+ """Raised when grid path operations encounter invalid input."""
18
+ pass
19
+
20
+
21
+ def _validate_component(component: str, name: str) -> None:
22
+ """Validate a grid path component."""
23
+ if not isinstance(component, str):
24
+ raise GridError(f"{name} must be a string, got {type(component).__name__}")
25
+ if not component:
26
+ raise GridError(f"{name} cannot be empty")
27
+ if "/" in component:
28
+ raise GridError(f"{name} cannot contain '/' characters: {component!r}")
29
+
30
+
11
31
  def canonical_prefix(sandbox_id: str, session_id: str) -> str:
32
+ """
33
+ Generate canonical prefix for a sandbox/session combination.
34
+
35
+ Args:
36
+ sandbox_id: Non-empty sandbox identifier
37
+ session_id: Non-empty session identifier
38
+
39
+ Returns:
40
+ Canonical prefix ending with '/'
41
+
42
+ Raises:
43
+ GridError: If any component is invalid
44
+ """
45
+ _validate_component(sandbox_id, "sandbox_id")
46
+ _validate_component(session_id, "session_id")
47
+
12
48
  return f"{_ROOT}/{sandbox_id}/{session_id}/"
13
49
 
14
50
 
15
51
  def artifact_key(sandbox_id: str, session_id: str, artifact_id: str) -> str:
52
+ """
53
+ Generate artifact key for grid storage.
54
+
55
+ Args:
56
+ sandbox_id: Non-empty sandbox identifier
57
+ session_id: Non-empty session identifier
58
+ artifact_id: Non-empty artifact identifier
59
+
60
+ Returns:
61
+ Grid artifact key
62
+
63
+ Raises:
64
+ GridError: If any component is invalid
65
+ """
66
+ _validate_component(sandbox_id, "sandbox_id")
67
+ _validate_component(session_id, "session_id")
68
+ _validate_component(artifact_id, "artifact_id")
69
+
16
70
  return f"{_ROOT}/{sandbox_id}/{session_id}/{artifact_id}"
17
71
 
18
72
 
19
- def parse(key: str) -> Optional[Dict[str, str]]:
73
+ def parse(key: str) -> Optional[Dict[str, Optional[str]]]:
74
+ """
75
+ Parse a grid key into components.
76
+
77
+ Args:
78
+ key: Grid key to parse
79
+
80
+ Returns:
81
+ Dictionary with components, or None if invalid
82
+
83
+ Examples:
84
+ >>> parse("grid/sandbox/session/artifact")
85
+ {'sandbox_id': 'sandbox', 'session_id': 'session', 'artifact_id': 'artifact', 'subpath': None}
86
+
87
+ >>> parse("grid/sandbox/session/artifact/sub/path")
88
+ {'sandbox_id': 'sandbox', 'session_id': 'session', 'artifact_id': 'artifact', 'subpath': 'sub/path'}
89
+
90
+ >>> parse("invalid/key")
91
+ None
92
+ """
93
+ if not isinstance(key, str):
94
+ return None
95
+
20
96
  parts = key.split("/")
21
- if len(parts) < 4 or parts[0] != _ROOT:
97
+
98
+ # Must have at least 4 parts: root, sandbox, session, artifact
99
+ if len(parts) < 4:
22
100
  return None
101
+
102
+ # Must start with correct root
103
+ if parts[0] != _ROOT:
104
+ return None
105
+
106
+ # Extract components
107
+ sandbox_id = parts[1]
108
+ session_id = parts[2]
109
+ artifact_id = parts[3]
110
+
111
+ # Validate that core components are non-empty
112
+ if not sandbox_id or not session_id or not artifact_id:
113
+ return None
114
+
115
+ # Handle subpath
116
+ subpath = None
117
+ if len(parts) > 4:
118
+ subpath_parts = parts[4:]
119
+ subpath = "/".join(subpath_parts)
120
+ # Convert empty subpath to None for consistency
121
+ if subpath == "":
122
+ subpath = None
123
+
23
124
  return {
24
- "sandbox_id": parts[1],
25
- "session_id": parts[2],
26
- "artifact_id": parts[3] if len(parts) > 3 else None,
27
- "subpath": "/".join(parts[4:]) if len(parts) > 4 else None,
125
+ "sandbox_id": sandbox_id,
126
+ "session_id": session_id,
127
+ "artifact_id": artifact_id,
128
+ "subpath": subpath,
28
129
  }
130
+
131
+
132
+ def is_valid_grid_key(key: str) -> bool:
133
+ """
134
+ Check if a string is a valid grid key.
135
+
136
+ Args:
137
+ key: String to validate
138
+
139
+ Returns:
140
+ True if valid grid key, False otherwise
141
+ """
142
+ return parse(key) is not None
143
+
144
+
145
+ def validate_grid_key(key: str) -> Dict[str, Optional[str]]:
146
+ """
147
+ Validate and parse a grid key, raising an exception if invalid.
148
+
149
+ Args:
150
+ key: Grid key to validate
151
+
152
+ Returns:
153
+ Parsed grid components
154
+
155
+ Raises:
156
+ GridError: If key is invalid
157
+ """
158
+ result = parse(key)
159
+ if result is None:
160
+ raise GridError(f"Invalid grid key: {key!r}")
161
+ return result
@@ -53,6 +53,7 @@ class MetadataOperations:
53
53
  # Delete metadata from session provider
54
54
  session_ctx_mgr = self.artifact_store._session_factory()
55
55
  async with session_ctx_mgr as session:
56
+ # Fix: hasattr is not async, don't await it
56
57
  if hasattr(session, 'delete'):
57
58
  await session.delete(artifact_id)
58
59
 
chuk_artifacts/models.py CHANGED
@@ -1,7 +1,7 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  # chuk_artifacts/models.py
3
3
  from typing import Any, Dict
4
- from pydantic import BaseModel, Field
4
+ from pydantic import BaseModel, Field, ConfigDict
5
5
 
6
6
 
7
7
  class ArtifactEnvelope(BaseModel):
@@ -19,5 +19,5 @@ class ArtifactEnvelope(BaseModel):
19
19
  summary: str # human-readable description / alt
20
20
  meta: Dict[str, Any] = Field(default_factory=dict)
21
21
 
22
- class Config:
23
- extra = "allow" # future-proof: lets tools add keys
22
+ # Pydantic V2 configuration using ConfigDict
23
+ model_config = ConfigDict(extra="allow") # future-proof: lets tools add keys
@@ -88,7 +88,9 @@ class _FilesystemClient:
88
88
 
89
89
  async with self._lock:
90
90
  await self._ensure_parent_dir(object_path)
91
+ # Write the main object file
91
92
  await asyncio.to_thread(object_path.write_bytes, Body)
93
+ # Write the metadata file
92
94
  await self._write_metadata(meta_path, ContentType, Metadata, len(Body), etag)
93
95
 
94
96
  return {
@@ -110,7 +112,16 @@ class _FilesystemClient:
110
112
  meta_path = self._get_metadata_path(object_path)
111
113
 
112
114
  if not object_path.exists():
113
- raise FileNotFoundError(f"NoSuchKey: {Key}")
115
+ # Mimic S3 NoSuchKey error
116
+ error = {
117
+ "Error": {
118
+ "Code": "NoSuchKey",
119
+ "Message": "The specified key does not exist.",
120
+ "Key": Key,
121
+ "BucketName": Bucket,
122
+ }
123
+ }
124
+ raise Exception(f"NoSuchKey: {error}")
114
125
 
115
126
  async with self._lock:
116
127
  body = await asyncio.to_thread(object_path.read_bytes)
@@ -142,7 +153,16 @@ class _FilesystemClient:
142
153
  meta_path = self._get_metadata_path(object_path)
143
154
 
144
155
  if not object_path.exists():
145
- raise FileNotFoundError(f"NoSuchKey: {Key}")
156
+ # Mimic S3 NoSuchKey error
157
+ error = {
158
+ "Error": {
159
+ "Code": "NoSuchKey",
160
+ "Message": "The specified key does not exist.",
161
+ "Key": Key,
162
+ "BucketName": Bucket,
163
+ }
164
+ }
165
+ raise Exception(f"NoSuchKey: {error}")
146
166
 
147
167
  async with self._lock:
148
168
  metadata = await self._read_metadata(meta_path)
@@ -163,7 +183,7 @@ class _FilesystemClient:
163
183
 
164
184
  bucket_path = self._root / Bucket
165
185
  if not bucket_path.exists():
166
- raise FileNotFoundError(f"NoSuchBucket: {Bucket}")
186
+ bucket_path.mkdir(parents=True, exist_ok=True)
167
187
 
168
188
  return {"ResponseMetadata": {"HTTPStatusCode": 200}}
169
189
 
@@ -2,74 +2,30 @@
2
2
  # chuk_artifacts/providers/ibm_cos.py
3
3
  """
4
4
  Factory for an aioboto3 client wired for IBM Cloud Object Storage (COS).
5
- Supports both IAM and HMAC auth.
6
5
 
7
- aioboto3 12 returns an *async-context* client, so we expose
8
- factory() - preferred, used by provider_factory
9
- client() - retained for backward-compat tests/manual use
6
+ AUTO-GENERATED by IBM COS Signature Tester
7
+ Best configuration: Signature v2 + Virtual (IBM COS Alt)
8
+ Score: 10/10
9
+ Tested on: 2025-06-20 16:03:24
10
10
  """
11
11
 
12
12
  from __future__ import annotations
13
13
  import os, aioboto3
14
- from aioboto3.session import AioConfig # ✅ CRITICAL: Import AioConfig
14
+ from aioboto3.session import AioConfig
15
15
  from typing import Optional, Callable, AsyncContextManager
16
16
 
17
- # ──────────────────────────────────────────────────────────────────
18
- # internal helper that actually builds the client
19
- # ──────────────────────────────────────────────────────────────────
20
- def _build_client(
21
- *,
22
- endpoint_url: str,
23
- region: str,
24
- ibm_api_key: Optional[str],
25
- ibm_instance_crn: Optional[str],
26
- access_key: Optional[str],
27
- secret_key: Optional[str],
28
- ):
29
- session = aioboto3.Session()
30
-
31
- # IAM auth (preferred)
32
- if not access_key and not secret_key:
33
- return session.client(
34
- "s3",
35
- endpoint_url=endpoint_url,
36
- region_name=region,
37
- ibm_api_key_id=ibm_api_key,
38
- ibm_service_instance_id=ibm_instance_crn,
39
- # ✅ Use SigV2 for IBM COS IAM + path style
40
- config=AioConfig(
41
- signature_version='s3',
42
- s3={'addressing_style': 'path'}
43
- )
44
- )
45
-
46
- # HMAC auth
47
- return session.client(
48
- "s3",
49
- endpoint_url=endpoint_url,
50
- region_name=region,
51
- aws_access_key_id=access_key,
52
- aws_secret_access_key=secret_key,
53
- # ✅ Use SigV2 for IBM COS HMAC + path style
54
- config=AioConfig(
55
- signature_version='s3',
56
- s3={'addressing_style': 'path'}
57
- )
58
- )
59
-
60
17
 
61
- # ──────────────────────────────────────────────────────────────────
62
- # public factory (provider_factory expects this)
63
- # ──────────────────────────────────────────────────────────────────
64
18
  def factory(
65
19
  *,
66
20
  endpoint_url: Optional[str] = None,
67
21
  region: str = "us-south",
68
22
  access_key: Optional[str] = None,
69
23
  secret_key: Optional[str] = None,
70
- ):
24
+ ) -> Callable[[], AsyncContextManager]:
71
25
  """
72
26
  Return an async-context S3 client for IBM COS (HMAC only).
27
+
28
+ Tested configuration: Signature v2 + Virtual (IBM COS Alt)
73
29
  """
74
30
  endpoint_url = endpoint_url or os.getenv(
75
31
  "IBM_COS_ENDPOINT",
@@ -78,18 +34,21 @@ def factory(
78
34
  access_key = access_key or os.getenv("AWS_ACCESS_KEY_ID")
79
35
  secret_key = secret_key or os.getenv("AWS_SECRET_ACCESS_KEY")
80
36
 
81
- # Extract region from endpoint to ensure they match
37
+ # Extract region from endpoint
82
38
  if endpoint_url:
83
39
  if "us-south" in endpoint_url:
84
40
  region = "us-south"
85
41
  elif "us-east" in endpoint_url:
86
- region = "us-east-1"
42
+ region = "us-east"
87
43
  elif "eu-gb" in endpoint_url:
88
44
  region = "eu-gb"
89
45
  elif "eu-de" in endpoint_url:
90
46
  region = "eu-de"
47
+ elif "jp-tok" in endpoint_url:
48
+ region = "jp-tok"
49
+ elif "au-syd" in endpoint_url:
50
+ region = "au-syd"
91
51
 
92
- # Check AWS_REGION environment variable as override
93
52
  env_region = os.getenv('AWS_REGION')
94
53
  if env_region:
95
54
  region = env_region
@@ -109,13 +68,56 @@ def factory(
109
68
  region_name=region,
110
69
  aws_access_key_id=access_key,
111
70
  aws_secret_access_key=secret_key,
112
- # ✅ CRITICAL: IBM COS requires Signature Version 2 for writes AND presigned URLs
113
71
  config=AioConfig(
114
72
  signature_version='s3',
115
- s3={
116
- 'addressing_style': 'path' # Also ensure path-style addressing
73
+ s3={'addressing_style': 'virtual'},
74
+ read_timeout=60,
75
+ connect_timeout=30,
76
+ retries={
77
+ 'max_attempts': 3,
78
+ 'mode': 'adaptive'
117
79
  }
118
80
  )
119
81
  )
120
82
 
121
- return _make
83
+ return _make
84
+
85
+
86
+ def client(
87
+ *,
88
+ endpoint_url: Optional[str] = None,
89
+ region: Optional[str] = None,
90
+ access_key: Optional[str] = None,
91
+ secret_key: Optional[str] = None,
92
+ ):
93
+ """Return an aioboto3 S3 client context manager for IBM COS."""
94
+ session = aioboto3.Session()
95
+
96
+ endpoint_url = endpoint_url or os.getenv(
97
+ "IBM_COS_ENDPOINT",
98
+ "https://s3.us-south.cloud-object-storage.appdomain.cloud"
99
+ )
100
+
101
+ if not region:
102
+ if "us-south" in endpoint_url:
103
+ region = "us-south"
104
+ elif "us-east" in endpoint_url:
105
+ region = "us-east"
106
+ elif "eu-gb" in endpoint_url:
107
+ region = "eu-gb"
108
+ else:
109
+ region = "us-south"
110
+
111
+ return session.client(
112
+ "s3",
113
+ endpoint_url=endpoint_url,
114
+ region_name=region,
115
+ aws_access_key_id=access_key or os.getenv("AWS_ACCESS_KEY_ID"),
116
+ aws_secret_access_key=secret_key or os.getenv("AWS_SECRET_ACCESS_KEY"),
117
+ config=AioConfig(
118
+ signature_version='s3',
119
+ s3={'addressing_style': 'virtual'},
120
+ read_timeout=60,
121
+ connect_timeout=30
122
+ )
123
+ )