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