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.
@@ -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"