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/_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)