objpath 0.1.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.
- objpath/AGENTS.md +311 -0
- objpath/README.md +153 -0
- objpath/__init__.py +68 -0
- objpath/_az.py +547 -0
- objpath/_az_backend.py +130 -0
- objpath/_az_errors.py +110 -0
- objpath/_clients.py +108 -0
- objpath/_digests.py +10 -0
- objpath/_failures.py +84 -0
- objpath/_gs.py +447 -0
- objpath/_gs_backend.py +95 -0
- objpath/_gs_errors.py +112 -0
- objpath/_keys.py +119 -0
- objpath/_optional.py +60 -0
- objpath/_places.py +26 -0
- objpath/_reader.py +83 -0
- objpath/_s3.py +489 -0
- objpath/_s3_backend.py +68 -0
- objpath/_s3_errors.py +106 -0
- objpath/_storepath.py +579 -0
- objpath/_uri_store.py +162 -0
- objpath/_version.py +11 -0
- objpath/_writer.py +113 -0
- objpath/exceptions.py +31 -0
- objpath/py.typed +0 -0
- objpath/uri.py +213 -0
- objpath-0.1.0.dist-info/METADATA +204 -0
- objpath-0.1.0.dist-info/RECORD +31 -0
- objpath-0.1.0.dist-info/WHEEL +4 -0
- objpath-0.1.0.dist-info/entry_points.txt +4 -0
- objpath-0.1.0.dist-info/licenses/LICENSE +21 -0
objpath/_s3.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno as _errno
|
|
4
|
+
import io as _io
|
|
5
|
+
import itertools as _itertools
|
|
6
|
+
import re as _re
|
|
7
|
+
import typing as _ty
|
|
8
|
+
|
|
9
|
+
from pathapi import FileStat
|
|
10
|
+
from pathapi import providers as _providers
|
|
11
|
+
|
|
12
|
+
from ._s3_backend import BaseS3Backend, S3Backend, _default_backend
|
|
13
|
+
from ._s3_errors import (
|
|
14
|
+
S3_ERRORS,
|
|
15
|
+
TRANSPORT_ERRORS,
|
|
16
|
+
S3UploadFailedError,
|
|
17
|
+
botoexc,
|
|
18
|
+
is_not_found,
|
|
19
|
+
is_not_implemented,
|
|
20
|
+
is_precondition_failed,
|
|
21
|
+
oserror,
|
|
22
|
+
reply_oserror,
|
|
23
|
+
)
|
|
24
|
+
from ._digests import digest_hex
|
|
25
|
+
from ._places import check_place
|
|
26
|
+
from ._storepath import ObjectStorePath
|
|
27
|
+
from ._writer import SpoolUploadStream
|
|
28
|
+
|
|
29
|
+
_PUT_OBJECT_LIMIT = 5 * 1024**3
|
|
30
|
+
"""Largest body a single PutObject accepts, and the largest source a single
|
|
31
|
+
CopyObject copies; above it writes and renames use boto3's managed
|
|
32
|
+
(multipart) transfers."""
|
|
33
|
+
|
|
34
|
+
_SPOOL_SIZE = 8 * 1024**2
|
|
35
|
+
"""Bytes a write stream keeps in memory before spilling to a temp file."""
|
|
36
|
+
|
|
37
|
+
_PLAIN_MD5 = _re.compile("[0-9a-fA-F]{32}")
|
|
38
|
+
|
|
39
|
+
#: HeadObject fields that hold a checksum of the whole object, by the
|
|
40
|
+
#: `hashlib`-style name `checksum()` takes.
|
|
41
|
+
_CHECKSUM_FIELDS = {
|
|
42
|
+
"crc32": "ChecksumCRC32",
|
|
43
|
+
"crc32c": "ChecksumCRC32C",
|
|
44
|
+
"crc64nvme": "ChecksumCRC64NVME",
|
|
45
|
+
"sha1": "ChecksumSHA1",
|
|
46
|
+
"sha256": "ChecksumSHA256",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _plain_md5(head) -> "str | None":
|
|
51
|
+
"""The MD5 of the content that a HeadObject reply's ETag is, or None.
|
|
52
|
+
S3 makes the ETag the MD5 only for an object that a single request wrote
|
|
53
|
+
and that is not encrypted with KMS or a customer key; a multipart
|
|
54
|
+
object's ETag ends in `-<parts>`."""
|
|
55
|
+
etag = str(head.get("ETag", "")).strip('"')
|
|
56
|
+
if not _PLAIN_MD5.fullmatch(etag):
|
|
57
|
+
return None
|
|
58
|
+
if head.get("ServerSideEncryption") not in (None, "AES256"):
|
|
59
|
+
return None
|
|
60
|
+
if head.get("SSECustomerAlgorithm") or head.get("SSEKMSKeyId"):
|
|
61
|
+
return None
|
|
62
|
+
return etag.lower()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _copy_call(client, source: dict, bucket: str, key: str, size: int, extra: dict):
|
|
66
|
+
"""One server-side copy of `source` onto `bucket`/`key`: CopyObject, or
|
|
67
|
+
boto3's managed copy (UploadPartCopy) for a source CopyObject refuses."""
|
|
68
|
+
managed_copy = getattr(client, "copy", None)
|
|
69
|
+
if size > _PUT_OBJECT_LIMIT and callable(managed_copy):
|
|
70
|
+
managed_copy(source, bucket, key, ExtraArgs=extra or None)
|
|
71
|
+
else:
|
|
72
|
+
client.copy_object(Bucket=bucket, Key=key, CopySource=source, **extra)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _S3BodyReader(_io.RawIOBase):
|
|
76
|
+
"""Reads a GetObject `StreamingBody` as it arrives, instead of loading
|
|
77
|
+
the whole object into memory first. A failure of the connection in the
|
|
78
|
+
middle of the body is the `OSError` `oserror()` makes of it."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, body, path):
|
|
81
|
+
super().__init__()
|
|
82
|
+
self._body = body
|
|
83
|
+
self._path = path
|
|
84
|
+
|
|
85
|
+
def readable(self):
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
def readinto(self, buffer):
|
|
89
|
+
try:
|
|
90
|
+
data = self._body.read(len(buffer))
|
|
91
|
+
except TRANSPORT_ERRORS as error:
|
|
92
|
+
raise oserror(error, self._path) from None
|
|
93
|
+
size = len(data)
|
|
94
|
+
buffer[:size] = data
|
|
95
|
+
return size
|
|
96
|
+
|
|
97
|
+
def close(self):
|
|
98
|
+
if self.closed:
|
|
99
|
+
return
|
|
100
|
+
try:
|
|
101
|
+
self._body.close()
|
|
102
|
+
finally:
|
|
103
|
+
super().close()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _S3WriteStream(SpoolUploadStream):
|
|
107
|
+
"""The spooled write of `S3Path`, uploaded on close() as a managed
|
|
108
|
+
transfer, multipart above boto3's threshold, so objects over PutObject's
|
|
109
|
+
5 GB limit can be written.
|
|
110
|
+
|
|
111
|
+
`exclusive` (`open("x")`) creates the key with a conditional PutObject
|
|
112
|
+
(`IfNoneMatch="*"`), atomic against a concurrent creator."""
|
|
113
|
+
|
|
114
|
+
spool_size = _SPOOL_SIZE
|
|
115
|
+
|
|
116
|
+
def _upload(self):
|
|
117
|
+
path = self._path
|
|
118
|
+
client = path._client
|
|
119
|
+
body = self._file
|
|
120
|
+
body.seek(0, _io.SEEK_END)
|
|
121
|
+
size = body.tell()
|
|
122
|
+
body.seek(0)
|
|
123
|
+
if self._exclusive:
|
|
124
|
+
if size <= _PUT_OBJECT_LIMIT and path._put_exclusive(body):
|
|
125
|
+
return
|
|
126
|
+
# Over PutObject's limit (managed uploads take no IfNoneMatch),
|
|
127
|
+
# or a store without conditional writes: only this check guards
|
|
128
|
+
# the key.
|
|
129
|
+
if path.exists():
|
|
130
|
+
raise FileExistsError(_errno.EEXIST, "File exists", str(path))
|
|
131
|
+
body.seek(0)
|
|
132
|
+
try:
|
|
133
|
+
upload_fileobj = getattr(client, "upload_fileobj", None)
|
|
134
|
+
if upload_fileobj is None:
|
|
135
|
+
# A plain botocore client (no boto3 transfer methods).
|
|
136
|
+
client.put_object(Bucket=path.bucket, Key=path.key, Body=body)
|
|
137
|
+
else:
|
|
138
|
+
upload_fileobj(body, path.bucket, path.key)
|
|
139
|
+
except S3_ERRORS as error:
|
|
140
|
+
raise oserror(error, path) from None
|
|
141
|
+
except S3UploadFailedError as error:
|
|
142
|
+
# boto3 wraps the store's answer; the answer is what means something.
|
|
143
|
+
cause = error.__context__
|
|
144
|
+
if isinstance(cause, S3_ERRORS):
|
|
145
|
+
raise oserror(cause, path) from None
|
|
146
|
+
raise OSError(_errno.EIO, str(error), str(path)) from None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class S3Path(ObjectStorePath):
|
|
150
|
+
"""A path in an Amazon S3 bucket (or any S3-compatible store), read and
|
|
151
|
+
written through `boto3`. Needs the `s3` extra.
|
|
152
|
+
|
|
153
|
+
`S3Path("dir/key", bucket="bkt")` is the key `dir/key` of the bucket
|
|
154
|
+
`bkt`. The client options (`region_name=`, `endpoint_url=`,
|
|
155
|
+
`aws_access_key_id=`, ...) build the path's own `S3Backend`; `backend=`
|
|
156
|
+
shares one client between paths built separately, or plugs in a
|
|
157
|
+
`BaseS3Backend` of your own. Giving `backend=` together with a client
|
|
158
|
+
option is a `TypeError`. Without either, paths share one default backend
|
|
159
|
+
per process. A path joined from another keeps its bucket and its backend,
|
|
160
|
+
and `bucket=` moves it to another bucket on the same backend. Nothing
|
|
161
|
+
connects when a path is built.
|
|
162
|
+
|
|
163
|
+
S3 has no real directories -- `is_dir()` is prefix emulation (any object
|
|
164
|
+
key under `"<path>/"`), and `mkdir()` creates a zero-byte `"<path>/"`
|
|
165
|
+
marker object (the same convention the AWS console itself uses for an
|
|
166
|
+
empty "folder"). `rename()` uses server-side `copy_object` +
|
|
167
|
+
`delete_object` (same bucket only) instead of the generic
|
|
168
|
+
download+upload+delete `move()` fallback; a prefix directory is not
|
|
169
|
+
renamed (NotImplementedError), so `move()` copies and removes it."""
|
|
170
|
+
|
|
171
|
+
__slots__ = ()
|
|
172
|
+
|
|
173
|
+
if _ty.TYPE_CHECKING:
|
|
174
|
+
_backend: BaseS3Backend
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
*segments: "str | S3Path",
|
|
179
|
+
bucket: "str | None" = None,
|
|
180
|
+
backend: "BaseS3Backend | None" = None,
|
|
181
|
+
**client_kwargs,
|
|
182
|
+
):
|
|
183
|
+
text, donor = self._join(segments)
|
|
184
|
+
place = check_place("bucket", bucket, donor, "S3Path")
|
|
185
|
+
if backend is not None and client_kwargs:
|
|
186
|
+
raise TypeError(
|
|
187
|
+
"pass either backend= or the client options "
|
|
188
|
+
"(region_name=, endpoint_url=, ...), not both"
|
|
189
|
+
)
|
|
190
|
+
if backend is None:
|
|
191
|
+
if client_kwargs:
|
|
192
|
+
backend = S3Backend(**client_kwargs)
|
|
193
|
+
elif donor is not None:
|
|
194
|
+
backend = donor._backend
|
|
195
|
+
else:
|
|
196
|
+
backend = _default_backend()
|
|
197
|
+
self._initialize(text, place, backend)
|
|
198
|
+
|
|
199
|
+
def _keywords(self) -> dict:
|
|
200
|
+
return {"bucket": self._place}
|
|
201
|
+
|
|
202
|
+
def _uri_root(self) -> str:
|
|
203
|
+
return f"s3://{self._place}"
|
|
204
|
+
|
|
205
|
+
def __repr__(self) -> str:
|
|
206
|
+
shown = [repr(self.as_posix()), f"bucket={self._place!r}"]
|
|
207
|
+
if self._backend is not _default_backend():
|
|
208
|
+
shown.append(f"backend={self._backend!r}")
|
|
209
|
+
return f"{type(self).__name__}({', '.join(shown)})"
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def bucket(self) -> str:
|
|
213
|
+
"""The name of the bucket."""
|
|
214
|
+
return self._place
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def _client(self):
|
|
218
|
+
return self._backend.client()
|
|
219
|
+
|
|
220
|
+
def _not_a_directory(self):
|
|
221
|
+
return NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
|
|
222
|
+
|
|
223
|
+
def _scandir(self):
|
|
224
|
+
# Each list_objects_v2 page already carries size/mtime for every
|
|
225
|
+
# object -- reuse it instead of `iterdir()` + a HEAD per child.
|
|
226
|
+
prefix = f"{self.key}/" if self.key else ""
|
|
227
|
+
seen = set()
|
|
228
|
+
empty = True
|
|
229
|
+
paginator = self._client.get_paginator("list_objects_v2")
|
|
230
|
+
try:
|
|
231
|
+
for page in paginator.paginate(
|
|
232
|
+
Bucket=self.bucket, Prefix=prefix, Delimiter="/"
|
|
233
|
+
):
|
|
234
|
+
# A key that is both an object and a prefix (`x` and `x/y`) lists as the object,
|
|
235
|
+
# agreeing with stat()'s exact-object precedence. Keys sort `x` before `x/`, so an
|
|
236
|
+
# earlier page never holds the prefix.
|
|
237
|
+
objects = {
|
|
238
|
+
obj["Key"][len(prefix) :] for obj in page.get("Contents", [])
|
|
239
|
+
}
|
|
240
|
+
for common in page.get("CommonPrefixes", []):
|
|
241
|
+
empty = False
|
|
242
|
+
name = common["Prefix"][len(prefix) :].rstrip("/")
|
|
243
|
+
if name in objects:
|
|
244
|
+
continue
|
|
245
|
+
# A key segment is whatever its writer chose: "..", "." or
|
|
246
|
+
# an embedded "/" must not become a child path.
|
|
247
|
+
if _providers.is_safe_child_name(name) and name not in seen:
|
|
248
|
+
seen.add(name)
|
|
249
|
+
yield name, FileStat(is_dir=True)
|
|
250
|
+
for obj in page.get("Contents", []):
|
|
251
|
+
empty = False
|
|
252
|
+
name = obj["Key"][len(prefix) :]
|
|
253
|
+
if _providers.is_safe_child_name(name) and name not in seen:
|
|
254
|
+
seen.add(name)
|
|
255
|
+
lm = obj.get("LastModified")
|
|
256
|
+
mtime = int(lm.timestamp()) if lm else 0
|
|
257
|
+
yield name, FileStat(
|
|
258
|
+
st_size=obj.get("Size", 0) or 0,
|
|
259
|
+
st_mtime=mtime,
|
|
260
|
+
is_dir=False,
|
|
261
|
+
)
|
|
262
|
+
except S3_ERRORS as error:
|
|
263
|
+
raise oserror(error, self) from None
|
|
264
|
+
if empty and self.key:
|
|
265
|
+
# Nothing under the prefix: a missing path or a file, which
|
|
266
|
+
# pathlib's iterdir() refuses. Only an empty listing pays this.
|
|
267
|
+
if not self.stat().is_dir():
|
|
268
|
+
raise self._not_a_directory()
|
|
269
|
+
|
|
270
|
+
def _open(self, mode="r", buffering=-1):
|
|
271
|
+
if mode in ("r", "r+"):
|
|
272
|
+
if not self.key:
|
|
273
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
|
|
274
|
+
try:
|
|
275
|
+
resp = self._client.get_object(Bucket=self.bucket, Key=self.key)
|
|
276
|
+
except S3_ERRORS as error:
|
|
277
|
+
translated = oserror(error, self)
|
|
278
|
+
if isinstance(translated, FileNotFoundError) and self.is_dir():
|
|
279
|
+
translated = IsADirectoryError(
|
|
280
|
+
_errno.EISDIR, "Is a directory", str(self)
|
|
281
|
+
)
|
|
282
|
+
raise translated from None
|
|
283
|
+
raw = _S3BodyReader(resp["Body"], self)
|
|
284
|
+
if mode == "r+":
|
|
285
|
+
# Read-modify-write: what is written is uploaded on close.
|
|
286
|
+
with raw:
|
|
287
|
+
return _S3WriteStream(self, initial=raw)
|
|
288
|
+
if buffering == 0:
|
|
289
|
+
return raw
|
|
290
|
+
size = _io.DEFAULT_BUFFER_SIZE if buffering in (-1, 1) else buffering
|
|
291
|
+
return _io.BufferedReader(raw, size)
|
|
292
|
+
if mode not in ("w", "x"):
|
|
293
|
+
raise NotImplementedError(f"open(mode={mode!r})")
|
|
294
|
+
if mode == "x" and self.exists():
|
|
295
|
+
raise FileExistsError(self)
|
|
296
|
+
if not self.key:
|
|
297
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
|
|
298
|
+
if mode == "w" and self._holds_keys(self.key):
|
|
299
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
|
|
300
|
+
return _S3WriteStream(self, exclusive=(mode == "x"))
|
|
301
|
+
|
|
302
|
+
def _put_exclusive(self, body, key=None) -> bool:
|
|
303
|
+
"""PutObject that creates `key` (default: this path's) only if it
|
|
304
|
+
does not exist. FileExistsError if it does; False if the store does
|
|
305
|
+
not implement conditional writes, so nothing was written."""
|
|
306
|
+
key = self.key if key is None else key
|
|
307
|
+
try:
|
|
308
|
+
self._client.put_object(
|
|
309
|
+
Bucket=self.bucket, Key=key, Body=body, IfNoneMatch="*"
|
|
310
|
+
)
|
|
311
|
+
except botoexc.ClientError as error:
|
|
312
|
+
if is_precondition_failed(error):
|
|
313
|
+
raise FileExistsError(_errno.EEXIST, "File exists", str(self)) from None
|
|
314
|
+
if is_not_implemented(error):
|
|
315
|
+
return False
|
|
316
|
+
raise oserror(error, self) from None
|
|
317
|
+
except TRANSPORT_ERRORS as error:
|
|
318
|
+
raise oserror(error, self) from None
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
def _stat_root(self) -> FileStat:
|
|
322
|
+
try:
|
|
323
|
+
self._client.head_bucket(Bucket=self.bucket)
|
|
324
|
+
except S3_ERRORS as error:
|
|
325
|
+
raise oserror(error, self) from None
|
|
326
|
+
return FileStat(is_dir=True)
|
|
327
|
+
|
|
328
|
+
def _stat_object(self, key: str) -> "FileStat | None":
|
|
329
|
+
try:
|
|
330
|
+
head = self._client.head_object(Bucket=self.bucket, Key=key)
|
|
331
|
+
except botoexc.ClientError as error:
|
|
332
|
+
# A 403 stays PermissionError: S3 answers HEAD of a missing key
|
|
333
|
+
# with 403 when the caller may not list the bucket.
|
|
334
|
+
if not is_not_found(error):
|
|
335
|
+
raise oserror(error, self) from None
|
|
336
|
+
return None
|
|
337
|
+
except TRANSPORT_ERRORS as error:
|
|
338
|
+
raise oserror(error, self) from None
|
|
339
|
+
return FileStat(
|
|
340
|
+
st_size=head["ContentLength"],
|
|
341
|
+
st_mtime=int(head["LastModified"].timestamp()),
|
|
342
|
+
is_dir=False,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def _stored_digests(self, key: str, algorithm: str) -> "dict[str, str] | None":
|
|
346
|
+
extra = {} if algorithm == "md5" else {"ChecksumMode": "ENABLED"}
|
|
347
|
+
try:
|
|
348
|
+
head = self._client.head_object(Bucket=self.bucket, Key=key, **extra)
|
|
349
|
+
except botoexc.ClientError as error:
|
|
350
|
+
if is_not_found(error):
|
|
351
|
+
return None
|
|
352
|
+
raise oserror(error, self) from None
|
|
353
|
+
except TRANSPORT_ERRORS as error:
|
|
354
|
+
raise oserror(error, self) from None
|
|
355
|
+
digests = {}
|
|
356
|
+
md5 = _plain_md5(head)
|
|
357
|
+
if md5 is not None:
|
|
358
|
+
digests["md5"] = md5
|
|
359
|
+
# A composite checksum (of a multipart object's parts) ends in
|
|
360
|
+
# `-<parts>` and is never the digest of the content.
|
|
361
|
+
if head.get("ChecksumType") != "COMPOSITE":
|
|
362
|
+
for name, field in _CHECKSUM_FIELDS.items():
|
|
363
|
+
value = head.get(field)
|
|
364
|
+
if value and "-" not in value:
|
|
365
|
+
digests[name] = digest_hex(value)
|
|
366
|
+
return digests
|
|
367
|
+
|
|
368
|
+
def _has_object(self, key: str) -> bool:
|
|
369
|
+
try:
|
|
370
|
+
self._client.head_object(Bucket=self.bucket, Key=key)
|
|
371
|
+
except S3_ERRORS as error:
|
|
372
|
+
if isinstance(error, botoexc.ClientError) and is_not_found(error):
|
|
373
|
+
return False
|
|
374
|
+
raise oserror(error, self) from None
|
|
375
|
+
return True
|
|
376
|
+
|
|
377
|
+
def _has_prefix(self, key: str) -> bool:
|
|
378
|
+
try:
|
|
379
|
+
resp = self._client.list_objects_v2(
|
|
380
|
+
Bucket=self.bucket, Prefix=f"{key}/", MaxKeys=1
|
|
381
|
+
)
|
|
382
|
+
except S3_ERRORS as error:
|
|
383
|
+
raise oserror(error, self) from None
|
|
384
|
+
return resp.get("KeyCount", 0) > 0
|
|
385
|
+
|
|
386
|
+
def _flat_entries(self, prefix: str) -> "list[tuple[str, int]]":
|
|
387
|
+
"""`(key, size)` of every object under `prefix`, with no delimiter."""
|
|
388
|
+
try:
|
|
389
|
+
paginator = self._client.get_paginator("list_objects_v2")
|
|
390
|
+
return [
|
|
391
|
+
(obj["Key"], obj.get("Size", 0) or 0)
|
|
392
|
+
for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix)
|
|
393
|
+
for obj in page.get("Contents", [])
|
|
394
|
+
]
|
|
395
|
+
except S3_ERRORS as error:
|
|
396
|
+
raise oserror(error, self) from None
|
|
397
|
+
|
|
398
|
+
def _first_keys(self, prefix: str, limit: int) -> "list[str]":
|
|
399
|
+
try:
|
|
400
|
+
resp = self._client.list_objects_v2(
|
|
401
|
+
Bucket=self.bucket, Prefix=prefix, MaxKeys=limit
|
|
402
|
+
)
|
|
403
|
+
except S3_ERRORS as error:
|
|
404
|
+
raise oserror(error, self) from None
|
|
405
|
+
return [obj["Key"] for obj in resp.get("Contents", [])]
|
|
406
|
+
|
|
407
|
+
def _put_marker(self, key: str) -> None:
|
|
408
|
+
if not self._put_exclusive(b"", key=key):
|
|
409
|
+
try:
|
|
410
|
+
self._client.put_object(Bucket=self.bucket, Key=key, Body=b"")
|
|
411
|
+
except S3_ERRORS as error:
|
|
412
|
+
raise oserror(error, self) from None
|
|
413
|
+
|
|
414
|
+
def _delete_object(self, key: str, *, missing_ok: bool) -> None:
|
|
415
|
+
# S3 never answers a delete with "not found".
|
|
416
|
+
try:
|
|
417
|
+
self._client.delete_object(Bucket=self.bucket, Key=key)
|
|
418
|
+
except S3_ERRORS as error:
|
|
419
|
+
raise oserror(error, self) from None
|
|
420
|
+
|
|
421
|
+
def _delete_keys(self, keys: "list[str]", on_error) -> None:
|
|
422
|
+
iterator = iter(keys)
|
|
423
|
+
while True:
|
|
424
|
+
batch = list(_itertools.islice(iterator, 1000))
|
|
425
|
+
if not batch:
|
|
426
|
+
break
|
|
427
|
+
try:
|
|
428
|
+
response = self._client.delete_objects(
|
|
429
|
+
Bucket=self.bucket,
|
|
430
|
+
Delete={"Objects": [{"Key": key} for key in batch]},
|
|
431
|
+
)
|
|
432
|
+
except S3_ERRORS as error:
|
|
433
|
+
translated = oserror(error, self)
|
|
434
|
+
if not on_error(translated):
|
|
435
|
+
raise translated from None
|
|
436
|
+
continue
|
|
437
|
+
# The store refuses single keys of a batch that it accepts: each
|
|
438
|
+
# is offered with its own path.
|
|
439
|
+
for entry in response.get("Errors", []) if response else []:
|
|
440
|
+
failed_path = self._key_path(entry.get("Key", ""))
|
|
441
|
+
failed = reply_oserror(
|
|
442
|
+
str(entry.get("Code", "")),
|
|
443
|
+
None,
|
|
444
|
+
entry.get("Message", ""),
|
|
445
|
+
failed_path,
|
|
446
|
+
action="delete_objects",
|
|
447
|
+
)
|
|
448
|
+
if not on_error(failed, failed_path):
|
|
449
|
+
raise failed
|
|
450
|
+
|
|
451
|
+
def _rename_source(self, key: str):
|
|
452
|
+
try:
|
|
453
|
+
return self._client.head_object(Bucket=self.bucket, Key=key)
|
|
454
|
+
except S3_ERRORS as error:
|
|
455
|
+
if isinstance(error, botoexc.ClientError) and is_not_found(error):
|
|
456
|
+
return None
|
|
457
|
+
raise oserror(error, self) from None
|
|
458
|
+
|
|
459
|
+
def _rename_copy(self, head, dest, dest_key: str) -> None:
|
|
460
|
+
client = self._client
|
|
461
|
+
extra = {}
|
|
462
|
+
storage_class = head.get("StorageClass")
|
|
463
|
+
if storage_class and storage_class != "STANDARD":
|
|
464
|
+
# CopyObject writes STANDARD unless told otherwise.
|
|
465
|
+
extra["StorageClass"] = storage_class
|
|
466
|
+
source = {"Bucket": self.bucket, "Key": self.key}
|
|
467
|
+
try:
|
|
468
|
+
_copy_call(
|
|
469
|
+
client,
|
|
470
|
+
source,
|
|
471
|
+
self.bucket,
|
|
472
|
+
dest_key,
|
|
473
|
+
head.get("ContentLength", 0),
|
|
474
|
+
extra,
|
|
475
|
+
)
|
|
476
|
+
client.delete_object(Bucket=self.bucket, Key=self.key)
|
|
477
|
+
except S3_ERRORS as error:
|
|
478
|
+
raise oserror(error, self) from None
|
|
479
|
+
|
|
480
|
+
def _copy_object(self, target: "S3Path", size: int) -> bool:
|
|
481
|
+
client = target._client
|
|
482
|
+
source = {"Bucket": self.bucket, "Key": self.key}
|
|
483
|
+
try:
|
|
484
|
+
_copy_call(client, source, target.bucket, target.key, size, {})
|
|
485
|
+
except S3_ERRORS as error:
|
|
486
|
+
if isinstance(error, botoexc.ClientError) and is_not_implemented(error):
|
|
487
|
+
return False
|
|
488
|
+
raise oserror(error, self) from None
|
|
489
|
+
return True
|
objpath/_s3_backend.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The backends of `S3Path`: the contract and the `boto3` one, and the one
|
|
2
|
+
every path built without a backend shares."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import threading as _threading
|
|
7
|
+
|
|
8
|
+
from pathapi import providers as _providers
|
|
9
|
+
|
|
10
|
+
from ._clients import ClientBackend, LazyClientBackend
|
|
11
|
+
from ._optional import import_client
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseS3Backend(ClientBackend):
|
|
15
|
+
"""Protocol for obtaining a `boto3` S3 client. Subclass this to plug in
|
|
16
|
+
custom credential/session handling (e.g. tests mock it directly, no
|
|
17
|
+
real AWS account needed); `S3Backend` is the real implementation."""
|
|
18
|
+
|
|
19
|
+
__slots__ = ()
|
|
20
|
+
|
|
21
|
+
@_providers.notimplemented
|
|
22
|
+
def client(self):
|
|
23
|
+
"""The `boto3` S3 client every call of a path goes through."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class S3Backend(LazyClientBackend, BaseS3Backend):
|
|
27
|
+
"""Lazily creates and caches a `boto3` S3 client. A single `boto3` client
|
|
28
|
+
is reused across threads -- it's documented as thread-safe.
|
|
29
|
+
|
|
30
|
+
`client_kwargs` go to `boto3.client("s3", ...)` unchanged (`region_name`,
|
|
31
|
+
`endpoint_url`, `aws_access_key_id`, `config`, ...). A context manager:
|
|
32
|
+
leaving the block closes the client."""
|
|
33
|
+
|
|
34
|
+
__slots__ = ("client_kwargs",)
|
|
35
|
+
|
|
36
|
+
def __init__(self, **client_kwargs):
|
|
37
|
+
super().__init__()
|
|
38
|
+
self.client_kwargs = client_kwargs
|
|
39
|
+
|
|
40
|
+
def __repr__(self) -> str:
|
|
41
|
+
return f"S3Backend({', '.join(sorted(self.client_kwargs))})"
|
|
42
|
+
|
|
43
|
+
def _build_client(self):
|
|
44
|
+
boto3 = import_client("boto3", "s3")
|
|
45
|
+
return boto3.client("s3", **self.client_kwargs)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_DEFAULT_BACKEND: "S3Backend | None" = None
|
|
49
|
+
_DEFAULT_BACKEND_LOCK = _threading.Lock()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _default_backend() -> S3Backend:
|
|
53
|
+
"""The one backend, and so the one client and connection pool, that every
|
|
54
|
+
`S3Path` built without a backend shares in this process."""
|
|
55
|
+
global _DEFAULT_BACKEND
|
|
56
|
+
with _DEFAULT_BACKEND_LOCK:
|
|
57
|
+
if _DEFAULT_BACKEND is None:
|
|
58
|
+
_DEFAULT_BACKEND = S3Backend()
|
|
59
|
+
return _DEFAULT_BACKEND
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _reset_default_backend() -> None:
|
|
63
|
+
"""Forget the shared default backend, so the next path builds a new one.
|
|
64
|
+
The test suite calls it between tests; paths that already hold the old
|
|
65
|
+
one keep it."""
|
|
66
|
+
global _DEFAULT_BACKEND
|
|
67
|
+
with _DEFAULT_BACKEND_LOCK:
|
|
68
|
+
_DEFAULT_BACKEND = None
|
objpath/_s3_errors.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""How a failure of S3 becomes an `OSError`: botocore's exceptions, grouped by
|
|
2
|
+
what they mean, and the translation of an error reply."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import errno as _errno
|
|
7
|
+
|
|
8
|
+
from . import _failures
|
|
9
|
+
from ._optional import import_or_stub
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from boto3.exceptions import S3UploadFailedError
|
|
13
|
+
except ImportError: # a botocore-only backend
|
|
14
|
+
S3UploadFailedError = ()
|
|
15
|
+
|
|
16
|
+
# The `s3` extra. Without botocore the error tuples are empty (nothing to
|
|
17
|
+
# catch) and the first client the backend builds raises an ImportError naming
|
|
18
|
+
# the extra.
|
|
19
|
+
botoexc = import_or_stub("botocore.exceptions", "s3")
|
|
20
|
+
try:
|
|
21
|
+
# botocore's connection-level failures: an endpoint that cannot be reached,
|
|
22
|
+
# a timeout, a dropped connection, a body cut short. Not always an OSError,
|
|
23
|
+
# so exists() and walk()'s on_error would not see them untranslated.
|
|
24
|
+
TRANSPORT_ERRORS = (
|
|
25
|
+
botoexc.ConnectionError,
|
|
26
|
+
botoexc.HTTPClientError,
|
|
27
|
+
botoexc.IncompleteReadError,
|
|
28
|
+
botoexc.FlexibleChecksumError,
|
|
29
|
+
)
|
|
30
|
+
S3_ERRORS = (botoexc.ClientError,) + TRANSPORT_ERRORS
|
|
31
|
+
TIMEOUT_ERRORS = (botoexc.ReadTimeoutError, botoexc.ConnectTimeoutError)
|
|
32
|
+
INTERRUPTED_ERRORS = (
|
|
33
|
+
botoexc.ConnectionClosedError,
|
|
34
|
+
botoexc.ResponseStreamingError,
|
|
35
|
+
botoexc.IncompleteReadError,
|
|
36
|
+
)
|
|
37
|
+
except ImportError:
|
|
38
|
+
TRANSPORT_ERRORS = S3_ERRORS = TIMEOUT_ERRORS = INTERRUPTED_ERRORS = ()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def error_code(error: botoexc.ClientError) -> str:
|
|
42
|
+
return str(error.response.get("Error", {}).get("Code", ""))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def http_status(error: botoexc.ClientError) -> "int | None":
|
|
46
|
+
return error.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_not_found(error: botoexc.ClientError) -> bool:
|
|
50
|
+
return reply_not_found(error_code(error), http_status(error))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def reply_not_found(code: str, status: "int | None") -> bool:
|
|
54
|
+
return code in ("404", "NoSuchKey", "NoSuchBucket", "NotFound") or status == 404
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_precondition_failed(error: botoexc.ClientError) -> bool:
|
|
58
|
+
# 412 PreconditionFailed: the key exists. 409 ConditionalRequestConflict:
|
|
59
|
+
# a concurrent conditional write to the key is in progress.
|
|
60
|
+
return (
|
|
61
|
+
error_code(error) in ("PreconditionFailed", "ConditionalRequestConflict")
|
|
62
|
+
or http_status(error) == 412
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_not_implemented(error: botoexc.ClientError) -> bool:
|
|
67
|
+
return error_code(error) == "NotImplemented" or http_status(error) == 501
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def reply_oserror(
|
|
71
|
+
code: str, status: "int | None", message: str, path, action: "str | None" = None
|
|
72
|
+
) -> OSError:
|
|
73
|
+
"""The OSError the store's answer (`code`, HTTP `status`, `message`) means
|
|
74
|
+
for `path`: not found (a key or a bucket) is FileNotFoundError, access
|
|
75
|
+
denied PermissionError, anything else OSError(EIO). `action` names the
|
|
76
|
+
request in the message of the last two."""
|
|
77
|
+
if reply_not_found(code, status):
|
|
78
|
+
return FileNotFoundError(
|
|
79
|
+
_errno.ENOENT, f"No such file or directory ({code})", str(path)
|
|
80
|
+
)
|
|
81
|
+
head = f"S3 {action} failed: " if action else ""
|
|
82
|
+
if code in ("403", "401", "AccessDenied", "AllAccessDisabled") or (
|
|
83
|
+
status in (401, 403)
|
|
84
|
+
):
|
|
85
|
+
return PermissionError(_errno.EACCES, f"{head}{code}: {message}", str(path))
|
|
86
|
+
return OSError(_errno.EIO, f"{head or 'S3 '}{code}: {message}", str(path))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def oserror(error: Exception, path) -> OSError:
|
|
90
|
+
"""The OSError a botocore error means for `path`: the store's answer as
|
|
91
|
+
`reply_oserror()` reads it; a request that ran out of time is
|
|
92
|
+
TimeoutError, an endpoint that cannot be reached ConnectionError, a
|
|
93
|
+
connection that broke mid-answer ConnectionResetError, anything else
|
|
94
|
+
OSError. Raise it `from None`: the SDK's text can carry the request URL."""
|
|
95
|
+
if isinstance(error, botoexc.ClientError):
|
|
96
|
+
message = error.response.get("Error", {}).get("Message") or str(error)
|
|
97
|
+
return reply_oserror(error_code(error), http_status(error), message, path)
|
|
98
|
+
if isinstance(error, TIMEOUT_ERRORS):
|
|
99
|
+
kind = _failures.TIMEOUT
|
|
100
|
+
elif isinstance(error, INTERRUPTED_ERRORS):
|
|
101
|
+
kind = _failures.INTERRUPTED
|
|
102
|
+
elif isinstance(error, botoexc.ConnectionError):
|
|
103
|
+
kind = _failures.UNREACHABLE
|
|
104
|
+
else:
|
|
105
|
+
kind = _failures.FAILED
|
|
106
|
+
return _failures.transport_error(kind, "S3", path, error)
|