dirigent-storage-s3 0.9.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.
- dirigent_storage_s3-0.9.0/LICENSE +18 -0
- dirigent_storage_s3-0.9.0/PKG-INFO +35 -0
- dirigent_storage_s3-0.9.0/README.md +22 -0
- dirigent_storage_s3-0.9.0/pyproject.toml +24 -0
- dirigent_storage_s3-0.9.0/pyproject.toml.orig +24 -0
- dirigent_storage_s3-0.9.0/src/dirigent_storage_s3/__init__.py +73 -0
- dirigent_storage_s3-0.9.0/src/dirigent_storage_s3/backend.py +334 -0
- dirigent_storage_s3-0.9.0/src/dirigent_storage_s3/connection.py +36 -0
- dirigent_storage_s3-0.9.0/src/dirigent_storage_s3/py.typed +0 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Copyright (c) 2026 Morten Olav Hansen <morten@winterop.com>. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This source code and accompanying documentation are the property of
|
|
4
|
+
Morten Olav Hansen. No license, express or implied, is granted to use, copy,
|
|
5
|
+
modify, merge, publish, distribute, sublicense, or sell copies of this
|
|
6
|
+
software or its derivatives.
|
|
7
|
+
|
|
8
|
+
The source is published for reference only. Any use beyond reading
|
|
9
|
+
requires written permission from the copyright holder.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
12
|
+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
13
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
|
14
|
+
IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES,
|
|
15
|
+
OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
|
|
16
|
+
|
|
17
|
+
Third-party components redistributed with this software, and the licences they
|
|
18
|
+
carry, are listed in THIRD_PARTY_NOTICES.md.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dirigent-storage-s3
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: The s3:// storage backend for dirigent, compatible with any S3 API.
|
|
5
|
+
License-Expression: LicenseRef-Proprietary
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: aioboto3>=15.5.0
|
|
8
|
+
Requires-Dist: dirigent-common
|
|
9
|
+
Requires-Dist: dirigent-plugin
|
|
10
|
+
Requires-Dist: pydantic>=2.13.5
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# dirigent-storage-s3
|
|
15
|
+
|
|
16
|
+
The `s3://` storage backend for dirigent, for AWS S3 and any S3-compatible endpoint.
|
|
17
|
+
|
|
18
|
+
Registers the `s3` URI scheme and an `s3` connection kind. URIs are `s3://bucket/key`; the
|
|
19
|
+
bucket is always carried by the URI, never inferred.
|
|
20
|
+
|
|
21
|
+
- **Reads stream.** `open_read` yields bounded chunks off the object body, so copying a
|
|
22
|
+
multi-gigabyte artifact is never resident.
|
|
23
|
+
- **Writes are all-or-nothing.** A small write lands as one `put_object`. Once the buffer
|
|
24
|
+
crosses 5 MiB it becomes a multipart upload, completed only on a clean exit and aborted on
|
|
25
|
+
any exception, so a reader never sees a half-written object.
|
|
26
|
+
- **Listing takes a prefix or a glob.** The fixed part of the pattern becomes the S3
|
|
27
|
+
`Prefix`; the returned keys are filtered with `fnmatch`, so `*` matches across `/`.
|
|
28
|
+
- **Any S3 API.** Set `endpoint_url` and `path_style` to address a self-hosted or
|
|
29
|
+
third-party S3-compatible endpoint; leave both alone for AWS S3 itself.
|
|
30
|
+
|
|
31
|
+
The `s3` connection check does a `head_bucket` when the connection names a default bucket and
|
|
32
|
+
a `list_buckets` otherwise, and reports rather than raises.
|
|
33
|
+
|
|
34
|
+
Integration tests run against a real S3-compatible server under the `s3` marker, which the
|
|
35
|
+
default test lane excludes: `uv run pytest packages/dirigent-storage-s3 -m s3`.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# dirigent-storage-s3
|
|
2
|
+
|
|
3
|
+
The `s3://` storage backend for dirigent, for AWS S3 and any S3-compatible endpoint.
|
|
4
|
+
|
|
5
|
+
Registers the `s3` URI scheme and an `s3` connection kind. URIs are `s3://bucket/key`; the
|
|
6
|
+
bucket is always carried by the URI, never inferred.
|
|
7
|
+
|
|
8
|
+
- **Reads stream.** `open_read` yields bounded chunks off the object body, so copying a
|
|
9
|
+
multi-gigabyte artifact is never resident.
|
|
10
|
+
- **Writes are all-or-nothing.** A small write lands as one `put_object`. Once the buffer
|
|
11
|
+
crosses 5 MiB it becomes a multipart upload, completed only on a clean exit and aborted on
|
|
12
|
+
any exception, so a reader never sees a half-written object.
|
|
13
|
+
- **Listing takes a prefix or a glob.** The fixed part of the pattern becomes the S3
|
|
14
|
+
`Prefix`; the returned keys are filtered with `fnmatch`, so `*` matches across `/`.
|
|
15
|
+
- **Any S3 API.** Set `endpoint_url` and `path_style` to address a self-hosted or
|
|
16
|
+
third-party S3-compatible endpoint; leave both alone for AWS S3 itself.
|
|
17
|
+
|
|
18
|
+
The `s3` connection check does a `head_bucket` when the connection names a default bucket and
|
|
19
|
+
a `list_buckets` otherwise, and reports rather than raises.
|
|
20
|
+
|
|
21
|
+
Integration tests run against a real S3-compatible server under the `s3` marker, which the
|
|
22
|
+
default test lane excludes: `uv run pytest packages/dirigent-storage-s3 -m s3`.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dirigent-storage-s3"
|
|
3
|
+
version = "0.9.0"
|
|
4
|
+
description = "The s3:// storage backend for dirigent, compatible with any S3 API."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "LicenseRef-Proprietary"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"aioboto3>=15.5.0",
|
|
11
|
+
"dirigent-common",
|
|
12
|
+
"dirigent-plugin",
|
|
13
|
+
"pydantic>=2.13.5",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.entry-points."dirigent.plugins.v1"]
|
|
17
|
+
storage-s3 = "dirigent_storage_s3:plugin"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
21
|
+
build-backend = "uv_build"
|
|
22
|
+
|
|
23
|
+
[tool.uv.sources.dirigent-plugin]
|
|
24
|
+
workspace = true
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dirigent-storage-s3"
|
|
3
|
+
version = "0.9.0"
|
|
4
|
+
description = "The s3:// storage backend for dirigent, compatible with any S3 API."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "LicenseRef-Proprietary"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"aioboto3>=15.5.0",
|
|
11
|
+
"dirigent-common",
|
|
12
|
+
"dirigent-plugin",
|
|
13
|
+
"pydantic>=2.13.5",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.entry-points."dirigent.plugins.v1"]
|
|
17
|
+
storage-s3 = "dirigent_storage_s3:plugin"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
21
|
+
build-backend = "uv_build"
|
|
22
|
+
|
|
23
|
+
[tool.uv.sources]
|
|
24
|
+
dirigent-plugin = { workspace = true }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""The ``s3://`` storage plugin: one backend and the connection kind it is configured from."""
|
|
2
|
+
|
|
3
|
+
from dirigent_plugin import Contribution, extension
|
|
4
|
+
from dirigent_storage_s3.backend import (
|
|
5
|
+
CHUNK_SIZE,
|
|
6
|
+
GLOB_CHARACTERS,
|
|
7
|
+
MINIMUM_PART_SIZE,
|
|
8
|
+
MISSING_CODES,
|
|
9
|
+
MULTIPART_THRESHOLD,
|
|
10
|
+
PART_SIZE,
|
|
11
|
+
PATH_ADDRESSING,
|
|
12
|
+
SCHEME,
|
|
13
|
+
VIRTUAL_ADDRESSING,
|
|
14
|
+
InvalidS3Uri,
|
|
15
|
+
S3Client,
|
|
16
|
+
S3Sink,
|
|
17
|
+
S3StorageBackend,
|
|
18
|
+
S3StorageConfig,
|
|
19
|
+
S3StorageError,
|
|
20
|
+
client_kwargs,
|
|
21
|
+
fixed_prefix,
|
|
22
|
+
is_missing,
|
|
23
|
+
is_pattern,
|
|
24
|
+
matches_pattern,
|
|
25
|
+
object_uri,
|
|
26
|
+
open_client,
|
|
27
|
+
parse_s3_uri,
|
|
28
|
+
)
|
|
29
|
+
from dirigent_storage_s3.connection import S3ConnectionKind
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class S3StoragePlugin:
|
|
33
|
+
"""The plugin object the host discovers under the dirigent.plugins.v1 entry-point group."""
|
|
34
|
+
|
|
35
|
+
@extension
|
|
36
|
+
def contribute(self) -> Contribution:
|
|
37
|
+
"""Contribute the ``s3://`` storage backend and the ``s3`` connection kind it reads."""
|
|
38
|
+
return Contribution(
|
|
39
|
+
storage_backends=[S3StorageBackend()],
|
|
40
|
+
connection_kinds=[S3ConnectionKind()],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
plugin = S3StoragePlugin()
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"CHUNK_SIZE",
|
|
48
|
+
"GLOB_CHARACTERS",
|
|
49
|
+
"MINIMUM_PART_SIZE",
|
|
50
|
+
"MISSING_CODES",
|
|
51
|
+
"MULTIPART_THRESHOLD",
|
|
52
|
+
"PART_SIZE",
|
|
53
|
+
"PATH_ADDRESSING",
|
|
54
|
+
"SCHEME",
|
|
55
|
+
"VIRTUAL_ADDRESSING",
|
|
56
|
+
"InvalidS3Uri",
|
|
57
|
+
"S3Client",
|
|
58
|
+
"S3ConnectionKind",
|
|
59
|
+
"S3Sink",
|
|
60
|
+
"S3StorageBackend",
|
|
61
|
+
"S3StorageConfig",
|
|
62
|
+
"S3StorageError",
|
|
63
|
+
"S3StoragePlugin",
|
|
64
|
+
"client_kwargs",
|
|
65
|
+
"fixed_prefix",
|
|
66
|
+
"is_missing",
|
|
67
|
+
"is_pattern",
|
|
68
|
+
"matches_pattern",
|
|
69
|
+
"object_uri",
|
|
70
|
+
"open_client",
|
|
71
|
+
"parse_s3_uri",
|
|
72
|
+
"plugin",
|
|
73
|
+
]
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""The ``s3://`` storage backend: streamed bytes against AWS S3 or any S3-compatible endpoint."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
from collections.abc import AsyncGenerator, AsyncIterator
|
|
5
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
|
|
6
|
+
from typing import Any, ClassVar, Final, cast
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
import aioboto3
|
|
10
|
+
|
|
11
|
+
# botocore ships no py.typed, and the workspace mypy config only exempts ``aioboto3.*``.
|
|
12
|
+
from botocore.config import Config # type: ignore[import-untyped]
|
|
13
|
+
from botocore.exceptions import ClientError # type: ignore[import-untyped]
|
|
14
|
+
from pydantic import BaseModel, SecretStr
|
|
15
|
+
|
|
16
|
+
from dirigent_common import BlockModel
|
|
17
|
+
from dirigent_plugin import ByteSink, StatResult, StorageBackend
|
|
18
|
+
|
|
19
|
+
SCHEME: Final = "s3"
|
|
20
|
+
|
|
21
|
+
SERVICE_NAME: Final = "s3"
|
|
22
|
+
|
|
23
|
+
CHUNK_SIZE: Final = 256 * 1024
|
|
24
|
+
|
|
25
|
+
#: The smallest part S3 accepts in a multipart upload, for every part except the last.
|
|
26
|
+
MINIMUM_PART_SIZE: Final = 5 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
PART_SIZE: Final = MINIMUM_PART_SIZE
|
|
29
|
+
|
|
30
|
+
MULTIPART_THRESHOLD: Final = PART_SIZE
|
|
31
|
+
|
|
32
|
+
GLOB_CHARACTERS: Final = ("*", "?", "[")
|
|
33
|
+
|
|
34
|
+
#: The error codes S3 answers a head or a delete of something absent with.
|
|
35
|
+
MISSING_CODES: Final = frozenset({"404", "NoSuchKey", "NoSuchBucket", "NotFound"})
|
|
36
|
+
|
|
37
|
+
PATH_ADDRESSING: Final = "path"
|
|
38
|
+
|
|
39
|
+
VIRTUAL_ADDRESSING: Final = "virtual"
|
|
40
|
+
|
|
41
|
+
#: Typed ``Any`` because neither aioboto3 nor botocore ships type information.
|
|
42
|
+
type S3Client = Any
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class S3StorageError(Exception):
|
|
46
|
+
"""Any failure raised by the s3 backend itself, as opposed to by the S3 API."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InvalidS3Uri(S3StorageError):
|
|
50
|
+
"""A URI handed to this backend is not an addressable ``s3://bucket/key``."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, uri: str, reason: str) -> None:
|
|
53
|
+
"""Name the URI and exactly what is wrong with it, because this is a document error."""
|
|
54
|
+
super().__init__(f"{uri!r} is not a usable {SCHEME} URI: {reason}")
|
|
55
|
+
self.uri = uri
|
|
56
|
+
self.reason = reason
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class S3StorageConfig(BlockModel):
|
|
60
|
+
"""Everything the backend needs to reach one S3 or S3-compatible endpoint."""
|
|
61
|
+
|
|
62
|
+
endpoint_url: str | None = None
|
|
63
|
+
"""The service root, set for any S3-compatible endpoint; None means AWS S3 itself."""
|
|
64
|
+
|
|
65
|
+
region: str = "us-east-1"
|
|
66
|
+
"""The region signed into every request."""
|
|
67
|
+
|
|
68
|
+
access_key_id: str | None = None
|
|
69
|
+
"""The public half of the credential; None falls back to the ambient AWS chain."""
|
|
70
|
+
|
|
71
|
+
secret_access_key: SecretStr | None = None
|
|
72
|
+
"""The secret half of the credential, redacted by the API and encrypted at rest."""
|
|
73
|
+
|
|
74
|
+
path_style: bool = False
|
|
75
|
+
"""Whether buckets are addressed as a path segment, which every S3 clone needs."""
|
|
76
|
+
|
|
77
|
+
verify_tls: bool = True
|
|
78
|
+
"""Whether certificates are verified; turning this off is a per-connection decision."""
|
|
79
|
+
|
|
80
|
+
bucket: str | None = None
|
|
81
|
+
"""The bucket a connection check probes; addressing still carries the bucket in the URI."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_s3_uri(uri: str) -> tuple[str, str]:
|
|
85
|
+
"""Split an ``s3://bucket/key`` URI into its bucket and its key, or refuse it."""
|
|
86
|
+
split = urlsplit(uri)
|
|
87
|
+
if not split.scheme:
|
|
88
|
+
raise InvalidS3Uri(uri, "it names no scheme")
|
|
89
|
+
if split.scheme != SCHEME:
|
|
90
|
+
raise InvalidS3Uri(uri, f"expected scheme {SCHEME!r}, got {split.scheme!r}")
|
|
91
|
+
if split.query or split.fragment:
|
|
92
|
+
raise InvalidS3Uri(uri, "a key may contain '?' and '#', so neither may be a URI delimiter here")
|
|
93
|
+
bucket = split.netloc
|
|
94
|
+
if not bucket:
|
|
95
|
+
raise InvalidS3Uri(uri, "it names no bucket")
|
|
96
|
+
if ":" in bucket or "@" in bucket:
|
|
97
|
+
raise InvalidS3Uri(uri, f"the authority {bucket!r} is not a bare bucket name")
|
|
98
|
+
return bucket, split.path.lstrip("/")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def object_uri(bucket: str, key: str) -> str:
|
|
102
|
+
"""Render a bucket and key back as the URI a caller would use."""
|
|
103
|
+
return f"{SCHEME}://{bucket}/{key}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_pattern(location: str) -> bool:
|
|
107
|
+
"""Report whether a listing location is a glob rather than a plain key prefix."""
|
|
108
|
+
return any(character in location for character in GLOB_CHARACTERS)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def fixed_prefix(pattern: str) -> str:
|
|
112
|
+
"""Return the leading run of a pattern before its first glob character, for the S3 ``Prefix``."""
|
|
113
|
+
for index, character in enumerate(pattern):
|
|
114
|
+
if character in GLOB_CHARACTERS:
|
|
115
|
+
return pattern[:index]
|
|
116
|
+
return pattern
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def matches_pattern(key: str, pattern: str) -> bool:
|
|
120
|
+
"""Decide whether a key belongs in a listing: prefix when plain, ``fnmatch`` when a glob.
|
|
121
|
+
|
|
122
|
+
``fnmatch`` is deliberate, and differs from the ``file://`` backend's ``Path.full_match``.
|
|
123
|
+
An S3 key is one flat string and not a path, so ``*`` matches across ``/`` here: listing
|
|
124
|
+
``s3://bucket/data/*.csv`` finds ``data/2026/01/rows.csv`` as well as ``data/rows.csv``.
|
|
125
|
+
That keeps a plain prefix listing and a glob listing consistent, since a prefix already
|
|
126
|
+
reaches every depth below it.
|
|
127
|
+
"""
|
|
128
|
+
if not is_pattern(pattern):
|
|
129
|
+
return key.startswith(pattern)
|
|
130
|
+
return fnmatch.fnmatchcase(key, pattern)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def client_kwargs(config: S3StorageConfig) -> dict[str, Any]:
|
|
134
|
+
"""Build the keyword arguments one S3 client is opened with."""
|
|
135
|
+
addressing = PATH_ADDRESSING if config.path_style else VIRTUAL_ADDRESSING
|
|
136
|
+
kwargs: dict[str, Any] = {
|
|
137
|
+
"service_name": SERVICE_NAME,
|
|
138
|
+
"region_name": config.region,
|
|
139
|
+
"config": Config(s3={"addressing_style": addressing}),
|
|
140
|
+
"verify": config.verify_tls,
|
|
141
|
+
}
|
|
142
|
+
if config.endpoint_url is not None:
|
|
143
|
+
kwargs["endpoint_url"] = config.endpoint_url
|
|
144
|
+
if config.access_key_id is not None:
|
|
145
|
+
kwargs["aws_access_key_id"] = config.access_key_id
|
|
146
|
+
if config.secret_access_key is not None:
|
|
147
|
+
kwargs["aws_secret_access_key"] = config.secret_access_key.get_secret_value()
|
|
148
|
+
return kwargs
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def open_client(config: S3StorageConfig) -> AbstractAsyncContextManager[S3Client]:
|
|
152
|
+
"""Open an S3 client carrying a connection's endpoint, region, credentials, and TLS setting."""
|
|
153
|
+
session: Any = aioboto3.Session()
|
|
154
|
+
return cast(AbstractAsyncContextManager[S3Client], session.client(**client_kwargs(config)))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def is_missing(error: ClientError) -> bool:
|
|
158
|
+
"""Report whether a client error is S3 saying the object or bucket simply is not there."""
|
|
159
|
+
response: dict[str, Any] = getattr(error, "response", None) or {}
|
|
160
|
+
details: dict[str, Any] = response.get("Error") or {}
|
|
161
|
+
return str(details.get("Code", "")) in MISSING_CODES
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class S3Sink:
|
|
165
|
+
"""The write end of an S3 object: buffered, and promoted to a multipart upload once large."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, client: S3Client, bucket: str, key: str) -> None:
|
|
168
|
+
"""Hold the open client and the object the accumulated bytes will be published as."""
|
|
169
|
+
self._client = client
|
|
170
|
+
self.bucket = bucket
|
|
171
|
+
self.key = key
|
|
172
|
+
self.written = 0
|
|
173
|
+
self._buffer = bytearray()
|
|
174
|
+
self._parts: list[dict[str, Any]] = []
|
|
175
|
+
self._upload_id: str | None = None
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def upload_id(self) -> str | None:
|
|
179
|
+
"""Return the multipart upload id, which is None while the write still fits one put."""
|
|
180
|
+
return self._upload_id
|
|
181
|
+
|
|
182
|
+
async def write(self, data: bytes) -> int:
|
|
183
|
+
"""Buffer bytes, sending out a part whenever enough of them have accumulated."""
|
|
184
|
+
self._buffer.extend(data)
|
|
185
|
+
self.written += len(data)
|
|
186
|
+
while len(self._buffer) >= MULTIPART_THRESHOLD:
|
|
187
|
+
await self._send_part()
|
|
188
|
+
return len(data)
|
|
189
|
+
|
|
190
|
+
async def close(self) -> None:
|
|
191
|
+
"""Publish the object: one put for a small write, a completed upload for a multipart one."""
|
|
192
|
+
if self._upload_id is None:
|
|
193
|
+
await self._client.put_object(Bucket=self.bucket, Key=self.key, Body=bytes(self._buffer))
|
|
194
|
+
self._buffer.clear()
|
|
195
|
+
return
|
|
196
|
+
if self._buffer:
|
|
197
|
+
await self._send_part()
|
|
198
|
+
await self._client.complete_multipart_upload(
|
|
199
|
+
Bucket=self.bucket,
|
|
200
|
+
Key=self.key,
|
|
201
|
+
UploadId=self._upload_id,
|
|
202
|
+
MultipartUpload={"Parts": self._parts},
|
|
203
|
+
)
|
|
204
|
+
self._upload_id = None
|
|
205
|
+
|
|
206
|
+
async def abort(self) -> None:
|
|
207
|
+
"""Discard an in-flight multipart upload, so a failed write leaves no object behind."""
|
|
208
|
+
if self._upload_id is None:
|
|
209
|
+
return
|
|
210
|
+
# A failed abort must not mask the failure that caused it; S3 expires the upload anyway.
|
|
211
|
+
with suppress(ClientError):
|
|
212
|
+
await self._client.abort_multipart_upload(Bucket=self.bucket, Key=self.key, UploadId=self._upload_id)
|
|
213
|
+
self._upload_id = None
|
|
214
|
+
|
|
215
|
+
async def _send_part(self) -> None:
|
|
216
|
+
"""Upload one part off the front of the buffer, starting the multipart upload if needed."""
|
|
217
|
+
if self._upload_id is None:
|
|
218
|
+
started: Any = await self._client.create_multipart_upload(Bucket=self.bucket, Key=self.key)
|
|
219
|
+
self._upload_id = str(started["UploadId"])
|
|
220
|
+
chunk = bytes(self._buffer[:PART_SIZE])
|
|
221
|
+
del self._buffer[:PART_SIZE]
|
|
222
|
+
number = len(self._parts) + 1
|
|
223
|
+
uploaded: Any = await self._client.upload_part(
|
|
224
|
+
Bucket=self.bucket,
|
|
225
|
+
Key=self.key,
|
|
226
|
+
UploadId=self._upload_id,
|
|
227
|
+
PartNumber=number,
|
|
228
|
+
Body=chunk,
|
|
229
|
+
)
|
|
230
|
+
self._parts.append({"ETag": str(uploaded["ETag"]), "PartNumber": number})
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class S3StorageBackend(StorageBackend):
|
|
234
|
+
"""Streams bytes to and from an S3 bucket, or anything else that speaks the S3 API."""
|
|
235
|
+
|
|
236
|
+
scheme: ClassVar[str] = SCHEME
|
|
237
|
+
config_model: ClassVar[type[BaseModel]] = S3StorageConfig
|
|
238
|
+
|
|
239
|
+
def __init__(self, config: S3StorageConfig | None = None) -> None:
|
|
240
|
+
"""Bind the backend to the endpoint and credentials every ``s3://`` URI is served from."""
|
|
241
|
+
self.config = config if config is not None else S3StorageConfig()
|
|
242
|
+
|
|
243
|
+
def configured(self, config: BaseModel) -> "S3StorageBackend":
|
|
244
|
+
"""Return a backend bound to the connection this instance configures ``s3://`` from."""
|
|
245
|
+
return S3StorageBackend(S3StorageConfig.model_validate(config.model_dump()))
|
|
246
|
+
|
|
247
|
+
def locate(self, uri: str, *, require_key: bool = True) -> tuple[str, str]:
|
|
248
|
+
"""Resolve a URI to the bucket and key it addresses, refusing one that names no object."""
|
|
249
|
+
bucket, key = parse_s3_uri(uri)
|
|
250
|
+
if require_key and not key:
|
|
251
|
+
raise InvalidS3Uri(uri, "it names a bucket but no key")
|
|
252
|
+
return bucket, key
|
|
253
|
+
|
|
254
|
+
def uri_for(self, bucket: str, key: str) -> str:
|
|
255
|
+
"""Render a bucket and key back as the URI a caller would use."""
|
|
256
|
+
return object_uri(bucket, key)
|
|
257
|
+
|
|
258
|
+
def client(self) -> AbstractAsyncContextManager[S3Client]:
|
|
259
|
+
"""Open an S3 client from this backend's connection settings."""
|
|
260
|
+
return open_client(self.config)
|
|
261
|
+
|
|
262
|
+
async def open_read(self, uri: str) -> AsyncGenerator[bytes]:
|
|
263
|
+
"""Stream the object at a URI in bounded chunks, never holding the whole body."""
|
|
264
|
+
bucket, key = self.locate(uri)
|
|
265
|
+
async with self.client() as client:
|
|
266
|
+
response: Any = await client.get_object(Bucket=bucket, Key=key)
|
|
267
|
+
body: Any = response["Body"]
|
|
268
|
+
while True:
|
|
269
|
+
chunk: bytes = await body.read(CHUNK_SIZE)
|
|
270
|
+
if not chunk:
|
|
271
|
+
return
|
|
272
|
+
yield chunk
|
|
273
|
+
|
|
274
|
+
@asynccontextmanager
|
|
275
|
+
async def _writer(self, uri: str) -> AsyncGenerator[ByteSink]:
|
|
276
|
+
"""Open a buffered writer, publishing the object only once writing finished cleanly."""
|
|
277
|
+
bucket, key = self.locate(uri)
|
|
278
|
+
async with self.client() as client:
|
|
279
|
+
sink = S3Sink(client, bucket, key)
|
|
280
|
+
try:
|
|
281
|
+
yield sink
|
|
282
|
+
# Inside the guard, so a failure while sending the last part or completing the
|
|
283
|
+
# upload leaves no multipart upload outstanding.
|
|
284
|
+
await sink.close()
|
|
285
|
+
except BaseException:
|
|
286
|
+
await sink.abort()
|
|
287
|
+
raise
|
|
288
|
+
|
|
289
|
+
def open_write(self, uri: str) -> AbstractAsyncContextManager[ByteSink]:
|
|
290
|
+
"""Open a streamed writer for a URI; the object appears only once writing finished."""
|
|
291
|
+
return self._writer(uri)
|
|
292
|
+
|
|
293
|
+
async def stat(self, uri: str) -> StatResult | None:
|
|
294
|
+
"""Describe the object at a URI, or return None when it does not exist."""
|
|
295
|
+
bucket, key = self.locate(uri)
|
|
296
|
+
async with self.client() as client:
|
|
297
|
+
try:
|
|
298
|
+
head: Any = await client.head_object(Bucket=bucket, Key=key)
|
|
299
|
+
except ClientError as error:
|
|
300
|
+
if is_missing(error):
|
|
301
|
+
return None
|
|
302
|
+
raise
|
|
303
|
+
return StatResult(
|
|
304
|
+
uri=self.uri_for(bucket, key),
|
|
305
|
+
size=int(head["ContentLength"]),
|
|
306
|
+
modified_at=head["LastModified"],
|
|
307
|
+
content_type=head.get("ContentType"),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
async def list(self, uri: str) -> AsyncIterator[StatResult]:
|
|
311
|
+
"""List the objects under a key prefix, or the objects matching a glob pattern."""
|
|
312
|
+
bucket, pattern = self.locate(uri, require_key=False)
|
|
313
|
+
async with self.client() as client:
|
|
314
|
+
paginator: Any = client.get_paginator("list_objects_v2")
|
|
315
|
+
async for page in paginator.paginate(Bucket=bucket, Prefix=fixed_prefix(pattern)):
|
|
316
|
+
entries: Any = page.get("Contents", [])
|
|
317
|
+
for entry in cast(list[Any], entries):
|
|
318
|
+
key = str(entry["Key"])
|
|
319
|
+
if matches_pattern(key, pattern):
|
|
320
|
+
yield StatResult(
|
|
321
|
+
uri=self.uri_for(bucket, key),
|
|
322
|
+
size=int(entry["Size"]),
|
|
323
|
+
modified_at=entry["LastModified"],
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
async def delete(self, uri: str) -> None:
|
|
327
|
+
"""Remove the object at a URI; deleting what is not there is not an error."""
|
|
328
|
+
bucket, key = self.locate(uri)
|
|
329
|
+
async with self.client() as client:
|
|
330
|
+
try:
|
|
331
|
+
await client.delete_object(Bucket=bucket, Key=key)
|
|
332
|
+
except ClientError as error:
|
|
333
|
+
if not is_missing(error):
|
|
334
|
+
raise
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The ``s3`` connection kind: the named credential record the ``s3://`` backend is configured from."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, ClassVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from dirigent_common import HealthReport
|
|
8
|
+
from dirigent_plugin import ConnectionKind
|
|
9
|
+
from dirigent_storage_s3.backend import S3StorageConfig, open_client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class S3ConnectionKind(ConnectionKind):
|
|
13
|
+
"""The connection kind the ``s3://`` storage backend resolves its endpoint and credentials through."""
|
|
14
|
+
|
|
15
|
+
id: ClassVar[str] = "s3"
|
|
16
|
+
config_model: ClassVar[type[BaseModel]] = S3StorageConfig
|
|
17
|
+
|
|
18
|
+
async def check(self, config: BaseModel) -> HealthReport:
|
|
19
|
+
"""Probe the endpoint once and report whether it answered, never raising."""
|
|
20
|
+
settings = S3StorageConfig.model_validate(config.model_dump())
|
|
21
|
+
try:
|
|
22
|
+
async with open_client(settings) as client:
|
|
23
|
+
detail = await _probe(client, settings)
|
|
24
|
+
# Broad on purpose: a health check reports a failure, it never raises one at the caller.
|
|
25
|
+
except Exception as error:
|
|
26
|
+
return HealthReport(healthy=False, detail=f"{type(error).__name__}: {error}")
|
|
27
|
+
return HealthReport(healthy=True, detail=detail)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def _probe(client: Any, config: S3StorageConfig) -> str:
|
|
31
|
+
"""Make the cheapest call that proves the connection works, and describe what it found."""
|
|
32
|
+
if config.bucket:
|
|
33
|
+
await client.head_bucket(Bucket=config.bucket)
|
|
34
|
+
return f"bucket {config.bucket!r} reachable"
|
|
35
|
+
listed: Any = await client.list_buckets()
|
|
36
|
+
return f"{len(listed.get('Buckets', []))} buckets visible"
|
|
File without changes
|