cmem-plugin-s3 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """cmem-plugin-s3"""
@@ -0,0 +1,130 @@
1
+ """Parameter types that ask the service what it holds"""
2
+
3
+ from typing import Any, ClassVar
4
+
5
+ from cmem_plugin_base.dataintegration.context import PluginContext
6
+ from cmem_plugin_base.dataintegration.parameter.password import Password
7
+ from cmem_plugin_base.dataintegration.types import Autocompletion, StringParameterType
8
+
9
+ from cmem_plugin_s3.client import S3Client, S3Error, S3Settings
10
+
11
+ CONNECTION_PARAMETERS: list[str] = [
12
+ "endpoint_url",
13
+ "region",
14
+ "credentials",
15
+ "access_key_id",
16
+ "secret_access_key",
17
+ ]
18
+ """The parameters an autocompletion needs before it can reach the service.
19
+
20
+ Kept to the five that decide where to connect and as whom. The advanced
21
+ connection parameters are deliberately left out: every entry here is a field
22
+ the user has to fill before any list appears, and their defaults are right
23
+ almost always.
24
+ """
25
+
26
+
27
+ def _as_string(value: object) -> str:
28
+ """Read a parameter value that may arrive as a password."""
29
+ if isinstance(value, Password):
30
+ return str(value.decrypt())
31
+ return "" if value is None else str(value)
32
+
33
+
34
+ def _client(values: list[Any]) -> S3Client:
35
+ """Build a client from the parameter values an autocompletion depends on.
36
+
37
+ The connection itself is opened by the first call that needs it, and an
38
+ endpoint or a credential the user has not finished typing raises there -
39
+ which is why every caller treats an S3Error as "nothing to offer yet".
40
+ """
41
+ endpoint_url, region, credentials, access_key_id, secret_access_key = (
42
+ _as_string(value) for value in values
43
+ )
44
+ settings = S3Settings(
45
+ endpoint_url=endpoint_url,
46
+ region=region,
47
+ credentials=credentials,
48
+ access_key_id=access_key_id,
49
+ secret_access_key=secret_access_key,
50
+ )
51
+ return S3Client(settings)
52
+
53
+
54
+ def _matching(completions: list[Autocompletion], query_terms: list[str]) -> list[Autocompletion]:
55
+ """Keep the completions that match every term the user typed."""
56
+ if not query_terms:
57
+ return completions
58
+ return [
59
+ completion
60
+ for completion in completions
61
+ if all(
62
+ term.lower() in (completion.label or completion.value).lower() for term in query_terms
63
+ )
64
+ ]
65
+
66
+
67
+ class BucketParameterType(StringParameterType):
68
+ """Offers the buckets the credentials may see
69
+
70
+ Any bucket name may still be typed by hand. Listing buckets needs a
71
+ permission a key scoped to a single bucket does not have, and that key is
72
+ the well configured case rather than the broken one.
73
+ """
74
+
75
+ allow_only_autocompleted_values: bool = False
76
+ autocomplete_value_with_labels: bool = False
77
+ autocompletion_depends_on_parameters: ClassVar[list[str]] = CONNECTION_PARAMETERS
78
+
79
+ def autocomplete(
80
+ self,
81
+ query_terms: list[str],
82
+ depend_on_parameter_values: list[Any],
83
+ context: PluginContext,
84
+ ) -> list[Autocompletion]:
85
+ """Return the buckets matching all query terms."""
86
+ _ = context
87
+ try:
88
+ buckets = _client(depend_on_parameter_values).buckets()
89
+ except S3Error:
90
+ return []
91
+ return _matching([Autocompletion(value=name, label=name) for name in buckets], query_terms)
92
+
93
+
94
+ class PrefixParameterType(StringParameterType):
95
+ """Offers the key prefixes of the selected bucket, one level at a time
96
+
97
+ What is offered are the prefixes directly below what is typed, so a prefix
98
+ is assembled by selecting its parts in turn rather than by searching the
99
+ whole key space - a bucket holds far too many keys for the latter.
100
+ """
101
+
102
+ allow_only_autocompleted_values: bool = False
103
+ autocomplete_value_with_labels: bool = False
104
+ autocompletion_depends_on_parameters: ClassVar[list[str]] = [
105
+ *CONNECTION_PARAMETERS,
106
+ "bucket",
107
+ ]
108
+
109
+ def autocomplete(
110
+ self,
111
+ query_terms: list[str],
112
+ depend_on_parameter_values: list[Any],
113
+ context: PluginContext,
114
+ ) -> list[Autocompletion]:
115
+ """Return the prefixes below the one that is typed."""
116
+ _ = context
117
+ *connection, bucket = depend_on_parameter_values
118
+ bucket = _as_string(bucket).strip()
119
+ if not bucket:
120
+ return []
121
+ typed = " ".join(query_terms).strip()
122
+ below = typed[: typed.rindex("/") + 1] if "/" in typed else ""
123
+ try:
124
+ prefixes = _client(connection).prefixes(bucket, below)
125
+ except S3Error:
126
+ return []
127
+ completions = [Autocompletion(value=prefix, label=prefix) for prefix in prefixes]
128
+ if not typed:
129
+ return completions
130
+ return [completion for completion in completions if completion.value.startswith(typed)]
@@ -0,0 +1,366 @@
1
+ """Talking to one S3 endpoint"""
2
+
3
+ import re
4
+ import shutil
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from pathlib import Path, PurePosixPath
9
+ from typing import IO, TYPE_CHECKING, Any, Final, Literal
10
+
11
+ import boto3
12
+ from botocore.config import Config
13
+ from botocore.exceptions import BotoCoreError, ClientError
14
+
15
+ if TYPE_CHECKING: # pragma: no cover - the stubs are a development dependency
16
+ from types_boto3_s3.client import S3Client as BotoS3
17
+
18
+ AUTO: Final = "auto"
19
+ PATH: Final = "path"
20
+ VIRTUAL: Final = "virtual"
21
+ EXPLICIT: Final = "explicit"
22
+
23
+ CHUNK_SIZE = 1024 * 1024
24
+ """How much of an object is held in memory while it is written to disk."""
25
+
26
+ DENIED_CODES = frozenset({"AccessDenied", "AllAccessDisabled", "InvalidAccessKeyId"})
27
+ """What a service answers when the credentials may not do what was asked."""
28
+
29
+ NOT_FOUND = 404
30
+ PRECONDITION_FAILED = 412
31
+
32
+
33
+ class S3Error(Exception):
34
+ """Something went wrong while talking to the service"""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class S3Settings:
39
+ """Everything needed to reach one endpoint as one identity"""
40
+
41
+ endpoint_url: str = ""
42
+ region: str = "us-east-1"
43
+ credentials: str = EXPLICIT
44
+ access_key_id: str = ""
45
+ secret_access_key: str = ""
46
+ session_token: str = ""
47
+ addressing_style: str = AUTO
48
+ ssl_verify: bool = True
49
+
50
+ @property
51
+ def resolved_addressing_style(self) -> Literal["path", "virtual"]:
52
+ """Decide how a bucket is named in a request.
53
+
54
+ An endpoint URL means an implementation other than Amazon S3, and those
55
+ are commonly deployed without the wildcard DNS entry virtual-hosted
56
+ addressing needs - so the automatic choice is path style there and
57
+ virtual-hosted style against Amazon S3.
58
+ """
59
+ if self.addressing_style == PATH:
60
+ return PATH
61
+ if self.addressing_style == VIRTUAL:
62
+ return VIRTUAL
63
+ return PATH if self.endpoint_url.strip() else VIRTUAL
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class S3Object:
68
+ """One object of a bucket, as the service describes it"""
69
+
70
+ bucket: str
71
+ key: str
72
+ size: int = 0
73
+ etag: str = ""
74
+ last_modified: str = ""
75
+ storage_class: str = ""
76
+
77
+ @property
78
+ def uri(self) -> str:
79
+ """Name the object the way a user writes it."""
80
+ return f"s3://{self.bucket}/{self.key}"
81
+
82
+ @property
83
+ def name(self) -> str:
84
+ """Reduce the key to a plain file name
85
+
86
+ A key is free text that whoever wrote the object decided, so it may end
87
+ in a separator, may consist of nothing but dots, or may be empty. None
88
+ of that is allowed to decide where a workflow node writes, and an empty
89
+ name would write onto the directory itself.
90
+ """
91
+ name = PurePosixPath(self.key.replace("\\", "/")).name
92
+ name = name.replace("\0", "").strip().strip(".")
93
+ return name or "object"
94
+
95
+ def values(self) -> list[list[str]]:
96
+ """Lay the object out in the order of the object schema's paths."""
97
+ return [
98
+ [self.bucket],
99
+ [self.key],
100
+ [self.name],
101
+ [str(self.size)],
102
+ [self.etag],
103
+ [self.last_modified],
104
+ [self.storage_class],
105
+ ]
106
+
107
+
108
+ def quoted_etag(etag: str) -> str:
109
+ """Put an entity tag into the quoted form the service compares against.
110
+
111
+ A listing reports the tag quoted and a user who copies it into a transform
112
+ may well drop the quotes, so both forms have to be accepted here.
113
+ """
114
+ stripped = etag.strip()
115
+ if not stripped:
116
+ return ""
117
+ if stripped.startswith('"') and stripped.endswith('"'):
118
+ return stripped
119
+ return f'"{stripped}"'
120
+
121
+
122
+ def _unquoted_etag(etag: str | None) -> str:
123
+ """Keep an entity tag without the quotes the service wraps it in."""
124
+ return (etag or "").strip().strip('"')
125
+
126
+
127
+ def _is_denied(error: ClientError) -> bool:
128
+ """Check whether a service refused the call for lack of permission."""
129
+ return str(error.response.get("Error", {}).get("Code", "")) in DENIED_CODES
130
+
131
+
132
+ class S3Client:
133
+ """Talks to one S3 endpoint as one identity"""
134
+
135
+ def __init__(self, settings: S3Settings) -> None:
136
+ self.settings = settings
137
+ self._client: BotoS3 | None = None
138
+ self._session: boto3.Session | None = None
139
+
140
+ @property
141
+ def client(self) -> "BotoS3":
142
+ """Build the connection, once per task run."""
143
+ if self._client is None:
144
+ self._client = self._build()
145
+ return self._client
146
+
147
+ def _build(self) -> "BotoS3":
148
+ """Configure boto3 from the settings.
149
+
150
+ The credentials go on the session rather than on the client, so that
151
+ asking the session who it signs as answers for the explicit key as well
152
+ as for a key the chain resolved.
153
+ """
154
+ settings = self.settings
155
+ session_arguments: dict[str, Any] = {}
156
+ if settings.region.strip():
157
+ session_arguments["region_name"] = settings.region.strip()
158
+ if settings.credentials == EXPLICIT:
159
+ if not settings.access_key_id.strip() or not settings.secret_access_key:
160
+ raise S3Error(
161
+ "An access key ID and a secret access key are needed, or the credentials "
162
+ "have to be taken from the node identity."
163
+ )
164
+ session_arguments["aws_access_key_id"] = settings.access_key_id.strip()
165
+ session_arguments["aws_secret_access_key"] = settings.secret_access_key
166
+ if settings.session_token:
167
+ session_arguments["aws_session_token"] = settings.session_token
168
+ client_arguments: dict[str, Any] = {
169
+ "config": Config(
170
+ s3={"addressing_style": settings.resolved_addressing_style},
171
+ signature_version="s3v4",
172
+ ),
173
+ "verify": settings.ssl_verify,
174
+ }
175
+ if settings.endpoint_url.strip():
176
+ client_arguments["endpoint_url"] = settings.endpoint_url.strip()
177
+ try:
178
+ self._session = boto3.Session(**session_arguments)
179
+ return self._session.client("s3", **client_arguments)
180
+ except (BotoCoreError, ValueError) as error:
181
+ raise S3Error(f"The connection could not be configured: {error}") from error
182
+
183
+ def buckets(self) -> list[str]:
184
+ """List the buckets the credentials may see.
185
+
186
+ Returns an empty list when listing buckets is not permitted, which is
187
+ the common case for a key scoped to a single bucket.
188
+ """
189
+ try:
190
+ response = self.client.list_buckets()
191
+ except ClientError as error:
192
+ if _is_denied(error):
193
+ return []
194
+ raise S3Error(f"The buckets could not be listed: {error}") from error
195
+ except BotoCoreError as error:
196
+ raise S3Error(f"The buckets could not be listed: {error}") from error
197
+ return sorted(str(bucket["Name"]) for bucket in response.get("Buckets", []))
198
+
199
+ def prefixes(self, bucket: str, prefix: str) -> list[str]:
200
+ """List the key prefixes directly below a prefix."""
201
+ try:
202
+ response = self.client.list_objects_v2(
203
+ Bucket=bucket, Prefix=prefix, Delimiter="/", MaxKeys=1000
204
+ )
205
+ except (ClientError, BotoCoreError) as error:
206
+ raise S3Error(f"'{bucket}' could not be listed: {error}") from error
207
+ return sorted(
208
+ str(entry["Prefix"])
209
+ for entry in response.get("CommonPrefixes", [])
210
+ if entry.get("Prefix")
211
+ )
212
+
213
+ def objects(self, bucket: str, prefix: str, top_level_only: bool) -> Iterator[S3Object]:
214
+ """Walk the objects below a prefix, one page of the listing at a time.
215
+
216
+ A key ending in a separator is a folder marker rather than content -
217
+ some tools write one so that a console shows an empty folder - and is
218
+ left out.
219
+ """
220
+ arguments: dict[str, Any] = {"Bucket": bucket, "Prefix": prefix}
221
+ if top_level_only:
222
+ arguments["Delimiter"] = "/"
223
+ try:
224
+ for page in self.client.get_paginator("list_objects_v2").paginate(**arguments):
225
+ for entry in page.get("Contents", []):
226
+ key = str(entry["Key"])
227
+ if key.endswith("/"):
228
+ continue
229
+ yield S3Object(
230
+ bucket=bucket,
231
+ key=key,
232
+ size=int(entry.get("Size", 0)),
233
+ etag=_unquoted_etag(entry.get("ETag")),
234
+ last_modified=self._timestamp(entry.get("LastModified")),
235
+ storage_class=str(entry.get("StorageClass", "STANDARD")),
236
+ )
237
+ except (ClientError, BotoCoreError) as error:
238
+ raise S3Error(f"'{bucket}' could not be listed: {error}") from error
239
+
240
+ @staticmethod
241
+ def _timestamp(value: datetime | None) -> str:
242
+ """Write a modification time down the way a graph can compare it."""
243
+ return value.isoformat() if value is not None else ""
244
+
245
+ def head(self, bucket: str, key: str) -> S3Object | None:
246
+ """Describe one object, or report that the bucket does not hold it."""
247
+ try:
248
+ response = self.client.head_object(Bucket=bucket, Key=key)
249
+ except ClientError as error:
250
+ status = int(error.response.get("ResponseMetadata", {}).get("HTTPStatusCode", 0))
251
+ if status == NOT_FOUND:
252
+ return None
253
+ raise S3Error(f"'{key}' could not be read: {error}") from error
254
+ except BotoCoreError as error:
255
+ raise S3Error(f"'{key}' could not be read: {error}") from error
256
+ return S3Object(
257
+ bucket=bucket,
258
+ key=key,
259
+ size=int(response.get("ContentLength", 0)),
260
+ etag=_unquoted_etag(response.get("ETag")),
261
+ last_modified=self._timestamp(response.get("LastModified")),
262
+ storage_class=str(response.get("StorageClass", "STANDARD")),
263
+ )
264
+
265
+ def download(self, obj: S3Object, directory: Path) -> tuple[Path, str | None]:
266
+ """Fetch one object into a directory, and report its media type.
267
+
268
+ The entity tag the listing reported is sent along, so an object that was
269
+ replaced between the listing and this call fails rather than quietly
270
+ delivering different bytes than the workflow selected.
271
+ """
272
+ arguments: dict[str, Any] = {"Bucket": obj.bucket, "Key": obj.key}
273
+ if obj.etag:
274
+ arguments["IfMatch"] = quoted_etag(obj.etag)
275
+ try:
276
+ response = self.client.get_object(**arguments)
277
+ except ClientError as error:
278
+ status = int(error.response.get("ResponseMetadata", {}).get("HTTPStatusCode", 0))
279
+ if status == PRECONDITION_FAILED:
280
+ raise S3Error(
281
+ f"'{obj.uri}' was replaced after it was listed, so it was not downloaded."
282
+ ) from error
283
+ raise S3Error(f"'{obj.uri}' could not be downloaded: {error}") from error
284
+ except BotoCoreError as error:
285
+ raise S3Error(f"'{obj.uri}' could not be downloaded: {error}") from error
286
+ target = directory / obj.name
287
+ with response["Body"] as body, target.open("wb") as sink:
288
+ shutil.copyfileobj(body, sink, CHUNK_SIZE)
289
+ content_type = response.get("ContentType")
290
+ return target, str(content_type) if content_type else None
291
+
292
+ def upload(self, bucket: str, key: str, source: IO[bytes]) -> S3Object:
293
+ """Write one object, in as many parts as its size needs."""
294
+ try:
295
+ self.client.upload_fileobj(source, bucket, key)
296
+ except (ClientError, BotoCoreError) as error:
297
+ raise S3Error(f"'s3://{bucket}/{key}' could not be uploaded: {error}") from error
298
+ written = self.head(bucket, key)
299
+ return written or S3Object(bucket=bucket, key=key)
300
+
301
+ def report(self, bucket: str) -> str:
302
+ """Say how far an attempt to reach and use the service gets.
303
+
304
+ Written for the action on the task form, where every step that did work
305
+ narrows down the one that did not.
306
+ """
307
+ settings = self.settings
308
+ lines = [
309
+ f"* Endpoint: `{settings.endpoint_url or 'https://s3.<region>.amazonaws.com'}`",
310
+ f"* Region: `{settings.region or '(none)'}`",
311
+ f"* Addressing style: `{settings.resolved_addressing_style}`",
312
+ f"* Credentials: `{settings.credentials}`",
313
+ ]
314
+ try:
315
+ identity = self._signer()
316
+ except S3Error as error:
317
+ return "\n".join([*lines, "", f"**Not configured.** {error}"])
318
+ lines.append(f"* Signing as: {identity}")
319
+ buckets = []
320
+ try:
321
+ buckets = self.buckets()
322
+ except S3Error as error:
323
+ return "\n".join([*lines, "", f"**The service could not be reached.** {error}"])
324
+ lines.append(
325
+ f"* Buckets visible: {len(buckets)}"
326
+ if buckets
327
+ else "* Buckets visible: none, which is normal for a key scoped to one bucket"
328
+ )
329
+ if not bucket.strip():
330
+ return "\n".join([*lines, "", "**The service answered.** No bucket is configured."])
331
+ return "\n".join([*lines, "", self._bucket_report(bucket.strip())])
332
+
333
+ def _bucket_report(self, bucket: str) -> str:
334
+ """Say whether the configured bucket can be reached and read."""
335
+ try:
336
+ self.client.head_bucket(Bucket=bucket)
337
+ except (ClientError, BotoCoreError) as error:
338
+ return f"**`{bucket}` could not be reached.** {error}"
339
+ try:
340
+ page = self.client.list_objects_v2(Bucket=bucket, MaxKeys=1)
341
+ except (ClientError, BotoCoreError) as error:
342
+ return f"**`{bucket}` exists but could not be listed.** {error}"
343
+ held = "holds at least one object" if page.get("Contents") else "is empty"
344
+ return f"**`{bucket}` can be reached and listed**, and {held}."
345
+
346
+ def _signer(self) -> str:
347
+ """Build the connection and name the access key it signs with.
348
+
349
+ Read from the session rather than from the client, so that the ambient
350
+ credential chain is resolved the same way a request would resolve it.
351
+ """
352
+ _ = self.client
353
+ if self._session is None:
354
+ return "nothing, the connection was not built"
355
+ credentials = self._session.get_credentials()
356
+ if credentials is None:
357
+ return "nothing, no credentials were found"
358
+ return f"`{credentials.get_frozen_credentials().access_key}`"
359
+
360
+
361
+ def compiled(regex: str) -> re.Pattern[str]:
362
+ """Compile a key expression, or one that matches every key."""
363
+ try:
364
+ return re.compile(regex) if regex.strip() else re.compile("")
365
+ except re.error as error:
366
+ raise S3Error(f"'{regex}' is not a valid regular expression: {error}") from error