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.
- muscles_data_s3-0.1.0/PKG-INFO +149 -0
- muscles_data_s3-0.1.0/README.md +137 -0
- muscles_data_s3-0.1.0/pyproject.toml +32 -0
- muscles_data_s3-0.1.0/setup.cfg +4 -0
- muscles_data_s3-0.1.0/src/muscles_data_s3/__init__.py +20 -0
- muscles_data_s3-0.1.0/src/muscles_data_s3/adapter.py +424 -0
- muscles_data_s3-0.1.0/src/muscles_data_s3.egg-info/PKG-INFO +149 -0
- muscles_data_s3-0.1.0/src/muscles_data_s3.egg-info/SOURCES.txt +11 -0
- muscles_data_s3-0.1.0/src/muscles_data_s3.egg-info/dependency_links.txt +1 -0
- muscles_data_s3-0.1.0/src/muscles_data_s3.egg-info/requires.txt +5 -0
- muscles_data_s3-0.1.0/src/muscles_data_s3.egg-info/top_level.txt +1 -0
- muscles_data_s3-0.1.0/tests/test_integration.py +64 -0
- muscles_data_s3-0.1.0/tests/test_s3_adapter.py +227 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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")
|