chuk-artifacts 0.3__py3-none-any.whl → 0.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/grid.py +140 -7
- chuk_artifacts/metadata.py +1 -0
- chuk_artifacts/models.py +3 -3
- chuk_artifacts/providers/filesystem.py +23 -3
- chuk_artifacts/providers/ibm_cos.py +61 -59
- chuk_artifacts/providers/ibm_cos_iam.py +51 -7
- chuk_artifacts/providers/memory.py +32 -4
- chuk_artifacts/store.py +47 -1
- chuk_artifacts-0.4.dist-info/METADATA +730 -0
- {chuk_artifacts-0.3.dist-info → chuk_artifacts-0.4.dist-info}/RECORD +12 -12
- chuk_artifacts-0.3.dist-info/METADATA +0 -719
- {chuk_artifacts-0.3.dist-info → chuk_artifacts-0.4.dist-info}/WHEEL +0 -0
- {chuk_artifacts-0.3.dist-info → chuk_artifacts-0.4.dist-info}/top_level.txt +0 -0
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
|
-
|
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":
|
25
|
-
"session_id":
|
26
|
-
"artifact_id":
|
27
|
-
"subpath":
|
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
|
chuk_artifacts/metadata.py
CHANGED
@@ -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
|
-
|
23
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
8
|
-
|
9
|
-
|
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
|
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
|
-
#
|
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
|
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
|
-
|
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
|
+
)
|
@@ -5,6 +5,11 @@ Async wrapper for IBM Cloud Object Storage using IAM API-key (oauth).
|
|
5
5
|
|
6
6
|
✓ Fits the aioboto3-style interface that ArtifactStore expects:
|
7
7
|
• async put_object(...)
|
8
|
+
• async get_object(...)
|
9
|
+
• async head_object(...)
|
10
|
+
• async delete_object(...)
|
11
|
+
• async list_objects_v2(...)
|
12
|
+
• async head_bucket(...)
|
8
13
|
• async generate_presigned_url(...)
|
9
14
|
✓ No HMAC keys required - just IBM_COS_APIKEY + IBM_COS_INSTANCE_CRN.
|
10
15
|
|
@@ -27,6 +32,7 @@ from ibm_botocore.client import Config
|
|
27
32
|
|
28
33
|
# ─────────────────────────────────────────────────────────────────────
|
29
34
|
def _sync_client():
|
35
|
+
"""Create synchronous IBM COS client with IAM authentication."""
|
30
36
|
endpoint = os.getenv(
|
31
37
|
"IBM_COS_ENDPOINT",
|
32
38
|
"https://s3.us-south.cloud-object-storage.appdomain.cloud",
|
@@ -49,19 +55,52 @@ def _sync_client():
|
|
49
55
|
|
50
56
|
# ─────────────────────────────────────────────────────────────────────
|
51
57
|
class _AsyncIBMClient:
|
52
|
-
"""
|
58
|
+
"""Complete async façade over synchronous ibm_boto3 S3 client."""
|
59
|
+
|
53
60
|
def __init__(self, sync_client):
|
54
61
|
self._c = sync_client
|
55
62
|
|
56
|
-
# ----
|
57
|
-
async def put_object(self, **
|
58
|
-
|
63
|
+
# ---- Core S3 operations used by ArtifactStore -------------------------
|
64
|
+
async def put_object(self, **kwargs) -> Dict[str, Any]:
|
65
|
+
"""Store object in IBM COS."""
|
66
|
+
return await asyncio.to_thread(self._c.put_object, **kwargs)
|
59
67
|
|
60
|
-
async def
|
61
|
-
|
68
|
+
async def get_object(self, **kwargs) -> Dict[str, Any]:
|
69
|
+
"""Retrieve object from IBM COS."""
|
70
|
+
return await asyncio.to_thread(self._c.get_object, **kwargs)
|
62
71
|
|
63
|
-
|
72
|
+
async def head_object(self, **kwargs) -> Dict[str, Any]:
|
73
|
+
"""Get object metadata from IBM COS."""
|
74
|
+
return await asyncio.to_thread(self._c.head_object, **kwargs)
|
75
|
+
|
76
|
+
async def delete_object(self, **kwargs) -> Dict[str, Any]:
|
77
|
+
"""Delete object from IBM COS."""
|
78
|
+
return await asyncio.to_thread(self._c.delete_object, **kwargs)
|
79
|
+
|
80
|
+
async def list_objects_v2(self, **kwargs) -> Dict[str, Any]:
|
81
|
+
"""List objects in IBM COS bucket."""
|
82
|
+
return await asyncio.to_thread(self._c.list_objects_v2, **kwargs)
|
83
|
+
|
84
|
+
async def head_bucket(self, **kwargs) -> Dict[str, Any]:
|
85
|
+
"""Check if bucket exists in IBM COS."""
|
86
|
+
return await asyncio.to_thread(self._c.head_bucket, **kwargs)
|
87
|
+
|
88
|
+
async def generate_presigned_url(self, *args, **kwargs) -> str:
|
89
|
+
"""Generate presigned URL for IBM COS object."""
|
90
|
+
return await asyncio.to_thread(self._c.generate_presigned_url, *args, **kwargs)
|
91
|
+
|
92
|
+
# ---- Additional operations for completeness ---------------------------
|
93
|
+
async def copy_object(self, **kwargs) -> Dict[str, Any]:
|
94
|
+
"""Copy object within IBM COS."""
|
95
|
+
return await asyncio.to_thread(self._c.copy_object, **kwargs)
|
96
|
+
|
97
|
+
async def delete_objects(self, **kwargs) -> Dict[str, Any]:
|
98
|
+
"""Delete multiple objects from IBM COS."""
|
99
|
+
return await asyncio.to_thread(self._c.delete_objects, **kwargs)
|
100
|
+
|
101
|
+
# ---- Cleanup -----------------------------------------------------------
|
64
102
|
async def close(self):
|
103
|
+
"""Close the underlying sync client."""
|
65
104
|
await asyncio.to_thread(self._c.close)
|
66
105
|
|
67
106
|
|
@@ -69,6 +108,11 @@ class _AsyncIBMClient:
|
|
69
108
|
def factory() -> Callable[[], AsyncContextManager]:
|
70
109
|
"""
|
71
110
|
Return a zero-arg callable that yields an async-context-manager.
|
111
|
+
|
112
|
+
Returns
|
113
|
+
-------
|
114
|
+
Callable[[], AsyncContextManager]
|
115
|
+
Factory function that creates IBM COS IAM client context managers
|
72
116
|
"""
|
73
117
|
|
74
118
|
@asynccontextmanager
|
@@ -36,6 +36,7 @@ class _MemoryS3Client:
|
|
36
36
|
If None, creates isolated per-instance storage.
|
37
37
|
"""
|
38
38
|
self._store: Dict[str, Dict[str, Any]] = shared_store if shared_store is not None else {}
|
39
|
+
self._is_shared_store = shared_store is not None
|
39
40
|
self._lock = asyncio.Lock()
|
40
41
|
self._closed = False
|
41
42
|
|
@@ -231,8 +232,11 @@ class _MemoryS3Client:
|
|
231
232
|
async def close(self):
|
232
233
|
"""Clean up resources and mark client as closed."""
|
233
234
|
if not self._closed:
|
234
|
-
|
235
|
-
|
235
|
+
# Only clear the store if it's NOT shared
|
236
|
+
# If it's shared, other clients may still need the data
|
237
|
+
if not self._is_shared_store:
|
238
|
+
async with self._lock:
|
239
|
+
self._store.clear()
|
236
240
|
self._closed = True
|
237
241
|
|
238
242
|
# ------------------------------------------------------------
|
@@ -253,6 +257,10 @@ class _MemoryS3Client:
|
|
253
257
|
"total_objects": total_objects,
|
254
258
|
"total_bytes": total_bytes,
|
255
259
|
"closed": self._closed,
|
260
|
+
"is_shared_store": self._is_shared_store,
|
261
|
+
"store_id": id(self._store), # Memory address for debugging
|
262
|
+
"client_id": id(self), # Client instance ID
|
263
|
+
"store_keys": list(self._store.keys())[:5], # First 5 keys for debugging
|
256
264
|
}
|
257
265
|
|
258
266
|
@classmethod
|
@@ -263,17 +271,32 @@ class _MemoryS3Client:
|
|
263
271
|
|
264
272
|
# ---- public factory -------------------------------------------------------
|
265
273
|
|
274
|
+
# Global shared storage for memory provider when used as default
|
275
|
+
_default_shared_store: Dict[str, Dict[str, Any]] = {}
|
276
|
+
|
266
277
|
def factory(shared_store: Optional[Dict[str, Dict[str, Any]]] = None) -> Callable[[], AsyncContextManager]:
|
267
278
|
"""
|
268
279
|
Return a **zero-arg** factory that yields an async-context client.
|
269
280
|
|
281
|
+
Key behavior for memory provider:
|
282
|
+
- If shared_store is provided, uses that specific storage
|
283
|
+
- If shared_store is None, ALWAYS uses the global shared storage
|
284
|
+
- This ensures all memory clients in the same process share data
|
285
|
+
- Prevents issues where ArtifactStore operations can't see each other's data
|
286
|
+
|
270
287
|
Parameters
|
271
288
|
----------
|
272
289
|
shared_store : dict, optional
|
273
290
|
If provided, all clients created by this factory will share
|
274
|
-
the same storage dict.
|
275
|
-
|
291
|
+
the same storage dict. If None, will use a global shared store
|
292
|
+
to ensure consistency across operations within the same process.
|
276
293
|
"""
|
294
|
+
|
295
|
+
# CRITICAL: Always use global shared storage when none specified
|
296
|
+
# This prevents the common issue where each ArtifactStore operation
|
297
|
+
# gets a different isolated storage and can't see each other's data
|
298
|
+
if shared_store is None:
|
299
|
+
shared_store = _default_shared_store
|
277
300
|
|
278
301
|
@asynccontextmanager
|
279
302
|
async def _ctx():
|
@@ -307,6 +330,11 @@ async def clear_all_memory_stores():
|
|
307
330
|
Emergency cleanup function that clears all active memory stores.
|
308
331
|
Useful for test teardown.
|
309
332
|
"""
|
333
|
+
# Clear the global shared store
|
334
|
+
global _default_shared_store
|
335
|
+
_default_shared_store.clear()
|
336
|
+
|
337
|
+
# Close all active instances
|
310
338
|
instances = list(_MemoryS3Client._instances)
|
311
339
|
for instance in instances:
|
312
340
|
try:
|
chuk_artifacts/store.py
CHANGED
@@ -633,4 +633,50 @@ class ArtifactStore:
|
|
633
633
|
return self
|
634
634
|
|
635
635
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
636
|
-
await self.close()
|
636
|
+
await self.close()
|
637
|
+
|
638
|
+
async def get_sandbox_info(self) -> Dict[str, Any]:
|
639
|
+
"""
|
640
|
+
Get sandbox information and metadata.
|
641
|
+
|
642
|
+
Returns
|
643
|
+
-------
|
644
|
+
Dict[str, Any]
|
645
|
+
Dictionary containing sandbox information including:
|
646
|
+
- sandbox_id: The current sandbox identifier
|
647
|
+
- bucket: The storage bucket name
|
648
|
+
- storage_provider: The storage provider type
|
649
|
+
- session_provider: The session provider type
|
650
|
+
- session_ttl_hours: Default session TTL
|
651
|
+
- grid_prefix_pattern: The grid path pattern for this sandbox
|
652
|
+
- created_at: Timestamp of when this info was retrieved
|
653
|
+
"""
|
654
|
+
from datetime import datetime
|
655
|
+
|
656
|
+
# Get session manager stats if available
|
657
|
+
session_stats = {}
|
658
|
+
try:
|
659
|
+
session_stats = self._session_manager.get_cache_stats()
|
660
|
+
except Exception:
|
661
|
+
pass # Session manager might not have stats
|
662
|
+
|
663
|
+
# Get storage stats if available
|
664
|
+
storage_stats = {}
|
665
|
+
try:
|
666
|
+
storage_stats = await self._admin.get_stats()
|
667
|
+
except Exception:
|
668
|
+
pass # Storage might not have stats
|
669
|
+
|
670
|
+
return {
|
671
|
+
"sandbox_id": self.sandbox_id,
|
672
|
+
"bucket": self.bucket,
|
673
|
+
"storage_provider": self._storage_provider_name,
|
674
|
+
"session_provider": self._session_provider_name,
|
675
|
+
"session_ttl_hours": self.session_ttl_hours,
|
676
|
+
"max_retries": self.max_retries,
|
677
|
+
"grid_prefix_pattern": self.get_session_prefix_pattern(),
|
678
|
+
"created_at": datetime.utcnow().isoformat() + "Z",
|
679
|
+
"session_stats": session_stats,
|
680
|
+
"storage_stats": storage_stats,
|
681
|
+
"closed": self._closed,
|
682
|
+
}
|