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.
- chuk_artifacts/__init__.py +5 -1
- chuk_artifacts/core.py +75 -58
- 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 +53 -4
- chuk_artifacts-0.4.1.dist-info/METADATA +730 -0
- chuk_artifacts-0.4.1.dist-info/RECORD +23 -0
- chuk_artifacts-0.3.dist-info/METADATA +0 -719
- chuk_artifacts-0.3.dist-info/RECORD +0 -23
- {chuk_artifacts-0.3.dist-info → chuk_artifacts-0.4.1.dist-info}/WHEEL +0 -0
- {chuk_artifacts-0.3.dist-info → chuk_artifacts-0.4.1.dist-info}/top_level.txt +0 -0
chuk_artifacts/__init__.py
CHANGED
@@ -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
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
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
|
-
|
119
|
-
|
120
|
-
|
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
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
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
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
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
|
-
|
165
|
-
|
179
|
+
# Add update timestamp
|
180
|
+
record["updated_at"] = datetime.utcnow().isoformat() + "Z"
|
166
181
|
|
167
|
-
|
168
|
-
|
169
|
-
|
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
|
-
|
187
|
+
logger.info("Artifact updated successfully", extra={"artifact_id": artifact_id})
|
188
|
+
return True
|
172
189
|
|
173
|
-
|
174
|
-
|
175
|
-
|
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
|
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
|
-
|
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
|
+
)
|