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/_gs.py ADDED
@@ -0,0 +1,447 @@
1
+ from __future__ import annotations
2
+
3
+ import errno as _errno
4
+ import io as _io
5
+ import typing as _ty
6
+
7
+ from pathapi import FileStat
8
+ from pathapi import providers as _providers
9
+
10
+ from . import _failures
11
+ from ._gs_backend import UNSET, BaseGSBackend, GSBackend, _default_backend
12
+ from ._gs_errors import BatchReplyError, http_status, oserror, translate_errors
13
+ from . import _reader
14
+ from ._reader import RangeReader
15
+ from ._digests import digest_hex
16
+ from ._places import check_place
17
+ from ._storepath import ObjectStorePath
18
+ from ._writer import SPOOL_SIZE, SpoolUploadStream
19
+
20
+ _DELETE_BATCH = 100
21
+ """Deletes sent in one batch request (the API takes up to 1000)."""
22
+
23
+ _GSWriteStream = SpoolUploadStream
24
+
25
+ _UPLOAD_CHUNK = 2 * 1024**2
26
+ """Bytes of one request of a resumable upload (a multiple of 256 KiB)."""
27
+
28
+
29
+ class _GSSnapshot:
30
+ """The ranged reads of one open stream, as one version of the object. The
31
+ first response reports the object's generation and every later request
32
+ requires it (`ifGenerationMatch`), so an object replaced between two of
33
+ them answers 412 and the stream raises instead of returning the bytes of
34
+ two objects. Each open has its own, so two opens are two snapshots."""
35
+
36
+ def __init__(self, path: "GSPath"):
37
+ self.object_path = path
38
+ self.generation = None
39
+
40
+ def _failure(self, error: BaseException) -> "OSError | None":
41
+ path = self.object_path
42
+ if http_status(error) == 412 and self.generation is not None:
43
+ # Replaced -- or deleted, which a store may also answer 412.
44
+ if path._has_object(path.key):
45
+ return _failures.changed_error(path)
46
+ return FileNotFoundError(
47
+ _errno.ENOENT, "No such file or directory", str(path)
48
+ )
49
+ return oserror(error, path)
50
+
51
+ def fetch(self, start: int, length: int) -> bytes:
52
+ """The bytes from `start`, at most `length`; none at or past the end (a
53
+ range there is answered 416, which is also an empty object's answer)."""
54
+ path = self.object_path
55
+ # The client is resolved outside the translation, so a missing SDK
56
+ # raises ImportError instead of FileNotFoundError.
57
+ blob = path._bucket.blob(path.key)
58
+ pinned = (
59
+ {} if self.generation is None else {"if_generation_match": self.generation}
60
+ )
61
+ try:
62
+ # No checksum: the SDK checks a download against the CRC32C or MD5
63
+ # of the whole object, which a part of it cannot match. The
64
+ # generation is what ties each range to the object the first read.
65
+ data = blob.download_as_bytes(
66
+ start=start,
67
+ end=start + length - 1,
68
+ checksum=None,
69
+ **pinned,
70
+ **path._options,
71
+ )
72
+ except Exception as error:
73
+ if http_status(error) == 416:
74
+ return b""
75
+ failure = self._failure(error)
76
+ if failure is None:
77
+ raise
78
+ raise failure from None
79
+ if self.generation is None:
80
+ self.generation = blob.generation
81
+ return data
82
+
83
+ def size(self) -> int:
84
+ """The size of the version being read."""
85
+ path = self.object_path
86
+ if self.generation is None:
87
+ return path.stat().st_size
88
+ blob = path._bucket.blob(path.key)
89
+ try:
90
+ blob.reload(if_generation_match=self.generation, **path._options)
91
+ except Exception as error:
92
+ failure = self._failure(error)
93
+ if failure is None:
94
+ raise
95
+ raise failure from None
96
+ return blob.size
97
+
98
+
99
+ class GSPath(ObjectStorePath):
100
+ """A path in a Google Cloud Storage bucket, read and written through
101
+ `google.cloud.storage`. Needs the `gs` extra.
102
+
103
+ `GSPath("dir/key", bucket="bkt")` is the key `dir/key` of the bucket
104
+ `bkt`. The client options (`project=`, `credentials=`,
105
+ `client_options=`, ...) and `timeout=` / `retry=` build the path's own
106
+ `GSBackend`; `backend=` shares one client between paths built separately,
107
+ or plugs in a `BaseGSBackend` of your own. Giving `backend=` together with
108
+ a client option is a `TypeError`. Without either, paths share one default
109
+ backend per process. A path joined from another keeps its bucket and its
110
+ backend, and `bucket=` moves it to another bucket on the same backend.
111
+ Nothing connects when a path is built.
112
+
113
+ GCS has no real directories -- `is_dir()` is prefix emulation (any object
114
+ key under `"<path>/"`), and `mkdir()` creates a zero-byte `"<path>/"`
115
+ marker object. `rename()` uses server-side copy + delete (same bucket
116
+ only) instead of the generic download+upload+delete `move()` fallback; a
117
+ prefix directory is not renamed (NotImplementedError), so `move()` copies
118
+ and removes it."""
119
+
120
+ __slots__ = ()
121
+ _LISTING_ERRORS = (Exception,)
122
+ _CHECKSUMS = frozenset({"md5", "crc32c"})
123
+
124
+ if _ty.TYPE_CHECKING:
125
+ _backend: BaseGSBackend
126
+
127
+ def __init__(
128
+ self,
129
+ *segments: "str | GSPath",
130
+ bucket: "str | None" = None,
131
+ backend: "BaseGSBackend | None" = None,
132
+ timeout=UNSET,
133
+ retry=UNSET,
134
+ **client_kwargs,
135
+ ):
136
+ text, donor = self._join(segments)
137
+ place = check_place("bucket", bucket, donor, "GSPath")
138
+ options = {
139
+ name: value
140
+ for name, value in (("timeout", timeout), ("retry", retry))
141
+ if value is not UNSET
142
+ }
143
+ options.update(client_kwargs)
144
+ if backend is not None and options:
145
+ raise TypeError(
146
+ "pass either backend= or the client options "
147
+ "(project=, credentials=, timeout=, ...), not both"
148
+ )
149
+ if backend is None:
150
+ if options:
151
+ backend = GSBackend(**options)
152
+ elif donor is not None:
153
+ backend = donor._backend
154
+ else:
155
+ backend = _default_backend()
156
+ self._initialize(text, place, backend)
157
+
158
+ def _keywords(self) -> dict:
159
+ return {"bucket": self._place}
160
+
161
+ def _uri_root(self) -> str:
162
+ return f"gs://{self._place}"
163
+
164
+ def __repr__(self) -> str:
165
+ shown = [repr(self.as_posix()), f"bucket={self._place!r}"]
166
+ if self._backend is not _default_backend():
167
+ shown.append(f"backend={self._backend!r}")
168
+ return f"{type(self).__name__}({', '.join(shown)})"
169
+
170
+ @property
171
+ def bucket(self) -> str:
172
+ """The name of the bucket."""
173
+ return self._place
174
+
175
+ @property
176
+ def _client(self):
177
+ return self._backend.client()
178
+
179
+ @property
180
+ def _bucket(self):
181
+ return self._client.bucket(self._place)
182
+
183
+ @property
184
+ def _options(self) -> dict:
185
+ """The backend's `timeout=`/`retry=` for an SDK call."""
186
+ options = getattr(self._backend, "call_options", None)
187
+ return options() if callable(options) else {}
188
+
189
+ def _reload(self, key: str):
190
+ """The reloaded blob at `key`, or None if there is no such object.
191
+ Any other failure raises (as OSError when it is an API error)."""
192
+ blob = self._bucket.blob(key)
193
+ try:
194
+ with translate_errors(self):
195
+ blob.reload(**self._options)
196
+ except FileNotFoundError:
197
+ return None
198
+ return blob
199
+
200
+ def _scandir(self):
201
+ # Each list_blobs call already carries size/mtime for every object --
202
+ # reuse it instead of `iterdir()` + a stat call per child.
203
+ prefix = f"{self.key}/" if self.key else ""
204
+ seen = set()
205
+ with translate_errors(self):
206
+ iterator = self._bucket.list_blobs(
207
+ prefix=prefix, delimiter="/", **self._options
208
+ )
209
+ blobs = list(iterator)
210
+ prefixes = list(iterator.prefixes)
211
+ if not blobs and not prefixes and self.key:
212
+ # Nothing under the prefix: a missing path or a file, which
213
+ # pathlib's iterdir() refuses. Only an empty listing pays this.
214
+ if not self.stat().is_dir():
215
+ raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
216
+ # A key that is both an object and a prefix (`x` and `x/y`) lists as
217
+ # the object, agreeing with stat()'s exact-object precedence; the
218
+ # subtree under it is not listed.
219
+ objects = {blob.name[len(prefix) :] for blob in blobs}
220
+ # Common prefixes (directories)
221
+ for common_prefix in prefixes:
222
+ name = common_prefix[len(prefix) :].rstrip("/")
223
+ if name in objects:
224
+ continue
225
+ # A key segment is whatever its writer chose: "..", "." or an
226
+ # embedded "/" must not become a child path.
227
+ if _providers.is_safe_child_name(name) and name not in seen:
228
+ seen.add(name)
229
+ yield name, FileStat(is_dir=True)
230
+ # Blobs (files)
231
+ for blob in blobs:
232
+ name = blob.name[len(prefix) :]
233
+ if name.endswith("/"):
234
+ continue
235
+ if _providers.is_safe_child_name(name) and name not in seen:
236
+ seen.add(name)
237
+ mtime = int(blob.updated.timestamp()) if blob.updated else 0
238
+ yield name, FileStat(
239
+ st_size=blob.size or 0, st_mtime=mtime, is_dir=False
240
+ )
241
+
242
+ def _open(self, mode="r", buffering=-1):
243
+ if mode in ("r", "r+"):
244
+ if not self.key:
245
+ raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
246
+ snapshot = _GSSnapshot(self)
247
+ try:
248
+ first = snapshot.fetch(0, _reader.READ_CHUNK)
249
+ except FileNotFoundError:
250
+ if self.is_dir():
251
+ raise IsADirectoryError(
252
+ _errno.EISDIR, "Is a directory", str(self)
253
+ ) from None
254
+ raise
255
+ raw = RangeReader(snapshot.fetch, snapshot.size, first)
256
+ if mode == "r+":
257
+ # Read-modify-write: what is written is uploaded on close.
258
+ with raw:
259
+ return _GSWriteStream(self, initial=raw)
260
+ if buffering == 0:
261
+ return raw
262
+ size = _io.DEFAULT_BUFFER_SIZE if buffering in (-1, 1) else buffering
263
+ return _io.BufferedReader(raw, size)
264
+ if mode not in ("w", "x"):
265
+ raise NotImplementedError(f"open(mode={mode!r})")
266
+ if mode == "x" and self.exists():
267
+ raise FileExistsError(self)
268
+ if not self.key:
269
+ raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
270
+ if mode == "w" and self._holds_keys(self.key):
271
+ raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
272
+ return _GSWriteStream(self, exclusive=(mode == "x"))
273
+
274
+ def _upload_file(self, file, size: int, *, exclusive=False) -> None:
275
+ """Upload the `size` bytes of `file` as the object: one request while it
276
+ fits in memory, else a resumable upload in `_UPLOAD_CHUNK` requests."""
277
+ if size <= SPOOL_SIZE:
278
+ self._upload(file.read(), exclusive=exclusive)
279
+ return
280
+ blob = self._bucket.blob(self.key, chunk_size=_UPLOAD_CHUNK)
281
+ # Generation 0 matches only a missing object, as in _upload().
282
+ conditions = {"if_generation_match": 0} if exclusive else {}
283
+ with translate_errors(self, create=exclusive):
284
+ blob.upload_from_file(file, size=size, **conditions, **self._options)
285
+
286
+ def _upload(self, data: bytes, *, key=None, exclusive=False) -> None:
287
+ blob = self._bucket.blob(self.key if key is None else key)
288
+ with translate_errors(self, create=exclusive):
289
+ if exclusive:
290
+ # Generation 0 matches only a missing object: a concurrent
291
+ # creator makes this fail with 412, never overwritten.
292
+ blob.upload_from_string(data, if_generation_match=0, **self._options)
293
+ else:
294
+ blob.upload_from_string(data, **self._options)
295
+
296
+ def _flat_entries(self, prefix: str) -> "list[tuple[str, int]]":
297
+ """`(name, size)` of every object under `prefix`, with no delimiter."""
298
+ with translate_errors(self):
299
+ return [
300
+ (blob.name, blob.size or 0)
301
+ for blob in self._bucket.list_blobs(prefix=prefix, **self._options)
302
+ ]
303
+
304
+ def _stat_root(self) -> FileStat:
305
+ # The bucket root: ask the server, as s3: does with HeadBucket, so a mistyped bucket
306
+ # does not read as an existing directory. A one-item listing needs only the
307
+ # object-list permission the rest of the path uses.
308
+ with translate_errors(self):
309
+ for _ in self._bucket.list_blobs(max_results=1, **self._options):
310
+ break
311
+ return FileStat(is_dir=True)
312
+
313
+ def _stat_object(self, key: str) -> "FileStat | None":
314
+ # Only a not-found reply means "no such object": a transient or
315
+ # permission error read as "missing" let copy() replace an existing
316
+ # object.
317
+ blob = self._reload(key)
318
+ if blob is None:
319
+ return None
320
+ return FileStat(
321
+ st_size=blob.size,
322
+ st_mtime=int(blob.updated.timestamp()) if blob.updated else 0,
323
+ is_dir=False,
324
+ )
325
+
326
+ def _stored_digests(self, key: str, algorithm: str) -> "dict[str, str] | None":
327
+ blob = self._reload(key)
328
+ if blob is None:
329
+ return None
330
+ digests = {}
331
+ # An object stored with a content encoding is read back decoded, so
332
+ # the digests of the stored bytes are not those of what a read gives.
333
+ if getattr(blob, "content_encoding", None) in (None, "", "identity"):
334
+ for name, value in (("md5", blob.md5_hash), ("crc32c", blob.crc32c)):
335
+ if value:
336
+ digests[name] = digest_hex(value)
337
+ return digests
338
+
339
+ def _has_object(self, key: str) -> bool:
340
+ return self._reload(key) is not None
341
+
342
+ def _has_prefix(self, key: str) -> bool:
343
+ with translate_errors(self):
344
+ for _ in self._bucket.list_blobs(
345
+ prefix=f"{key}/", max_results=1, **self._options
346
+ ):
347
+ return True
348
+ return False
349
+
350
+ def _first_keys(self, prefix: str, limit: int) -> "list[str]":
351
+ with translate_errors(self):
352
+ return [
353
+ blob.name
354
+ for blob in self._bucket.list_blobs(
355
+ prefix=prefix, max_results=limit, **self._options
356
+ )
357
+ ]
358
+
359
+ def _put_marker(self, key: str) -> None:
360
+ self._upload(b"", key=key, exclusive=True)
361
+
362
+ def _delete_object(self, key: str, *, missing_ok: bool) -> None:
363
+ blob = self._bucket.blob(key)
364
+ try:
365
+ with translate_errors(self):
366
+ blob.delete(**self._options)
367
+ except FileNotFoundError:
368
+ # Deleted since the caller looked. Anything else -- a hold, a
369
+ # permission error -- is not "already gone" and raises.
370
+ if not missing_ok:
371
+ raise
372
+
373
+ def _delete_each(self, keys, on_error, *, ignore_missing=False) -> None:
374
+ for key in keys:
375
+ try:
376
+ with translate_errors(self._key_path(key)):
377
+ self._bucket.blob(key).delete(**self._options)
378
+ except Exception as error:
379
+ if ignore_missing and isinstance(error, FileNotFoundError):
380
+ # Removed by the batch that was just retried.
381
+ continue
382
+ if not on_error(error, self._key_path(key)):
383
+ raise
384
+
385
+ def _delete_batch(self, keys: "list[str]"):
386
+ """Delete `keys` in one batch request and return its replies, one per
387
+ key in order. None when the batch cannot say what it did to each key:
388
+ the client has no batches, the request failed, or the replies do not
389
+ match what was sent."""
390
+ make_batch = getattr(self._client, "batch", None)
391
+ if not callable(make_batch):
392
+ return None
393
+ bucket = self._bucket
394
+ try:
395
+ with make_batch(raise_exception=False) as batch:
396
+ for key in keys:
397
+ bucket.blob(key).delete(**self._options)
398
+ except Exception:
399
+ return None
400
+ replies = getattr(batch, "_responses", None)
401
+ if isinstance(replies, list) and len(replies) == len(keys):
402
+ return replies
403
+ return None
404
+
405
+ def _delete_keys(self, keys: "list[str]", on_error) -> None:
406
+ for index in range(0, len(keys), _DELETE_BATCH):
407
+ chunk = keys[index : index + _DELETE_BATCH]
408
+ replies = self._delete_batch(chunk)
409
+ if replies is None:
410
+ # A rejected batch deleted none and a broken one an unknown
411
+ # number: each key is deleted on its own, so one failing blob
412
+ # leaves no other behind.
413
+ self._delete_each(chunk, on_error, ignore_missing=True)
414
+ continue
415
+ for key, reply in zip(chunk, replies):
416
+ status = reply.status_code
417
+ # A blob gone since the listing is what the delete is for.
418
+ if 200 <= status < 300 or status == 404:
419
+ continue
420
+ where = self._key_path(key)
421
+ failed = oserror(BatchReplyError(status, reply), where)
422
+ if not on_error(failed, where):
423
+ raise failed
424
+
425
+ def _copy_object(self, target: "GSPath", size: int) -> bool:
426
+ # A target with no bucket is "a path needs a bucket", as for a write.
427
+ destination = target._bucket
428
+ try:
429
+ self._bucket.copy_blob(
430
+ self._bucket.blob(self.key), destination, target.key, **self._options
431
+ )
432
+ except Exception as error:
433
+ if http_status(error) == 501:
434
+ return False
435
+ translated = oserror(error, self)
436
+ if translated is None:
437
+ raise
438
+ raise translated from None
439
+ return True
440
+
441
+ def _rename_source(self, key: str):
442
+ return self._reload(key)
443
+
444
+ def _rename_copy(self, source_blob, dest, dest_key: str) -> None:
445
+ with translate_errors(self):
446
+ self._bucket.copy_blob(source_blob, self._bucket, dest_key, **self._options)
447
+ source_blob.delete(**self._options)
objpath/_gs_backend.py ADDED
@@ -0,0 +1,95 @@
1
+ """The backends of `GSPath`: the contract and the `google-cloud-storage` one,
2
+ and the one 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
+ #: "Not given": `None` is a value for `retry=`.
14
+ UNSET = object()
15
+
16
+
17
+ class BaseGSBackend(ClientBackend):
18
+ """Protocol for obtaining a `google.cloud.storage` Client. Subclass this
19
+ to plug in custom credential/session handling (e.g. tests can override to
20
+ point at a fake server); `GSBackend` is the real implementation."""
21
+
22
+ __slots__ = ()
23
+
24
+ @_providers.notimplemented
25
+ def client(self):
26
+ """The `google.cloud.storage` client every call of a path goes through."""
27
+
28
+ def call_options(self) -> dict:
29
+ """Keyword arguments (`timeout=`, `retry=`) added to every SDK call
30
+ a path makes; none by default, which leaves the SDK's own."""
31
+ return {}
32
+
33
+
34
+ class GSBackend(LazyClientBackend, BaseGSBackend):
35
+ """Lazily creates and caches a `google.cloud.storage` Client. A single
36
+ client is reused across threads -- it's documented as thread-safe.
37
+
38
+ `client_kwargs` go to `storage.Client` unchanged, `client_options`
39
+ (dict or `ClientOptions`, `api_endpoint` included) among them. A custom
40
+ endpoint still authenticates; for an emulator or fake server pass
41
+ `use_auth_w_custom_endpoint=False` (anonymous credentials), or set
42
+ `STORAGE_EMULATOR_HOST` yourself -- the SDK reads it.
43
+
44
+ `timeout` (seconds, or a `(connect, read)` pair) and `retry` (a
45
+ `google.api_core.retry.Retry`, or None for no retry) are passed to every
46
+ call a path makes; left out, the SDK's own defaults apply, which keep an
47
+ unreachable endpoint waiting for about two minutes. A context manager:
48
+ leaving the block closes the client."""
49
+
50
+ __slots__ = ("client_kwargs", "_options")
51
+
52
+ def __init__(self, *, timeout=UNSET, retry=UNSET, **client_kwargs):
53
+ super().__init__()
54
+ self.client_kwargs = client_kwargs
55
+ self._options = {
56
+ name: value
57
+ for name, value in (("timeout", timeout), ("retry", retry))
58
+ if value is not UNSET
59
+ }
60
+
61
+ def __repr__(self) -> str:
62
+ return f"GSBackend({', '.join(sorted({*self.client_kwargs, *self._options}))})"
63
+
64
+ def call_options(self) -> dict:
65
+ return dict(self._options)
66
+
67
+ def _build_client(self):
68
+ storage = import_client("google.cloud.storage", "gs")
69
+ # Passed through as given: turning a dict `api_endpoint` into a
70
+ # process-wide `os.environ["STORAGE_EMULATOR_HOST"]` would redirect
71
+ # every later client in the process, and any subprocess, to it.
72
+ return storage.Client(**self.client_kwargs)
73
+
74
+
75
+ _DEFAULT_BACKEND: "GSBackend | None" = None
76
+ _DEFAULT_BACKEND_LOCK = _threading.Lock()
77
+
78
+
79
+ def _default_backend() -> GSBackend:
80
+ """The one backend, and so the one client, that every `GSPath` built
81
+ without a backend shares in this process."""
82
+ global _DEFAULT_BACKEND
83
+ with _DEFAULT_BACKEND_LOCK:
84
+ if _DEFAULT_BACKEND is None:
85
+ _DEFAULT_BACKEND = GSBackend()
86
+ return _DEFAULT_BACKEND
87
+
88
+
89
+ def _reset_default_backend() -> None:
90
+ """Forget the shared default backend, so the next path builds a new one.
91
+ The test suite calls it between tests; paths that already hold the old
92
+ one keep it."""
93
+ global _DEFAULT_BACKEND
94
+ with _DEFAULT_BACKEND_LOCK:
95
+ _DEFAULT_BACKEND = None
objpath/_gs_errors.py ADDED
@@ -0,0 +1,112 @@
1
+ """How a failure of Google Cloud Storage becomes an `OSError`: the status of an
2
+ API error reply, the HTTP client's failures, and the translation of both."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import contextlib as _contextlib
7
+ import errno as _errno
8
+ import json as _json
9
+ import sys as _sys
10
+
11
+ from . import _failures
12
+
13
+
14
+ def http_status(error: BaseException) -> "int | None":
15
+ """The HTTP status of a `google.api_core.exceptions.GoogleAPICallError`
16
+ (`NotFound.code == 404`, `Forbidden.code == 403`, ...), read from its
17
+ `code` attribute so the SDK need not be importable to classify it. None
18
+ for anything that is not an API error reply."""
19
+ if isinstance(error, OSError):
20
+ return None
21
+ code = getattr(error, "code", None)
22
+ return code if isinstance(code, int) and not isinstance(code, bool) else None
23
+
24
+
25
+ def is_http_client_error(error: BaseException) -> bool:
26
+ """Whether `error` comes from the HTTP client under the SDK (`requests`
27
+ or `urllib3`), whose exceptions are what a refused connection, a timeout
28
+ or a body cut short look like."""
29
+ return any(
30
+ cls.__module__.split(".")[0] in ("requests", "urllib3")
31
+ for cls in type(error).__mro__
32
+ )
33
+
34
+
35
+ def transport_kind(error: BaseException) -> str:
36
+ """Which `_failures.transport_error()` kind a `requests`/`urllib3` failure is."""
37
+ if _failures.mentions_timeout(error):
38
+ return _failures.TIMEOUT
39
+ names = {cls.__name__ for cls in type(error).__mro__}
40
+ if names & {"ConnectionError", "NewConnectionError", "ProxyError", "SSLError"}:
41
+ return _failures.UNREACHABLE
42
+ if names & {
43
+ "ChunkedEncodingError",
44
+ "ContentDecodingError",
45
+ "ProtocolError",
46
+ "IncompleteRead",
47
+ }:
48
+ return _failures.INTERRUPTED
49
+ return _failures.FAILED
50
+
51
+
52
+ def oserror(error: BaseException, path, *, create=False) -> "OSError | None":
53
+ """The OSError an API error reply or a transport failure means for
54
+ `path`, or None for any other exception (a missing SDK, bad credentials,
55
+ a bug), which must propagate as itself rather than read as "no such
56
+ file". `create`: the reply answers a conditional create, where 412 means
57
+ the object exists. A request that ran out of time is TimeoutError, an
58
+ endpoint that cannot be reached ConnectionError. Raise it `from None`."""
59
+ # An error of this type exists only once the module that defines it is
60
+ # loaded, so there is nothing to import here.
61
+ api_exceptions = _sys.modules.get("google.api_core.exceptions")
62
+ if isinstance(error, getattr(api_exceptions, "RetryError", ())):
63
+ # The retry deadline passed: what the last attempt failed with is the
64
+ # reason, and with nothing to say the deadline itself is a timeout.
65
+ translated = (
66
+ None if error.cause is None else oserror(error.cause, path, create=create)
67
+ )
68
+ return translated or _failures.transport_error(
69
+ _failures.TIMEOUT, "GCS", path, error
70
+ )
71
+ status = http_status(error)
72
+ if status is None:
73
+ if is_http_client_error(error):
74
+ return _failures.transport_error(transport_kind(error), "GCS", path, error)
75
+ return None
76
+ if status == 404:
77
+ return FileNotFoundError(
78
+ _errno.ENOENT, f"No such file or directory ({error})", str(path)
79
+ )
80
+ if status in (401, 403):
81
+ return PermissionError(_errno.EACCES, str(error), str(path))
82
+ if create and status == 412:
83
+ return FileExistsError(_errno.EEXIST, f"File exists ({error})", str(path))
84
+ if status in (408, 504):
85
+ return _failures.transport_error(_failures.TIMEOUT, "GCS", path, error)
86
+ return OSError(_errno.EIO, f"GCS request failed: {error}", str(path))
87
+
88
+
89
+ @_contextlib.contextmanager
90
+ def translate_errors(path, *, create=False):
91
+ try:
92
+ yield
93
+ except Exception as error:
94
+ translated = oserror(error, path, create=create)
95
+ if translated is None:
96
+ raise
97
+ raise translated from None
98
+
99
+
100
+ class BatchReplyError(Exception):
101
+ """A part of a batch reply that is not a success, shaped as the SDK's own
102
+ API errors are: the HTTP status is `code`."""
103
+
104
+ def __init__(self, code: int, reply):
105
+ text = ""
106
+ try:
107
+ error = _json.loads(reply.content.decode("utf-8", "replace"))["error"]
108
+ text = error.get("message") or ""
109
+ except (ValueError, KeyError, AttributeError, TypeError):
110
+ pass
111
+ super().__init__(f"{code} {text or getattr(reply, 'reason', '') or 'error'}")
112
+ self.code = code