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.
- cmem_plugin_s3/__init__.py +1 -0
- cmem_plugin_s3/autocomplete.py +130 -0
- cmem_plugin_s3/client.py +366 -0
- cmem_plugin_s3/download.py +294 -0
- cmem_plugin_s3/list.py +214 -0
- cmem_plugin_s3/parameters.py +242 -0
- cmem_plugin_s3/reporting.py +32 -0
- cmem_plugin_s3/s3-download.svg +7 -0
- cmem_plugin_s3/s3-list.svg +7 -0
- cmem_plugin_s3/s3-upload.svg +7 -0
- cmem_plugin_s3/upload.py +294 -0
- cmem_plugin_s3-1.0.0.dist-info/METADATA +48 -0
- cmem_plugin_s3-1.0.0.dist-info/RECORD +15 -0
- cmem_plugin_s3-1.0.0.dist-info/WHEEL +4 -0
- cmem_plugin_s3-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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)]
|
cmem_plugin_s3/client.py
ADDED
|
@@ -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
|