muscles-data-s3 0.1.0__tar.gz

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.
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: muscles-data-s3
3
+ Version: 0.1.0
4
+ Summary: S3-compatible ObjectStorePort adapter for muscles-data
5
+ Author-email: "Denis B." <denis@butko.info>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: muscles-data<1.0.0,>=0.1.0
9
+ Requires-Dist: boto3<2.0,>=1.34
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
12
+
13
+ # muscles-data-s3
14
+
15
+ S3-compatible adapter package for `muscles-data`.
16
+
17
+ This package is intentionally separate from `muscles-data`: the core package
18
+ owns typed ports and runtime, while this package owns the boto3-backed
19
+ `ObjectStorePort` adapter.
20
+
21
+ ## Related packages
22
+
23
+ - Core runtime and port contracts:
24
+ [`muscles-data`](https://github.com/butkoden/muscles-data)
25
+ - Elasticsearch search adapter:
26
+ [`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
27
+ - OpenSearch search adapter:
28
+ [`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
29
+ - Redis key-value/lock/stream adapter:
30
+ [`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
31
+ - Qdrant vector adapter:
32
+ [`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
33
+ - MongoDB document-store adapter:
34
+ [`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
35
+ - SQLAlchemy direct SQL resource adapter:
36
+ [`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
37
+ - Executable example:
38
+ [`example_data_s3_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_s3_1)
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ python -m pip install muscles-data-s3
44
+ ```
45
+
46
+ For local framework development:
47
+
48
+ ```bash
49
+ PYTHONPATH=../muscles-data/src:src python3 -m pytest -q
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ ```yaml
55
+ data:
56
+ resources:
57
+ objects.docs:
58
+ type: s3
59
+ endpoint_url: ${S3_ENDPOINT}
60
+ bucket: documents
61
+ region_name: us-east-1
62
+ prefix: raw
63
+ max_keys: 100
64
+ ```
65
+
66
+ The adapter also supports standard boto3 credential options:
67
+
68
+ - `aws_access_key_id`;
69
+ - `aws_secret_access_key`;
70
+ - `aws_session_token`;
71
+ - `profile_name`;
72
+ - the default boto3 environment/provider chain.
73
+
74
+ ## Usage
75
+
76
+ Register the external factory in the project composition root:
77
+
78
+ ```python
79
+ from muscles_data.catalog import DataAdapterCatalog
80
+ from muscles_data.config import DataConfig
81
+ from muscles_data.ports import ObjectStorePort
82
+ from muscles_data.runtime import DataRuntime
83
+ from muscles_data_s3 import S3ObjectStoreFactory
84
+
85
+ catalog = DataAdapterCatalog.with_defaults()
86
+ catalog.register(S3ObjectStoreFactory())
87
+
88
+ runtime = DataRuntime(config=DataConfig.from_raw(config), catalog=catalog)
89
+ objects = runtime.require_port("objects.docs", ObjectStorePort)
90
+ ```
91
+
92
+ Then use the narrow port:
93
+
94
+ ```python
95
+ objects.put_object("docs/readme.txt", b"hello", content_type="text/plain")
96
+ blob = objects.get_object("docs/readme.txt")
97
+ items = objects.list_objects(prefix="docs", limit=20)
98
+ objects.delete_object("docs/readme.txt")
99
+ ```
100
+
101
+ Backend-specific request options are available without exposing the boto3
102
+ client: `get_object()` accepts `range`, conditional headers and
103
+ version/checksum options; `list_objects()` accepts `delimiter` and continuation
104
+ helpers; and `delete_object()` accepts version and retention options.
105
+
106
+ If `prefix: raw` is configured, the public key `docs/readme.txt` is stored as
107
+ `raw/docs/readme.txt` in S3 and returned to port consumers without the configured
108
+ prefix. This keeps application code independent from bucket layout details.
109
+
110
+ ## Capabilities
111
+
112
+ `S3ObjectStoreFactory` provides:
113
+
114
+ - `object_store`;
115
+ - `healthcheck`;
116
+ - `native_client` only when the resource declares `native_client: true`.
117
+
118
+ Native access is an advanced project escape hatch:
119
+
120
+ ```python
121
+ from muscles_data import DataCapability
122
+
123
+ client = runtime.require_resource("objects.docs", DataCapability.NATIVE_CLIENT).native_client()
124
+ ```
125
+
126
+ Use the native client for backend-specific operations such as presigned URLs,
127
+ multipart upload configuration, bucket policies or lifecycle rules. Keep normal
128
+ blob reads and writes on `ObjectStorePort`.
129
+
130
+ ## Boundaries
131
+
132
+ This package owns:
133
+
134
+ - lazy boto3 client creation;
135
+ - S3-compatible endpoint and bucket binding;
136
+ - object put/get/list/delete;
137
+ - key normalization and optional prefix mapping;
138
+ - safe `inspect()` and `doctor()`.
139
+
140
+ It does not own:
141
+
142
+ - document parsing;
143
+ - application storage schemas;
144
+ - lifecycle policies;
145
+ - multipart/streaming abstractions in the MVP;
146
+ - cross-resource transactions.
147
+
148
+ `data.doctor` performs `head_bucket`. `data.resources.list` and package
149
+ initialization do not open an S3 connection.
@@ -0,0 +1,137 @@
1
+ # muscles-data-s3
2
+
3
+ S3-compatible adapter package for `muscles-data`.
4
+
5
+ This package is intentionally separate from `muscles-data`: the core package
6
+ owns typed ports and runtime, while this package owns the boto3-backed
7
+ `ObjectStorePort` adapter.
8
+
9
+ ## Related packages
10
+
11
+ - Core runtime and port contracts:
12
+ [`muscles-data`](https://github.com/butkoden/muscles-data)
13
+ - Elasticsearch search adapter:
14
+ [`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
15
+ - OpenSearch search adapter:
16
+ [`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
17
+ - Redis key-value/lock/stream adapter:
18
+ [`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
19
+ - Qdrant vector adapter:
20
+ [`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
21
+ - MongoDB document-store adapter:
22
+ [`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
23
+ - SQLAlchemy direct SQL resource adapter:
24
+ [`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
25
+ - Executable example:
26
+ [`example_data_s3_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_s3_1)
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ python -m pip install muscles-data-s3
32
+ ```
33
+
34
+ For local framework development:
35
+
36
+ ```bash
37
+ PYTHONPATH=../muscles-data/src:src python3 -m pytest -q
38
+ ```
39
+
40
+ ## Configuration
41
+
42
+ ```yaml
43
+ data:
44
+ resources:
45
+ objects.docs:
46
+ type: s3
47
+ endpoint_url: ${S3_ENDPOINT}
48
+ bucket: documents
49
+ region_name: us-east-1
50
+ prefix: raw
51
+ max_keys: 100
52
+ ```
53
+
54
+ The adapter also supports standard boto3 credential options:
55
+
56
+ - `aws_access_key_id`;
57
+ - `aws_secret_access_key`;
58
+ - `aws_session_token`;
59
+ - `profile_name`;
60
+ - the default boto3 environment/provider chain.
61
+
62
+ ## Usage
63
+
64
+ Register the external factory in the project composition root:
65
+
66
+ ```python
67
+ from muscles_data.catalog import DataAdapterCatalog
68
+ from muscles_data.config import DataConfig
69
+ from muscles_data.ports import ObjectStorePort
70
+ from muscles_data.runtime import DataRuntime
71
+ from muscles_data_s3 import S3ObjectStoreFactory
72
+
73
+ catalog = DataAdapterCatalog.with_defaults()
74
+ catalog.register(S3ObjectStoreFactory())
75
+
76
+ runtime = DataRuntime(config=DataConfig.from_raw(config), catalog=catalog)
77
+ objects = runtime.require_port("objects.docs", ObjectStorePort)
78
+ ```
79
+
80
+ Then use the narrow port:
81
+
82
+ ```python
83
+ objects.put_object("docs/readme.txt", b"hello", content_type="text/plain")
84
+ blob = objects.get_object("docs/readme.txt")
85
+ items = objects.list_objects(prefix="docs", limit=20)
86
+ objects.delete_object("docs/readme.txt")
87
+ ```
88
+
89
+ Backend-specific request options are available without exposing the boto3
90
+ client: `get_object()` accepts `range`, conditional headers and
91
+ version/checksum options; `list_objects()` accepts `delimiter` and continuation
92
+ helpers; and `delete_object()` accepts version and retention options.
93
+
94
+ If `prefix: raw` is configured, the public key `docs/readme.txt` is stored as
95
+ `raw/docs/readme.txt` in S3 and returned to port consumers without the configured
96
+ prefix. This keeps application code independent from bucket layout details.
97
+
98
+ ## Capabilities
99
+
100
+ `S3ObjectStoreFactory` provides:
101
+
102
+ - `object_store`;
103
+ - `healthcheck`;
104
+ - `native_client` only when the resource declares `native_client: true`.
105
+
106
+ Native access is an advanced project escape hatch:
107
+
108
+ ```python
109
+ from muscles_data import DataCapability
110
+
111
+ client = runtime.require_resource("objects.docs", DataCapability.NATIVE_CLIENT).native_client()
112
+ ```
113
+
114
+ Use the native client for backend-specific operations such as presigned URLs,
115
+ multipart upload configuration, bucket policies or lifecycle rules. Keep normal
116
+ blob reads and writes on `ObjectStorePort`.
117
+
118
+ ## Boundaries
119
+
120
+ This package owns:
121
+
122
+ - lazy boto3 client creation;
123
+ - S3-compatible endpoint and bucket binding;
124
+ - object put/get/list/delete;
125
+ - key normalization and optional prefix mapping;
126
+ - safe `inspect()` and `doctor()`.
127
+
128
+ It does not own:
129
+
130
+ - document parsing;
131
+ - application storage schemas;
132
+ - lifecycle policies;
133
+ - multipart/streaming abstractions in the MVP;
134
+ - cross-resource transactions.
135
+
136
+ `data.doctor` performs `head_bucket`. `data.resources.list` and package
137
+ initialization do not open an S3 connection.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "muscles-data-s3"
7
+ version = "0.1.0"
8
+ description = "S3-compatible ObjectStorePort adapter for muscles-data"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Denis B.", email = "denis@butko.info" }]
12
+ dependencies = [
13
+ "muscles-data>=0.1.0,<1.0.0",
14
+ "boto3>=1.34,<2.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest>=8.0,<9.0",
20
+ ]
21
+
22
+ [tool.pytest.ini_options]
23
+ testpaths = ["tests"]
24
+ markers = [
25
+ "integration: tests that require the local backend integration stack",
26
+ ]
27
+
28
+ [tool.setuptools]
29
+ package-dir = {"" = "src"}
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from .adapter import (
4
+ S3AdapterError,
5
+ S3ClientMissingError,
6
+ S3ConfigError,
7
+ S3ConnectionError,
8
+ S3ObjectStoreAdapter,
9
+ S3ObjectStoreFactory,
10
+ )
11
+
12
+
13
+ __all__ = [
14
+ "S3AdapterError",
15
+ "S3ClientMissingError",
16
+ "S3ConfigError",
17
+ "S3ConnectionError",
18
+ "S3ObjectStoreAdapter",
19
+ "S3ObjectStoreFactory",
20
+ ]
@@ -0,0 +1,424 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import threading
5
+ from collections.abc import Callable
6
+ from dataclasses import asdict
7
+ from pathlib import PurePosixPath
8
+ from typing import Any, Mapping
9
+ from urllib.parse import urlsplit
10
+
11
+ from muscles_data.config import DataResourceConfig
12
+ from muscles_data.errors import DataError
13
+ from muscles_data.models import DataCapability, HealthResult, InspectResult, ObjectBlob, ObjectInfo, WriteResult
14
+
15
+
16
+ _CLIENT_UNSET = object()
17
+ _ALLOWED_OPTIONS = {
18
+ "endpoint_url",
19
+ "bucket",
20
+ "region_name",
21
+ "aws_access_key_id",
22
+ "aws_secret_access_key",
23
+ "aws_session_token",
24
+ "profile_name",
25
+ "prefix",
26
+ "max_keys",
27
+ "native_client",
28
+ "timeout",
29
+ "connect_timeout",
30
+ "read_timeout",
31
+ "addressing_style",
32
+ "verify",
33
+ "use_ssl",
34
+ }
35
+ _SECRET_MARKERS = ("password", "passwd", "secret", "token", "api_key", "apikey", "credential", "access_key")
36
+
37
+
38
+ class S3AdapterError(DataError):
39
+ """Base error for S3 object-store adapter failures."""
40
+
41
+
42
+ class S3ConfigError(ValueError, S3AdapterError):
43
+ """Raised when an S3 resource config cannot be mapped safely."""
44
+
45
+
46
+ class S3ClientMissingError(S3AdapterError):
47
+ """Raised when S3 adapter is used without an available client."""
48
+
49
+
50
+ class S3ConnectionError(S3AdapterError):
51
+ """Raised when an S3 operation cannot reach or use the backend."""
52
+
53
+
54
+ class S3ObjectStoreAdapter:
55
+ resource_type = "s3"
56
+
57
+ def __init__(
58
+ self,
59
+ config: DataResourceConfig,
60
+ *,
61
+ client_factory: Callable[[DataResourceConfig], Any] | None = None,
62
+ ) -> None:
63
+ self.config = config
64
+ self._client_factory = client_factory
65
+ self._client: Any = _CLIENT_UNSET
66
+ self._lock = threading.RLock()
67
+ self.closed = False
68
+
69
+ def put_object(
70
+ self,
71
+ key: str,
72
+ content: bytes,
73
+ content_type: str | None = None,
74
+ metadata: Mapping[str, Any] | None = None,
75
+ options: Mapping[str, Any] | None = None,
76
+ ) -> WriteResult:
77
+ options = dict(options or {})
78
+ kwargs: dict[str, Any] = {
79
+ "Bucket": self.bucket_name(),
80
+ "Key": self._storage_key(key),
81
+ "Body": bytes(content),
82
+ }
83
+ if content_type:
84
+ kwargs["ContentType"] = content_type
85
+ normalized_metadata = _normalize_metadata(metadata)
86
+ if normalized_metadata:
87
+ kwargs["Metadata"] = normalized_metadata
88
+ for option_key, target_key in {
89
+ "cache_control": "CacheControl",
90
+ "content_disposition": "ContentDisposition",
91
+ "content_encoding": "ContentEncoding",
92
+ }.items():
93
+ if option_key in options:
94
+ kwargs[target_key] = options[option_key]
95
+
96
+ try:
97
+ self._client_instance().put_object(**kwargs)
98
+ except (S3ClientMissingError, S3ConfigError):
99
+ raise
100
+ except Exception as exc:
101
+ raise S3ConnectionError(self._safe_error(exc)) from exc
102
+ return WriteResult(written=1, matched=1)
103
+
104
+ def get_object(self, key: str, options: Mapping[str, Any] | None = None) -> ObjectBlob:
105
+ normalized = _normalize_object_key(key)
106
+ request: dict[str, Any] = {
107
+ "Bucket": self.bucket_name(),
108
+ "Key": self._storage_key(normalized),
109
+ }
110
+ for option_key, request_key in {
111
+ "version_id": "VersionId",
112
+ "range": "Range",
113
+ "if_match": "IfMatch",
114
+ "if_none_match": "IfNoneMatch",
115
+ "checksum_mode": "ChecksumMode",
116
+ }.items():
117
+ if option_key in (options or {}):
118
+ request[request_key] = (options or {})[option_key]
119
+ try:
120
+ response = self._client_instance().get_object(**request)
121
+ body = response.get("Body")
122
+ content = body.read() if hasattr(body, "read") else body
123
+ except (S3ClientMissingError, S3ConfigError):
124
+ raise
125
+ except Exception as exc:
126
+ raise S3ConnectionError(self._safe_error(exc)) from exc
127
+ return ObjectBlob(
128
+ key=normalized,
129
+ content=bytes(content or b""),
130
+ content_type=response.get("ContentType"),
131
+ metadata=dict(response.get("Metadata") or {}),
132
+ )
133
+
134
+ def list_objects(
135
+ self,
136
+ prefix: str | None = None,
137
+ limit: int = 100,
138
+ cursor: str | None = None,
139
+ options: Mapping[str, Any] | None = None,
140
+ ) -> list[ObjectInfo]:
141
+ bounded_limit = self._bounded_limit(limit)
142
+ kwargs: dict[str, Any] = {
143
+ "Bucket": self.bucket_name(),
144
+ "MaxKeys": bounded_limit,
145
+ }
146
+ storage_prefix = self._storage_prefix(prefix)
147
+ if storage_prefix:
148
+ kwargs["Prefix"] = storage_prefix
149
+ if cursor:
150
+ kwargs["ContinuationToken"] = str(cursor)
151
+ for option_key, request_key in {
152
+ "delimiter": "Delimiter",
153
+ "start_after": "StartAfter",
154
+ "fetch_owner": "FetchOwner",
155
+ "request_payer": "RequestPayer",
156
+ }.items():
157
+ if option_key in (options or {}):
158
+ kwargs[request_key] = (options or {})[option_key]
159
+ try:
160
+ response = self._client_instance().list_objects_v2(**kwargs)
161
+ except (S3ClientMissingError, S3ConfigError):
162
+ raise
163
+ except Exception as exc:
164
+ raise S3ConnectionError(self._safe_error(exc)) from exc
165
+
166
+ output: list[ObjectInfo] = []
167
+ for item in response.get("Contents", []) or []:
168
+ key = self._public_key(str(item.get("Key", "")))
169
+ output.append(
170
+ ObjectInfo(
171
+ key=key,
172
+ size=int(item.get("Size", 0) or 0),
173
+ metadata=_object_info_metadata(item),
174
+ )
175
+ )
176
+ return output
177
+
178
+ def delete_object(self, key: str, options: Mapping[str, Any] | None = None) -> WriteResult:
179
+ request: dict[str, Any] = {
180
+ "Bucket": self.bucket_name(),
181
+ "Key": self._storage_key(key),
182
+ }
183
+ for option_key, request_key in {
184
+ "version_id": "VersionId",
185
+ "mfa": "MFA",
186
+ "bypass_governance_retention": "BypassGovernanceRetention",
187
+ "request_payer": "RequestPayer",
188
+ }.items():
189
+ if option_key in (options or {}):
190
+ request[request_key] = (options or {})[option_key]
191
+ try:
192
+ self._client_instance().delete_object(**request)
193
+ except (S3ClientMissingError, S3ConfigError):
194
+ raise
195
+ except Exception as exc:
196
+ raise S3ConnectionError(self._safe_error(exc)) from exc
197
+ return WriteResult(deleted=1, matched=1)
198
+
199
+ def inspect(self) -> dict[str, Any]:
200
+ return asdict(
201
+ InspectResult(
202
+ name=self.config.name,
203
+ type=self.config.type,
204
+ capabilities=[],
205
+ initialized=self._client is not _CLIENT_UNSET,
206
+ status="ok",
207
+ options=self.config.safe_options(),
208
+ details={
209
+ "backend": "s3",
210
+ "bucket": self.bucket_name(),
211
+ "prefix": self.prefix(),
212
+ "max_keys": self.max_keys(),
213
+ },
214
+ )
215
+ )
216
+
217
+ def health(self) -> HealthResult:
218
+ try:
219
+ bucket = self.bucket_name()
220
+ self._client_instance().head_bucket(Bucket=bucket)
221
+ except Exception as exc:
222
+ return HealthResult(status="failed", message=self._safe_error(exc), details=self._safe_details())
223
+ return HealthResult(status="ok", message="S3 bucket is available", details=self._safe_details())
224
+
225
+ def close(self) -> None:
226
+ if self._client is _CLIENT_UNSET:
227
+ self.closed = True
228
+ return
229
+ close = getattr(self._client, "close", None)
230
+ if callable(close):
231
+ close()
232
+ self.closed = True
233
+
234
+ def native_client(self):
235
+ return self._client_instance()
236
+
237
+ def bucket_name(self) -> str:
238
+ bucket = str(self.config.options.get("bucket", "")).strip()
239
+ if not bucket:
240
+ raise S3ConfigError("S3 resource requires bucket")
241
+ return bucket
242
+
243
+ def prefix(self) -> str | None:
244
+ return _normalize_object_prefix(self.config.options.get("prefix"))
245
+
246
+ def max_keys(self) -> int:
247
+ value = int(self.config.options.get("max_keys", 1000))
248
+ if value <= 0:
249
+ raise S3ConfigError("S3 max_keys must be positive")
250
+ return value
251
+
252
+ def _bounded_limit(self, limit: int) -> int:
253
+ return min(max(0, int(limit)), self.max_keys())
254
+
255
+ def _storage_key(self, key: str) -> str:
256
+ normalized = _normalize_object_key(key)
257
+ prefix = self.prefix()
258
+ return f"{prefix}{normalized}" if prefix else normalized
259
+
260
+ def _storage_prefix(self, prefix: str | None) -> str | None:
261
+ configured = self.prefix() or ""
262
+ requested = _normalize_object_prefix(prefix)
263
+ if requested is None:
264
+ return configured or None
265
+ return f"{configured}{requested}"
266
+
267
+ def _public_key(self, storage_key: str) -> str:
268
+ prefix = self.prefix()
269
+ if prefix and storage_key.startswith(prefix):
270
+ return storage_key[len(prefix) :]
271
+ return storage_key
272
+
273
+ def _client_instance(self):
274
+ if self._client is _CLIENT_UNSET:
275
+ with self._lock:
276
+ if self._client is _CLIENT_UNSET:
277
+ self._validate_options()
278
+ client = self._client_factory(self.config) if self._client_factory else _default_s3_client(self.config)
279
+ if client is None:
280
+ raise S3ClientMissingError("S3 client is not available")
281
+ self._client = client
282
+ return self._client
283
+
284
+ def _validate_options(self) -> None:
285
+ unknown = sorted(set(self.config.options) - _ALLOWED_OPTIONS)
286
+ if unknown:
287
+ names = ", ".join(unknown)
288
+ raise S3ConfigError(f"Unsupported S3 resource options: {names}")
289
+ self.bucket_name()
290
+ self.prefix()
291
+ self.max_keys()
292
+
293
+ def _safe_details(self) -> dict[str, Any]:
294
+ details: dict[str, Any] = {"backend": "s3"}
295
+ try:
296
+ details["bucket"] = self.bucket_name()
297
+ except S3ConfigError:
298
+ pass
299
+ try:
300
+ details["prefix"] = self.prefix()
301
+ except S3ConfigError:
302
+ pass
303
+ return details
304
+
305
+ def _safe_error(self, exc: Exception) -> str:
306
+ message = str(exc)
307
+ sensitive_values = set()
308
+ for key, value in self.config.options.items():
309
+ if any(marker in str(key).lower() for marker in _SECRET_MARKERS) and value:
310
+ sensitive_values.add(str(value))
311
+ endpoint_url = str(self.config.options.get("endpoint_url", ""))
312
+ if endpoint_url:
313
+ sensitive_values.add(endpoint_url)
314
+ try:
315
+ parsed = urlsplit(endpoint_url)
316
+ if parsed.username:
317
+ sensitive_values.add(parsed.username)
318
+ if parsed.password:
319
+ sensitive_values.add(parsed.password)
320
+ except Exception: # pragma: no cover
321
+ pass
322
+ for value in sorted((item for item in sensitive_values if item), key=len, reverse=True):
323
+ message = message.replace(value, "***")
324
+ return message
325
+
326
+
327
+ class S3ObjectStoreFactory:
328
+ resource_type = "s3"
329
+
330
+ def __init__(self, *, client_factory: Callable[[DataResourceConfig], Any] | None = None) -> None:
331
+ self._client_factory = client_factory
332
+
333
+ def capabilities(self, config: DataResourceConfig) -> set[DataCapability]:
334
+ native = {DataCapability.NATIVE_CLIENT} if bool(config.options.get("native_client")) else set()
335
+ return {DataCapability.OBJECT_STORE, DataCapability.HEALTHCHECK} | native
336
+
337
+ def create(self, config: DataResourceConfig) -> S3ObjectStoreAdapter:
338
+ return S3ObjectStoreAdapter(config, client_factory=self._client_factory)
339
+
340
+
341
+ def _default_s3_client(config: DataResourceConfig):
342
+ try:
343
+ boto3 = importlib.import_module("boto3")
344
+ except ImportError as exc:
345
+ raise S3ClientMissingError("boto3 package is not installed") from exc
346
+
347
+ kwargs: dict[str, Any] = {}
348
+ for option_key in (
349
+ "endpoint_url",
350
+ "region_name",
351
+ "aws_access_key_id",
352
+ "aws_secret_access_key",
353
+ "aws_session_token",
354
+ "verify",
355
+ "use_ssl",
356
+ ):
357
+ if option_key in config.options:
358
+ kwargs[option_key] = config.options[option_key]
359
+
360
+ client_config = _botocore_config(config)
361
+ if client_config is not None:
362
+ kwargs["config"] = client_config
363
+
364
+ profile_name = config.options.get("profile_name")
365
+ if profile_name:
366
+ session_kwargs: dict[str, Any] = {"profile_name": profile_name}
367
+ if "region_name" in kwargs:
368
+ session_kwargs["region_name"] = kwargs.pop("region_name")
369
+ session = boto3.session.Session(**session_kwargs)
370
+ return session.client("s3", **kwargs)
371
+ return boto3.client("s3", **kwargs)
372
+
373
+
374
+ def _botocore_config(config: DataResourceConfig):
375
+ config_kwargs: dict[str, Any] = {}
376
+ if "timeout" in config.options:
377
+ timeout = float(config.options["timeout"])
378
+ config_kwargs["connect_timeout"] = timeout
379
+ config_kwargs["read_timeout"] = timeout
380
+ if "connect_timeout" in config.options:
381
+ config_kwargs["connect_timeout"] = float(config.options["connect_timeout"])
382
+ if "read_timeout" in config.options:
383
+ config_kwargs["read_timeout"] = float(config.options["read_timeout"])
384
+ if "addressing_style" in config.options:
385
+ config_kwargs["s3"] = {"addressing_style": str(config.options["addressing_style"])}
386
+ if not config_kwargs:
387
+ return None
388
+ try:
389
+ config_module = importlib.import_module("botocore.config")
390
+ except ImportError as exc:
391
+ raise S3ClientMissingError("botocore package is not installed") from exc
392
+ return config_module.Config(**config_kwargs)
393
+
394
+
395
+ def _normalize_object_key(key: str) -> str:
396
+ path = PurePosixPath(str(key))
397
+ if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
398
+ raise S3ConfigError(f"unsafe object key: {key}")
399
+ return path.as_posix()
400
+
401
+
402
+ def _normalize_object_prefix(prefix: Any) -> str | None:
403
+ if prefix is None:
404
+ return None
405
+ cleaned = str(prefix).strip("/")
406
+ if not cleaned:
407
+ return None
408
+ return _normalize_object_key(cleaned) + "/"
409
+
410
+
411
+ def _normalize_metadata(metadata: Mapping[str, Any] | None) -> dict[str, str]:
412
+ return {str(key): str(value) for key, value in dict(metadata or {}).items()}
413
+
414
+
415
+ def _object_info_metadata(item: Mapping[str, Any]) -> dict[str, Any]:
416
+ metadata: dict[str, Any] = {}
417
+ for source_key, target_key in {
418
+ "ETag": "etag",
419
+ "LastModified": "last_modified",
420
+ "StorageClass": "storage_class",
421
+ }.items():
422
+ if source_key in item:
423
+ metadata[target_key] = item[source_key]
424
+ return metadata
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: muscles-data-s3
3
+ Version: 0.1.0
4
+ Summary: S3-compatible ObjectStorePort adapter for muscles-data
5
+ Author-email: "Denis B." <denis@butko.info>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: muscles-data<1.0.0,>=0.1.0
9
+ Requires-Dist: boto3<2.0,>=1.34
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
12
+
13
+ # muscles-data-s3
14
+
15
+ S3-compatible adapter package for `muscles-data`.
16
+
17
+ This package is intentionally separate from `muscles-data`: the core package
18
+ owns typed ports and runtime, while this package owns the boto3-backed
19
+ `ObjectStorePort` adapter.
20
+
21
+ ## Related packages
22
+
23
+ - Core runtime and port contracts:
24
+ [`muscles-data`](https://github.com/butkoden/muscles-data)
25
+ - Elasticsearch search adapter:
26
+ [`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
27
+ - OpenSearch search adapter:
28
+ [`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
29
+ - Redis key-value/lock/stream adapter:
30
+ [`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
31
+ - Qdrant vector adapter:
32
+ [`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
33
+ - MongoDB document-store adapter:
34
+ [`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
35
+ - SQLAlchemy direct SQL resource adapter:
36
+ [`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
37
+ - Executable example:
38
+ [`example_data_s3_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_s3_1)
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ python -m pip install muscles-data-s3
44
+ ```
45
+
46
+ For local framework development:
47
+
48
+ ```bash
49
+ PYTHONPATH=../muscles-data/src:src python3 -m pytest -q
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ ```yaml
55
+ data:
56
+ resources:
57
+ objects.docs:
58
+ type: s3
59
+ endpoint_url: ${S3_ENDPOINT}
60
+ bucket: documents
61
+ region_name: us-east-1
62
+ prefix: raw
63
+ max_keys: 100
64
+ ```
65
+
66
+ The adapter also supports standard boto3 credential options:
67
+
68
+ - `aws_access_key_id`;
69
+ - `aws_secret_access_key`;
70
+ - `aws_session_token`;
71
+ - `profile_name`;
72
+ - the default boto3 environment/provider chain.
73
+
74
+ ## Usage
75
+
76
+ Register the external factory in the project composition root:
77
+
78
+ ```python
79
+ from muscles_data.catalog import DataAdapterCatalog
80
+ from muscles_data.config import DataConfig
81
+ from muscles_data.ports import ObjectStorePort
82
+ from muscles_data.runtime import DataRuntime
83
+ from muscles_data_s3 import S3ObjectStoreFactory
84
+
85
+ catalog = DataAdapterCatalog.with_defaults()
86
+ catalog.register(S3ObjectStoreFactory())
87
+
88
+ runtime = DataRuntime(config=DataConfig.from_raw(config), catalog=catalog)
89
+ objects = runtime.require_port("objects.docs", ObjectStorePort)
90
+ ```
91
+
92
+ Then use the narrow port:
93
+
94
+ ```python
95
+ objects.put_object("docs/readme.txt", b"hello", content_type="text/plain")
96
+ blob = objects.get_object("docs/readme.txt")
97
+ items = objects.list_objects(prefix="docs", limit=20)
98
+ objects.delete_object("docs/readme.txt")
99
+ ```
100
+
101
+ Backend-specific request options are available without exposing the boto3
102
+ client: `get_object()` accepts `range`, conditional headers and
103
+ version/checksum options; `list_objects()` accepts `delimiter` and continuation
104
+ helpers; and `delete_object()` accepts version and retention options.
105
+
106
+ If `prefix: raw` is configured, the public key `docs/readme.txt` is stored as
107
+ `raw/docs/readme.txt` in S3 and returned to port consumers without the configured
108
+ prefix. This keeps application code independent from bucket layout details.
109
+
110
+ ## Capabilities
111
+
112
+ `S3ObjectStoreFactory` provides:
113
+
114
+ - `object_store`;
115
+ - `healthcheck`;
116
+ - `native_client` only when the resource declares `native_client: true`.
117
+
118
+ Native access is an advanced project escape hatch:
119
+
120
+ ```python
121
+ from muscles_data import DataCapability
122
+
123
+ client = runtime.require_resource("objects.docs", DataCapability.NATIVE_CLIENT).native_client()
124
+ ```
125
+
126
+ Use the native client for backend-specific operations such as presigned URLs,
127
+ multipart upload configuration, bucket policies or lifecycle rules. Keep normal
128
+ blob reads and writes on `ObjectStorePort`.
129
+
130
+ ## Boundaries
131
+
132
+ This package owns:
133
+
134
+ - lazy boto3 client creation;
135
+ - S3-compatible endpoint and bucket binding;
136
+ - object put/get/list/delete;
137
+ - key normalization and optional prefix mapping;
138
+ - safe `inspect()` and `doctor()`.
139
+
140
+ It does not own:
141
+
142
+ - document parsing;
143
+ - application storage schemas;
144
+ - lifecycle policies;
145
+ - multipart/streaming abstractions in the MVP;
146
+ - cross-resource transactions.
147
+
148
+ `data.doctor` performs `head_bucket`. `data.resources.list` and package
149
+ initialization do not open an S3 connection.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/muscles_data_s3/__init__.py
4
+ src/muscles_data_s3/adapter.py
5
+ src/muscles_data_s3.egg-info/PKG-INFO
6
+ src/muscles_data_s3.egg-info/SOURCES.txt
7
+ src/muscles_data_s3.egg-info/dependency_links.txt
8
+ src/muscles_data_s3.egg-info/requires.txt
9
+ src/muscles_data_s3.egg-info/top_level.txt
10
+ tests/test_integration.py
11
+ tests/test_s3_adapter.py
@@ -0,0 +1,5 @@
1
+ muscles-data<1.0.0,>=0.1.0
2
+ boto3<2.0,>=1.34
3
+
4
+ [dev]
5
+ pytest<9.0,>=8.0
@@ -0,0 +1 @@
1
+ muscles_data_s3
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from uuid import uuid4
5
+
6
+ import pytest
7
+ from muscles_data.catalog import DataAdapterCatalog
8
+ from muscles_data.config import DataConfig
9
+ from muscles_data.models import DataCapability
10
+ from muscles_data.ports import ObjectStorePort
11
+ from muscles_data.runtime import DataRuntime
12
+
13
+ from muscles_data_s3 import S3ObjectStoreFactory
14
+
15
+
16
+ pytestmark = [
17
+ pytest.mark.integration,
18
+ pytest.mark.skipif(not os.getenv("MUSCLES_DATA_INTEGRATION"), reason="backend integration is disabled"),
19
+ ]
20
+
21
+
22
+ def test_s3_real_object_lifecycle_against_minio():
23
+ bucket = f"muscles-data-it-{uuid4().hex[:12]}"
24
+ config = DataConfig.from_raw(
25
+ {
26
+ "data": {
27
+ "resources": {
28
+ "objects.s3": {
29
+ "type": "s3",
30
+ "endpoint_url": os.environ["S3_ENDPOINT_URL"],
31
+ "bucket": bucket,
32
+ "region_name": "us-east-1",
33
+ "aws_access_key_id": "minioadmin",
34
+ "aws_secret_access_key": "minioadmin",
35
+ "addressing_style": "path",
36
+ "native_client": True,
37
+ }
38
+ }
39
+ }
40
+ }
41
+ )
42
+ catalog = DataAdapterCatalog.with_defaults()
43
+ catalog.register(S3ObjectStoreFactory())
44
+ runtime = DataRuntime(config=config, catalog=catalog)
45
+
46
+ client = None
47
+ try:
48
+ client = runtime.require_resource("objects.s3", DataCapability.NATIVE_CLIENT).native_client()
49
+ client.create_bucket(Bucket=bucket)
50
+ store = runtime.require_port("objects.s3", ObjectStorePort)
51
+ contracts = pytest.importorskip("muscles_data.contracts")
52
+ contract = getattr(contracts, "assert_object_store_contract", None)
53
+ if contract is not None:
54
+ contract(lambda: store)
55
+ assert runtime.doctor()["status"] == "ok"
56
+ finally:
57
+ try:
58
+ if client is not None:
59
+ objects = client.list_objects_v2(Bucket=bucket).get("Contents", [])
60
+ for item in objects:
61
+ client.delete_object(Bucket=bucket, Key=item["Key"])
62
+ client.delete_bucket(Bucket=bucket)
63
+ finally:
64
+ runtime.close()
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ from types import SimpleNamespace
4
+ from typing import Any
5
+
6
+ import pytest
7
+ from muscles_data.catalog import DataAdapterCatalog
8
+ from muscles_data.config import DataConfig
9
+ from muscles_data.models import DataCapability
10
+ from muscles_data.ports import ObjectStorePort
11
+ from muscles_data.runtime import DataRuntime
12
+
13
+ from muscles_data_s3 import (
14
+ S3ClientMissingError,
15
+ S3ConfigError,
16
+ S3ConnectionError,
17
+ S3ObjectStoreFactory,
18
+ )
19
+
20
+
21
+ class FakeBody:
22
+ def __init__(self, content: bytes) -> None:
23
+ self.content = content
24
+
25
+ def read(self) -> bytes:
26
+ return self.content
27
+
28
+
29
+ class FakeS3Client:
30
+ def __init__(self, *, fail_head: bool = False) -> None:
31
+ self.fail_head = fail_head
32
+ self.objects: dict[tuple[str, str], dict[str, Any]] = {}
33
+ self.put_calls: list[dict[str, Any]] = []
34
+ self.get_calls: list[dict[str, Any]] = []
35
+ self.list_calls: list[dict[str, Any]] = []
36
+ self.delete_calls: list[dict[str, Any]] = []
37
+ self.head_bucket_calls: list[dict[str, Any]] = []
38
+
39
+ def put_object(self, **kwargs):
40
+ self.put_calls.append(dict(kwargs))
41
+ self.objects[(kwargs["Bucket"], kwargs["Key"])] = {
42
+ "Body": bytes(kwargs["Body"]),
43
+ "ContentType": kwargs.get("ContentType"),
44
+ "Metadata": dict(kwargs.get("Metadata") or {}),
45
+ }
46
+ return {"ETag": '"etag"'}
47
+
48
+ def get_object(self, **kwargs):
49
+ self.get_calls.append(dict(kwargs))
50
+ item = self.objects[(kwargs["Bucket"], kwargs["Key"])]
51
+ return {
52
+ "Body": FakeBody(item["Body"]),
53
+ "ContentLength": len(item["Body"]),
54
+ "ContentType": item.get("ContentType"),
55
+ "Metadata": dict(item.get("Metadata") or {}),
56
+ }
57
+
58
+ def list_objects_v2(self, **kwargs):
59
+ self.list_calls.append(dict(kwargs))
60
+ bucket = kwargs["Bucket"]
61
+ prefix = kwargs.get("Prefix", "")
62
+ max_keys = int(kwargs.get("MaxKeys", 100))
63
+ contents = []
64
+ for (stored_bucket, key), item in sorted(self.objects.items()):
65
+ if stored_bucket != bucket or not key.startswith(prefix):
66
+ continue
67
+ contents.append({"Key": key, "Size": len(item["Body"]), "ETag": '"etag"'})
68
+ return {"Contents": contents[:max_keys], "IsTruncated": len(contents) > max_keys}
69
+
70
+ def delete_object(self, **kwargs):
71
+ self.delete_calls.append(dict(kwargs))
72
+ self.objects.pop((kwargs["Bucket"], kwargs["Key"]), None)
73
+ return {"DeleteMarker": True}
74
+
75
+ def head_bucket(self, **kwargs):
76
+ self.head_bucket_calls.append(dict(kwargs))
77
+ if self.fail_head:
78
+ raise TimeoutError("https://user:s3-secret@s3.example timed out")
79
+ return {"ResponseMetadata": {"HTTPStatusCode": 200}}
80
+
81
+
82
+ def _s3_config(endpoint_url: str = "https://user:s3-secret@s3.example") -> dict[str, Any]:
83
+ return {
84
+ "data": {
85
+ "resources": {
86
+ "objects.docs": {
87
+ "type": "s3",
88
+ "endpoint_url": endpoint_url,
89
+ "bucket": "documents",
90
+ "region_name": "us-east-1",
91
+ "aws_access_key_id": "access-secret",
92
+ "aws_secret_access_key": "secret-key",
93
+ "prefix": "raw",
94
+ "max_keys": 2,
95
+ "native_client": True,
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+
102
+ def _catalog(factory: S3ObjectStoreFactory) -> DataAdapterCatalog:
103
+ catalog = DataAdapterCatalog.with_defaults()
104
+ catalog.register(factory)
105
+ return catalog
106
+
107
+
108
+ def _runtime(client: FakeS3Client | None):
109
+ return DataRuntime(
110
+ config=DataConfig.from_raw(_s3_config()),
111
+ catalog=_catalog(S3ObjectStoreFactory(client_factory=lambda _config: client)),
112
+ )
113
+
114
+
115
+ def test_s3_object_store_is_registered_lazy_and_maps_operations():
116
+ client = FakeS3Client()
117
+ runtime = _runtime(client)
118
+
119
+ listed = runtime.list_resources()
120
+ resource = next(item for item in listed if item["name"] == "objects.docs")
121
+ inspected_before = runtime.inspect_resource("objects.docs")
122
+
123
+ assert resource["type"] == "s3"
124
+ assert "object_store" in resource["capabilities"]
125
+ assert "native_client" not in resource["capabilities"]
126
+ assert resource["initialized"] is False
127
+ assert inspected_before["initialized"] is False
128
+ assert inspected_before["options"]["endpoint_url"] == "***"
129
+ assert inspected_before["options"]["aws_secret_access_key"] == "***"
130
+
131
+ store = runtime.require_port("objects.docs", ObjectStorePort)
132
+ write = store.put_object(
133
+ "docs/readme.txt",
134
+ b"hello",
135
+ content_type="text/plain",
136
+ metadata={"owner": "denis", "version": 1},
137
+ )
138
+ blob = store.get_object("docs/readme.txt")
139
+ ranged = store.get_object("docs/readme.txt", options={"range": "bytes=0-2", "checksum_mode": "ENABLED"})
140
+ store.put_object("docs/guide.txt", b"guide")
141
+ store.put_object("images/logo.png", b"png")
142
+ listed_objects = store.list_objects(prefix="docs", limit=10)
143
+ deleted = store.delete_object("docs/guide.txt", options={"version_id": "v1"})
144
+
145
+ assert write.written == 1
146
+ assert client.put_calls[0]["Bucket"] == "documents"
147
+ assert client.put_calls[0]["Key"] == "raw/docs/readme.txt"
148
+ assert client.put_calls[0]["Body"] == b"hello"
149
+ assert client.put_calls[0]["ContentType"] == "text/plain"
150
+ assert client.put_calls[0]["Metadata"] == {"owner": "denis", "version": "1"}
151
+ assert blob.key == "docs/readme.txt"
152
+ assert blob.content == b"hello"
153
+ assert blob.content_type == "text/plain"
154
+ assert blob.metadata == {"owner": "denis", "version": "1"}
155
+ assert ranged.content == b"hello"
156
+ assert client.get_calls[-1]["Range"] == "bytes=0-2"
157
+ assert client.get_calls[-1]["ChecksumMode"] == "ENABLED"
158
+ assert [item.key for item in listed_objects] == ["docs/guide.txt", "docs/readme.txt"]
159
+ assert client.list_calls[-1]["Prefix"] == "raw/docs/"
160
+ assert client.list_calls[-1]["MaxKeys"] == 2
161
+ assert deleted.deleted == 1
162
+ assert client.delete_calls[-1] == {"Bucket": "documents", "Key": "raw/docs/guide.txt", "VersionId": "v1"}
163
+
164
+ native = runtime.require_resource("objects.docs", DataCapability.NATIVE_CLIENT).native_client()
165
+ assert native is client
166
+ assert client.head_bucket_calls == []
167
+
168
+
169
+ def test_s3_inspect_doctor_and_safe_failures():
170
+ client = FakeS3Client()
171
+ runtime = _runtime(client)
172
+
173
+ doctor = runtime.doctor()
174
+ checks = [check for check in doctor["checks"] if check["resource"] == "objects.docs"]
175
+
176
+ assert checks[0]["status"] == "ok"
177
+ assert client.head_bucket_calls == [{"Bucket": "documents"}]
178
+ assert "s3-secret" not in repr(doctor)
179
+ assert "secret-key" not in repr(runtime.inspect_resource("objects.docs"))
180
+
181
+ missing_client_runtime = _runtime(None)
182
+ with pytest.raises(S3ClientMissingError):
183
+ missing_client_runtime.require_port("objects.docs", ObjectStorePort).get_object("docs/readme.txt")
184
+
185
+ failing_runtime = DataRuntime(
186
+ config=DataConfig.from_raw(_s3_config()),
187
+ catalog=_catalog(S3ObjectStoreFactory(client_factory=lambda _config: FakeS3Client(fail_head=True))),
188
+ )
189
+ failing_doctor = failing_runtime.doctor()
190
+ assert failing_doctor["status"] == "failed"
191
+ assert [check for check in failing_doctor["checks"] if check["resource"] == "objects.docs"][0]["status"] == "failed"
192
+ assert "s3-secret" not in repr(failing_doctor)
193
+
194
+ bad_client = FakeS3Client()
195
+ bad_client.get_object = lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("secret-key leaked")) # type: ignore[method-assign]
196
+ bad_runtime = DataRuntime(
197
+ config=DataConfig.from_raw(_s3_config()),
198
+ catalog=_catalog(S3ObjectStoreFactory(client_factory=lambda _config: bad_client)),
199
+ )
200
+ with pytest.raises(S3ConnectionError) as exc_info:
201
+ bad_runtime.require_port("objects.docs", ObjectStorePort).get_object("docs/readme.txt")
202
+ assert "secret-key" not in str(exc_info.value)
203
+
204
+
205
+ def test_s3_resource_requires_bucket_and_rejects_unsafe_keys():
206
+ assert DataConfig.from_raw(_s3_config()).resources["objects.docs"].type == "s3"
207
+
208
+ adapter = S3ObjectStoreFactory(client_factory=lambda _config: FakeS3Client()).create(
209
+ DataConfig.from_raw(
210
+ {
211
+ "data": {
212
+ "resources": {
213
+ "objects.docs": {
214
+ "type": "s3",
215
+ "endpoint_url": "https://s3.example",
216
+ }
217
+ }
218
+ }
219
+ }
220
+ ).resources["objects.docs"]
221
+ )
222
+ with pytest.raises(S3ConfigError, match="requires bucket"):
223
+ adapter.put_object("docs/readme.txt", b"hello")
224
+
225
+ unsafe = _runtime(FakeS3Client()).require_port("objects.docs", ObjectStorePort)
226
+ with pytest.raises(S3ConfigError, match="unsafe object key"):
227
+ unsafe.put_object("../secret.txt", b"nope")