chuk-artifacts 0.2.2__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/core.py +73 -0
- chuk_artifacts/grid.py +140 -7
- chuk_artifacts/metadata.py +5 -4
- 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 +76 -3
- chuk_artifacts-0.4.dist-info/METADATA +730 -0
- chuk_artifacts-0.4.dist-info/RECORD +23 -0
- chuk_artifacts-0.2.2.dist-info/METADATA +0 -719
- chuk_artifacts-0.2.2.dist-info/RECORD +0 -24
- chuk_artifacts-0.2.2.dist-info/licenses/LICENSE +0 -21
- {chuk_artifacts-0.2.2.dist-info → chuk_artifacts-0.4.dist-info}/WHEEL +0 -0
- {chuk_artifacts-0.2.2.dist-info → chuk_artifacts-0.4.dist-info}/top_level.txt +0 -0
chuk_artifacts/core.py
CHANGED
@@ -101,6 +101,79 @@ class CoreStorageOperations:
|
|
101
101
|
else:
|
102
102
|
raise ProviderError(f"Storage failed: {e}") from e
|
103
103
|
|
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.
|
117
|
+
|
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")
|
137
|
+
|
138
|
+
try:
|
139
|
+
record = await self._get_record(artifact_id)
|
140
|
+
key = record["key"]
|
141
|
+
session_id = record["session_id"]
|
142
|
+
|
143
|
+
# Overwrite in object storage
|
144
|
+
await self._store_with_retry(
|
145
|
+
new_data,
|
146
|
+
key,
|
147
|
+
mime or record["mime"],
|
148
|
+
filename or record.get("filename"),
|
149
|
+
session_id,
|
150
|
+
)
|
151
|
+
|
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
|
+
})
|
163
|
+
|
164
|
+
if ttl is not None:
|
165
|
+
record["ttl"] = ttl
|
166
|
+
|
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))
|
170
|
+
|
171
|
+
logger.info("Artifact updated successfully", extra={"artifact_id": artifact_id})
|
172
|
+
|
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
|
176
|
+
|
104
177
|
async def retrieve(self, artifact_id: str) -> bytes:
|
105
178
|
"""Retrieve artifact data."""
|
106
179
|
if self.artifact_store._closed:
|
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
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
9
9
|
|
10
10
|
import json
|
11
11
|
import logging
|
12
|
-
from typing import Any, Dict, List,
|
12
|
+
from typing import Any, Dict, List, TYPE_CHECKING
|
13
13
|
|
14
14
|
if TYPE_CHECKING:
|
15
15
|
from .store import ArtifactStore
|
@@ -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
|
|
@@ -68,7 +69,7 @@ class MetadataOperations:
|
|
68
69
|
try:
|
69
70
|
artifacts = []
|
70
71
|
# Use the session manager's canonical prefix instead of building our own
|
71
|
-
prefix = self.artifact_store.
|
72
|
+
prefix = self.artifact_store.get_canonical_prefix(session_id)
|
72
73
|
|
73
74
|
storage_ctx_mgr = self.artifact_store._s3_factory()
|
74
75
|
async with storage_ctx_mgr as s3:
|
@@ -82,7 +83,7 @@ class MetadataOperations:
|
|
82
83
|
for obj in response.get('Contents', []):
|
83
84
|
key = obj['Key']
|
84
85
|
# Parse the grid key using chuk_sessions
|
85
|
-
parsed = self.artifact_store.
|
86
|
+
parsed = self.artifact_store.parse_grid_key(key)
|
86
87
|
if parsed and parsed.get('artifact_id'):
|
87
88
|
artifact_id = parsed['artifact_id']
|
88
89
|
try:
|
@@ -93,7 +94,7 @@ class MetadataOperations:
|
|
93
94
|
|
94
95
|
return artifacts[:limit]
|
95
96
|
|
96
|
-
logger.warning(
|
97
|
+
logger.warning("Storage provider doesn't support listing")
|
97
98
|
return []
|
98
99
|
|
99
100
|
except Exception as e:
|
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
|