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/_az.py
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno as _errno
|
|
4
|
+
import io as _io
|
|
5
|
+
import itertools as _itertools
|
|
6
|
+
import time as _time
|
|
7
|
+
import typing as _ty
|
|
8
|
+
|
|
9
|
+
from pathapi import FileStat
|
|
10
|
+
from pathapi import providers as _providers
|
|
11
|
+
|
|
12
|
+
from . import _failures
|
|
13
|
+
from ._az_backend import (
|
|
14
|
+
_DEFAULT_BACKENDS,
|
|
15
|
+
AZBackend,
|
|
16
|
+
BaseAZBackend,
|
|
17
|
+
_default_backend,
|
|
18
|
+
)
|
|
19
|
+
from ._az_errors import http_status, oserror, translate_errors
|
|
20
|
+
from ._optional import import_client as _import_client
|
|
21
|
+
from ._reader import RangeReader
|
|
22
|
+
from ._places import check_place
|
|
23
|
+
from ._storepath import ObjectStorePath
|
|
24
|
+
from ._writer import SPOOL_SIZE, SpoolUploadStream
|
|
25
|
+
|
|
26
|
+
_AZWriteStream = SpoolUploadStream
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _ReadOnly:
|
|
30
|
+
"""`file` seen through `read()` alone. The SDK uploads a stream it
|
|
31
|
+
cannot measure in blocks, and one it can measure in a single request
|
|
32
|
+
that holds all of it."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, file):
|
|
35
|
+
self.read = file.read
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_COPY_POLL_INTERVAL = 0.1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _copy_id(props) -> "str | None":
|
|
42
|
+
try:
|
|
43
|
+
return props["copy_id"]
|
|
44
|
+
except (KeyError, TypeError):
|
|
45
|
+
pass
|
|
46
|
+
return getattr(getattr(props, "copy", None), "id", None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _copy_status(props) -> "str | None":
|
|
50
|
+
# `start_copy_from_url()` returns a dict with "copy_status";
|
|
51
|
+
# `get_blob_properties()` returns BlobProperties, whose status lives at
|
|
52
|
+
# `.copy.status` -- indexing it with "copy_status" raised KeyError.
|
|
53
|
+
try:
|
|
54
|
+
return props["copy_status"]
|
|
55
|
+
except (KeyError, TypeError):
|
|
56
|
+
pass
|
|
57
|
+
return getattr(getattr(props, "copy", None), "status", None)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _if_not_modified():
|
|
61
|
+
"""`MatchConditions.IfNotModified`, which `etag=` is taken with. Without
|
|
62
|
+
the SDK there is nothing to take it, and the name stands for it."""
|
|
63
|
+
try:
|
|
64
|
+
from azure.core import MatchConditions
|
|
65
|
+
except ImportError:
|
|
66
|
+
return "IfNotModified"
|
|
67
|
+
return MatchConditions.IfNotModified
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _AZSnapshot:
|
|
71
|
+
"""The ranged reads of one open stream, as one version of the blob. The
|
|
72
|
+
first response reports the blob's ETag and every later request requires
|
|
73
|
+
it, so a blob replaced between two of them answers 412 and the stream
|
|
74
|
+
raises instead of returning the bytes of two blobs. Each open has its own,
|
|
75
|
+
so two opens are two snapshots."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, path: "AZPath", blob_client):
|
|
78
|
+
self.object_path = path
|
|
79
|
+
self._client = blob_client
|
|
80
|
+
self.etag = None
|
|
81
|
+
self.total = None
|
|
82
|
+
|
|
83
|
+
def _conditions(self) -> dict:
|
|
84
|
+
if self.etag is None:
|
|
85
|
+
return {}
|
|
86
|
+
return {"etag": self.etag, "match_condition": _if_not_modified()}
|
|
87
|
+
|
|
88
|
+
def _failure(self, error: BaseException) -> "OSError | None":
|
|
89
|
+
path = self.object_path
|
|
90
|
+
if http_status(error) == 412 and self.etag is not None:
|
|
91
|
+
# Replaced -- or deleted, which a service may also answer 412.
|
|
92
|
+
if path._has_object(path.key):
|
|
93
|
+
return _failures.changed_error(path)
|
|
94
|
+
return FileNotFoundError(
|
|
95
|
+
_errno.ENOENT, "No such file or directory", str(path)
|
|
96
|
+
)
|
|
97
|
+
return oserror(error, path)
|
|
98
|
+
|
|
99
|
+
def fetch(self, start: int, length: int) -> bytes:
|
|
100
|
+
"""The bytes from `start`, at most `length`; none at or past the end (a
|
|
101
|
+
range there is answered 416, which is also an empty blob's answer)."""
|
|
102
|
+
try:
|
|
103
|
+
downloader = self._client.download_blob(
|
|
104
|
+
offset=start, length=length, **self._conditions()
|
|
105
|
+
)
|
|
106
|
+
data = downloader.readall()
|
|
107
|
+
except Exception as error:
|
|
108
|
+
if http_status(error) == 416:
|
|
109
|
+
return b""
|
|
110
|
+
failure = self._failure(error)
|
|
111
|
+
if failure is None:
|
|
112
|
+
raise
|
|
113
|
+
raise failure from None
|
|
114
|
+
if self.etag is None:
|
|
115
|
+
properties = downloader.properties
|
|
116
|
+
self.etag = properties.etag
|
|
117
|
+
# "bytes 0-524287/33554432": the size of the blob, not of the range.
|
|
118
|
+
total = (getattr(properties, "content_range", None) or "").rpartition("/")[
|
|
119
|
+
2
|
|
120
|
+
]
|
|
121
|
+
self.total = int(total) if total.isdigit() else None
|
|
122
|
+
return data
|
|
123
|
+
|
|
124
|
+
def size(self) -> int:
|
|
125
|
+
"""The size of the version being read."""
|
|
126
|
+
if self.total is not None:
|
|
127
|
+
return self.total
|
|
128
|
+
if self.etag is None:
|
|
129
|
+
return self.object_path.stat().st_size
|
|
130
|
+
try:
|
|
131
|
+
return self._client.get_blob_properties(**self._conditions())["size"]
|
|
132
|
+
except Exception as error:
|
|
133
|
+
failure = self._failure(error)
|
|
134
|
+
if failure is None:
|
|
135
|
+
raise
|
|
136
|
+
raise failure from None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class AZPath(ObjectStorePath):
|
|
140
|
+
"""A path in an Azure Blob Storage container, read and written through
|
|
141
|
+
`azure.storage.blob`. Needs the `az` extra.
|
|
142
|
+
|
|
143
|
+
`AZPath("dir/key", container="cont", account="acct")` is the blob
|
|
144
|
+
`dir/key` of the container `cont` of the storage account `acct`, reached
|
|
145
|
+
through `https://acct.blob.core.windows.net` with azure-identity's
|
|
146
|
+
`DefaultAzureCredential` (an `ImportError` naming the extra if
|
|
147
|
+
azure-identity is missing); paths of one account share one default
|
|
148
|
+
backend per process. Client options (`credential=`, `account_url=`,
|
|
149
|
+
`connection_string=`, ...) build the path's own `AZBackend`, `account=`
|
|
150
|
+
among them or not; `backend=` shares one client between paths built
|
|
151
|
+
separately, or plugs in a `BaseAZBackend` of your own. Giving `backend=`
|
|
152
|
+
together with `account=` or a client option is a `TypeError`, and so is
|
|
153
|
+
giving none of them (unless a segment is an `AZPath`, whose backend is
|
|
154
|
+
kept). A path joined from another keeps its container and its backend, and
|
|
155
|
+
`container=` moves it to another container on the same backend. Nothing
|
|
156
|
+
connects when a path is built.
|
|
157
|
+
|
|
158
|
+
Azure Blob has no real directories -- `is_dir()` is prefix emulation (any
|
|
159
|
+
blob key under `"<path>/"`), and `mkdir()` creates a zero-byte
|
|
160
|
+
`"<path>/"` marker blob. `rename()` uses server-side copy + delete (same
|
|
161
|
+
container only) instead of the generic download+upload+delete `move()`
|
|
162
|
+
fallback; a prefix directory is not renamed (NotImplementedError), so
|
|
163
|
+
`move()` copies and removes it."""
|
|
164
|
+
|
|
165
|
+
__slots__ = ()
|
|
166
|
+
_TOP = "container"
|
|
167
|
+
_LISTING_ERRORS = (Exception,)
|
|
168
|
+
|
|
169
|
+
if _ty.TYPE_CHECKING:
|
|
170
|
+
_backend: BaseAZBackend
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
*segments: "str | AZPath",
|
|
175
|
+
container: "str | None" = None,
|
|
176
|
+
account: "str | None" = None,
|
|
177
|
+
backend: "BaseAZBackend | None" = None,
|
|
178
|
+
**client_kwargs,
|
|
179
|
+
):
|
|
180
|
+
text, donor = self._join(segments)
|
|
181
|
+
place = check_place("container", container, donor, "AZPath")
|
|
182
|
+
if backend is not None and (account is not None or client_kwargs):
|
|
183
|
+
raise TypeError(
|
|
184
|
+
"pass either backend= or account= and the client options "
|
|
185
|
+
"(credential=, account_url=, ...), not both"
|
|
186
|
+
)
|
|
187
|
+
if account is not None:
|
|
188
|
+
check_place("account", account, None, "AZPath")
|
|
189
|
+
if backend is None:
|
|
190
|
+
if client_kwargs:
|
|
191
|
+
backend = AZBackend(account, **client_kwargs)
|
|
192
|
+
elif account is not None:
|
|
193
|
+
backend = _default_backend(account)
|
|
194
|
+
elif donor is not None:
|
|
195
|
+
backend = donor._backend
|
|
196
|
+
else:
|
|
197
|
+
raise TypeError(
|
|
198
|
+
"an AZPath needs account=, backend= or the client options"
|
|
199
|
+
)
|
|
200
|
+
self._initialize(text, place, backend)
|
|
201
|
+
|
|
202
|
+
def _keywords(self) -> dict:
|
|
203
|
+
return {"container": self._place}
|
|
204
|
+
|
|
205
|
+
def _identity(self) -> tuple:
|
|
206
|
+
# Two accounts can each hold a container of one name.
|
|
207
|
+
return (self.account or "", self._place, self.key)
|
|
208
|
+
|
|
209
|
+
def _backend_setting(self, name: str):
|
|
210
|
+
"""The `read_chunk` or `copy_timeout` of this path's backend; a backend
|
|
211
|
+
that does not define it has the default of `BaseAZBackend`."""
|
|
212
|
+
return getattr(self._backend, name, getattr(BaseAZBackend, name))
|
|
213
|
+
|
|
214
|
+
def _default_keywords(self) -> dict:
|
|
215
|
+
account = self.account
|
|
216
|
+
if account is None:
|
|
217
|
+
raise TypeError(
|
|
218
|
+
f"cannot pickle an {type(self).__name__} whose backend is not "
|
|
219
|
+
"picklable and names no account"
|
|
220
|
+
)
|
|
221
|
+
return {"account": account}
|
|
222
|
+
|
|
223
|
+
def _uri_root(self) -> str:
|
|
224
|
+
account = self.account
|
|
225
|
+
if account is None:
|
|
226
|
+
raise NotImplementedError("as_uri() needs a backend that names its account")
|
|
227
|
+
return f"az://{account}/{self._place}"
|
|
228
|
+
|
|
229
|
+
def __str__(self) -> str:
|
|
230
|
+
try:
|
|
231
|
+
return self.as_uri()
|
|
232
|
+
except NotImplementedError:
|
|
233
|
+
return f"az:///{self._place}/{self.key}"
|
|
234
|
+
|
|
235
|
+
def __repr__(self) -> str:
|
|
236
|
+
shown = [repr(self.as_posix()), f"container={self._place!r}"]
|
|
237
|
+
backend = self._backend
|
|
238
|
+
account = self.account
|
|
239
|
+
if account is not None and backend is _DEFAULT_BACKENDS.get(account):
|
|
240
|
+
shown.append(f"account={account!r}")
|
|
241
|
+
else:
|
|
242
|
+
shown.append(f"backend={backend!r}")
|
|
243
|
+
return f"{type(self).__name__}({', '.join(shown)})"
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def account(self) -> "str | None":
|
|
247
|
+
"""The storage account, when the backend names it."""
|
|
248
|
+
return getattr(self._backend, "account", None)
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def container(self) -> str:
|
|
252
|
+
"""The name of the container."""
|
|
253
|
+
return self._place
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def _client(self):
|
|
257
|
+
return self._backend.client()
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def _container(self):
|
|
261
|
+
return self._client.get_container_client(self._place)
|
|
262
|
+
|
|
263
|
+
def _properties(self, key: str):
|
|
264
|
+
"""The properties of the blob at `key`, or None if there is no such
|
|
265
|
+
blob. Any other failure raises (as OSError when it is an error
|
|
266
|
+
reply)."""
|
|
267
|
+
blob_client = self._container.get_blob_client(key)
|
|
268
|
+
try:
|
|
269
|
+
with translate_errors(self):
|
|
270
|
+
return blob_client.get_blob_properties()
|
|
271
|
+
except FileNotFoundError:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
def _scandir(self):
|
|
275
|
+
# Each walk_blobs call already carries size/mtime for every blob --
|
|
276
|
+
# reuse it instead of `iterdir()` + a stat call per child.
|
|
277
|
+
BlobPrefix = _import_client("azure.storage.blob", "az").BlobPrefix
|
|
278
|
+
|
|
279
|
+
prefix = f"{self.key}/" if self.key else ""
|
|
280
|
+
seen = set()
|
|
281
|
+
with translate_errors(self):
|
|
282
|
+
# walk_blobs provides both blobs and common prefixes (directories)
|
|
283
|
+
items = list(
|
|
284
|
+
self._container.walk_blobs(name_starts_with=prefix, delimiter="/")
|
|
285
|
+
)
|
|
286
|
+
if not items and self.key:
|
|
287
|
+
# Nothing under the prefix: a missing path or a file, which
|
|
288
|
+
# pathlib's iterdir() refuses. Only an empty listing pays this.
|
|
289
|
+
if not self.stat().is_dir():
|
|
290
|
+
raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
|
|
291
|
+
# A key that is both a blob and a prefix (`x` and `x/y`) lists as the blob,
|
|
292
|
+
# agreeing with stat()'s exact-blob precedence. The SDK returns a page's prefixes
|
|
293
|
+
# before its blobs, so the blobs are collected first.
|
|
294
|
+
blobs = {
|
|
295
|
+
item.name[len(prefix) :]
|
|
296
|
+
for item in items
|
|
297
|
+
if not isinstance(item, BlobPrefix)
|
|
298
|
+
}
|
|
299
|
+
for item in items:
|
|
300
|
+
if isinstance(item, BlobPrefix):
|
|
301
|
+
name = item.name[len(prefix) :].rstrip("/")
|
|
302
|
+
if name in blobs:
|
|
303
|
+
continue
|
|
304
|
+
# A blob name segment is whatever its writer chose: "..", "."
|
|
305
|
+
# or an embedded "/" must not become a child path.
|
|
306
|
+
if _providers.is_safe_child_name(name) and name not in seen:
|
|
307
|
+
seen.add(name)
|
|
308
|
+
yield name, FileStat(is_dir=True)
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
name = item.name[len(prefix) :]
|
|
312
|
+
if _providers.is_safe_child_name(name) and name not in seen:
|
|
313
|
+
seen.add(name)
|
|
314
|
+
mtime = int(item.last_modified.timestamp()) if item.last_modified else 0
|
|
315
|
+
yield name, FileStat(
|
|
316
|
+
st_size=item.size or 0, st_mtime=mtime, is_dir=False
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def _open(self, mode="r", buffering=-1):
|
|
320
|
+
if mode in ("r", "r+"):
|
|
321
|
+
if not self.key:
|
|
322
|
+
# A container, not a blob.
|
|
323
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
|
|
324
|
+
# The client is resolved outside the translation, so a missing
|
|
325
|
+
# SDK or an unusable default backend raises as itself instead of
|
|
326
|
+
# FileNotFoundError.
|
|
327
|
+
snapshot = _AZSnapshot(self, self._container.get_blob_client(self.key))
|
|
328
|
+
try:
|
|
329
|
+
first = snapshot.fetch(0, self._backend_setting("read_chunk"))
|
|
330
|
+
except FileNotFoundError:
|
|
331
|
+
if self.is_dir():
|
|
332
|
+
raise IsADirectoryError(
|
|
333
|
+
_errno.EISDIR, "Is a directory", str(self)
|
|
334
|
+
) from None
|
|
335
|
+
raise
|
|
336
|
+
raw = RangeReader(
|
|
337
|
+
snapshot.fetch,
|
|
338
|
+
snapshot.size,
|
|
339
|
+
first,
|
|
340
|
+
self._backend_setting("read_chunk"),
|
|
341
|
+
)
|
|
342
|
+
if mode == "r+":
|
|
343
|
+
# Read-modify-write: what is written is uploaded on close.
|
|
344
|
+
with raw:
|
|
345
|
+
return _AZWriteStream(self, initial=raw)
|
|
346
|
+
if buffering == 0:
|
|
347
|
+
return raw
|
|
348
|
+
size = _io.DEFAULT_BUFFER_SIZE if buffering in (-1, 1) else buffering
|
|
349
|
+
return _io.BufferedReader(raw, size)
|
|
350
|
+
if mode not in ("w", "x"):
|
|
351
|
+
raise NotImplementedError(f"open(mode={mode!r})")
|
|
352
|
+
if mode == "x" and self.exists():
|
|
353
|
+
raise FileExistsError(self)
|
|
354
|
+
if not self.key:
|
|
355
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
|
|
356
|
+
if mode == "w" and self._holds_keys(self.key):
|
|
357
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
|
|
358
|
+
return _AZWriteStream(self, exclusive=(mode == "x"))
|
|
359
|
+
|
|
360
|
+
def _upload_file(self, file, size: int, *, exclusive=False) -> None:
|
|
361
|
+
"""Upload the `size` bytes of `file` as the blob: one request while it
|
|
362
|
+
fits in memory, else in blocks."""
|
|
363
|
+
if size <= SPOOL_SIZE:
|
|
364
|
+
self._upload(file.read(), exclusive=exclusive)
|
|
365
|
+
return
|
|
366
|
+
blob_client = self._container.get_blob_client(self.key)
|
|
367
|
+
with translate_errors(self, create=exclusive):
|
|
368
|
+
blob_client.upload_blob(_ReadOnly(file), overwrite=not exclusive)
|
|
369
|
+
|
|
370
|
+
def _upload(self, data: bytes, *, key=None, exclusive=False) -> None:
|
|
371
|
+
blob_client = self._container.get_blob_client(self.key if key is None else key)
|
|
372
|
+
with translate_errors(self, create=exclusive):
|
|
373
|
+
# overwrite=False sends If-None-Match: *, so a concurrent creator
|
|
374
|
+
# makes this fail (409), never silently overwritten.
|
|
375
|
+
blob_client.upload_blob(data, overwrite=not exclusive)
|
|
376
|
+
|
|
377
|
+
def _flat_entries(self, prefix: str) -> "list[tuple[str, int]]":
|
|
378
|
+
"""`(name, size)` of every blob under `prefix`, with no delimiter."""
|
|
379
|
+
with translate_errors(self):
|
|
380
|
+
return [
|
|
381
|
+
(blob.name, blob.size or 0)
|
|
382
|
+
for blob in self._container.list_blobs(name_starts_with=prefix)
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
def _delete_each(self, keys, on_error, *, ignore_missing=False):
|
|
386
|
+
for key in keys:
|
|
387
|
+
try:
|
|
388
|
+
with translate_errors(self._key_path(key)):
|
|
389
|
+
self._container.get_blob_client(key).delete_blob()
|
|
390
|
+
except Exception as error:
|
|
391
|
+
if ignore_missing and isinstance(error, FileNotFoundError):
|
|
392
|
+
# Removed by the batch that was just retried.
|
|
393
|
+
continue
|
|
394
|
+
if not on_error(error, self._key_path(key)):
|
|
395
|
+
raise
|
|
396
|
+
|
|
397
|
+
@staticmethod
|
|
398
|
+
def _abort_copy(blob_client, copy_id) -> None:
|
|
399
|
+
"""Stop a copy that is still pending; best effort."""
|
|
400
|
+
if copy_id is not None:
|
|
401
|
+
try:
|
|
402
|
+
blob_client.abort_copy(copy_id)
|
|
403
|
+
except Exception:
|
|
404
|
+
pass
|
|
405
|
+
|
|
406
|
+
def _stat_root(self) -> FileStat:
|
|
407
|
+
# Ask the server, as S3Path does with HeadBucket, so a mistyped name
|
|
408
|
+
# does not read as an existing directory. A one-item listing needs only
|
|
409
|
+
# the permission the rest of the path uses.
|
|
410
|
+
with translate_errors(self):
|
|
411
|
+
for _ in self._container.list_blobs(
|
|
412
|
+
name_starts_with="", results_per_page=1
|
|
413
|
+
):
|
|
414
|
+
break
|
|
415
|
+
return FileStat(is_dir=True)
|
|
416
|
+
|
|
417
|
+
def _stat_object(self, key: str) -> "FileStat | None":
|
|
418
|
+
# Only a not-found reply means "no such blob": a transient or
|
|
419
|
+
# permission error read as "missing" let copy() replace an existing
|
|
420
|
+
# blob.
|
|
421
|
+
props = self._properties(key)
|
|
422
|
+
if props is None:
|
|
423
|
+
return None
|
|
424
|
+
return FileStat(
|
|
425
|
+
st_size=props["size"],
|
|
426
|
+
st_mtime=(
|
|
427
|
+
int(props["last_modified"].timestamp())
|
|
428
|
+
if props.get("last_modified")
|
|
429
|
+
else 0
|
|
430
|
+
),
|
|
431
|
+
is_dir=False,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def _stored_digests(self, key: str, algorithm: str) -> "dict[str, str] | None":
|
|
435
|
+
props = self._properties(key)
|
|
436
|
+
if props is None:
|
|
437
|
+
return None
|
|
438
|
+
settings = props.get("content_settings")
|
|
439
|
+
md5 = None if settings is None else settings.get("content_md5")
|
|
440
|
+
return {"md5": bytes(md5).hex()} if md5 else {}
|
|
441
|
+
|
|
442
|
+
def _has_object(self, key: str) -> bool:
|
|
443
|
+
return self._properties(key) is not None
|
|
444
|
+
|
|
445
|
+
def _has_prefix(self, key: str) -> bool:
|
|
446
|
+
with translate_errors(self):
|
|
447
|
+
# One item answers; the default page would carry up to 5000.
|
|
448
|
+
for _ in self._container.list_blobs(
|
|
449
|
+
name_starts_with=f"{key}/", results_per_page=1
|
|
450
|
+
):
|
|
451
|
+
return True
|
|
452
|
+
return False
|
|
453
|
+
|
|
454
|
+
def _first_keys(self, prefix: str, limit: int) -> "list[str]":
|
|
455
|
+
with translate_errors(self):
|
|
456
|
+
return [
|
|
457
|
+
blob.name
|
|
458
|
+
for blob in _itertools.islice(
|
|
459
|
+
self._container.list_blobs(
|
|
460
|
+
name_starts_with=prefix, results_per_page=limit
|
|
461
|
+
),
|
|
462
|
+
limit,
|
|
463
|
+
)
|
|
464
|
+
]
|
|
465
|
+
|
|
466
|
+
def _put_marker(self, key: str) -> None:
|
|
467
|
+
self._upload(b"", key=key, exclusive=True)
|
|
468
|
+
|
|
469
|
+
def _delete_object(self, key: str, *, missing_ok: bool) -> None:
|
|
470
|
+
blob_client = self._container.get_blob_client(key)
|
|
471
|
+
try:
|
|
472
|
+
with translate_errors(self):
|
|
473
|
+
blob_client.delete_blob()
|
|
474
|
+
except FileNotFoundError:
|
|
475
|
+
# Deleted since the caller looked. Anything else -- a lease, a
|
|
476
|
+
# permission error -- is not "already gone" and raises.
|
|
477
|
+
if not missing_ok:
|
|
478
|
+
raise
|
|
479
|
+
|
|
480
|
+
def _delete_keys(self, keys: "list[str]", on_error) -> None:
|
|
481
|
+
try:
|
|
482
|
+
delete_blobs = getattr(self._container, "delete_blobs")
|
|
483
|
+
except AttributeError:
|
|
484
|
+
delete_blobs = None
|
|
485
|
+
if callable(delete_blobs):
|
|
486
|
+
for index in range(0, len(keys), 256):
|
|
487
|
+
batch = keys[index : index + 256]
|
|
488
|
+
try:
|
|
489
|
+
delete_blobs(*batch)
|
|
490
|
+
except Exception:
|
|
491
|
+
# A partial failure has already deleted some keys, a rejected batch (auth on the
|
|
492
|
+
# batch endpoint, an emulator without batch support) none: either way every key is
|
|
493
|
+
# retried on its own, so one failing blob does not leave the rest behind.
|
|
494
|
+
self._delete_each(batch, on_error, ignore_missing=True)
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
self._delete_each(keys, on_error)
|
|
498
|
+
|
|
499
|
+
def _rename_source(self, key: str):
|
|
500
|
+
return self._properties(key)
|
|
501
|
+
|
|
502
|
+
def _copy_blob(self, dest: "AZPath", dest_key: str) -> None:
|
|
503
|
+
"""Copy this blob onto `dest_key` of its container with the service's
|
|
504
|
+
copy, waiting for it to finish; the destination holds the content when
|
|
505
|
+
it returns."""
|
|
506
|
+
source_url = self._container.get_blob_client(self.key).url
|
|
507
|
+
dest_blob_client = self._container.get_blob_client(dest_key)
|
|
508
|
+
# start_copy_from_url is async, poll for completion
|
|
509
|
+
copy_props = dest_blob_client.start_copy_from_url(source_url)
|
|
510
|
+
copy_id = _copy_id(copy_props)
|
|
511
|
+
# Poll until copy is complete
|
|
512
|
+
timeout = self._backend_setting("copy_timeout")
|
|
513
|
+
deadline = _time.monotonic() + timeout
|
|
514
|
+
while _copy_status(copy_props) == "pending":
|
|
515
|
+
if _time.monotonic() >= deadline:
|
|
516
|
+
self._abort_copy(dest_blob_client, copy_id)
|
|
517
|
+
raise TimeoutError(
|
|
518
|
+
_errno.ETIMEDOUT,
|
|
519
|
+
f"the copy did not finish within {timeout:g} s",
|
|
520
|
+
str(self),
|
|
521
|
+
)
|
|
522
|
+
_time.sleep(_COPY_POLL_INTERVAL)
|
|
523
|
+
dest_blob_client = self._container.get_blob_client(dest_key)
|
|
524
|
+
copy_props = dest_blob_client.get_blob_properties()
|
|
525
|
+
if _copy_status(copy_props) != "success":
|
|
526
|
+
raise OSError(f"Copy failed: {self} -> {dest}")
|
|
527
|
+
|
|
528
|
+
def _rename_copy(self, props, dest: "AZPath", dest_key: str) -> None:
|
|
529
|
+
with translate_errors(self):
|
|
530
|
+
self._copy_blob(dest, dest_key)
|
|
531
|
+
self._container.get_blob_client(self.key).delete_blob()
|
|
532
|
+
|
|
533
|
+
def _store_copy_possible(self, target) -> bool:
|
|
534
|
+
# The service copies within one container, as rename() does.
|
|
535
|
+
return super()._store_copy_possible(target) and self._place == target._place
|
|
536
|
+
|
|
537
|
+
def _copy_object(self, target: "AZPath", size: int) -> bool:
|
|
538
|
+
try:
|
|
539
|
+
self._copy_blob(target, target.key)
|
|
540
|
+
except Exception as error:
|
|
541
|
+
if http_status(error) == 501:
|
|
542
|
+
return False
|
|
543
|
+
translated = oserror(error, self)
|
|
544
|
+
if translated is None:
|
|
545
|
+
raise
|
|
546
|
+
raise translated from None
|
|
547
|
+
return True
|
objpath/_az_backend.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""The backends of `AZPath`: the contract and the `azure-storage-blob` one,
|
|
2
|
+
and the one every path built for an account without a backend shares."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import threading as _threading
|
|
7
|
+
import urllib.parse as _urlparse
|
|
8
|
+
|
|
9
|
+
from pathapi import providers as _providers
|
|
10
|
+
|
|
11
|
+
from ._clients import ClientBackend, LazyClientBackend
|
|
12
|
+
from ._optional import import_client
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseAZBackend(ClientBackend):
|
|
16
|
+
"""Protocol for obtaining an `azure.storage.blob.BlobServiceClient`.
|
|
17
|
+
Subclass this to plug in custom credential/session handling (e.g. tests
|
|
18
|
+
can override to point at a fake server); `AZBackend` is the real
|
|
19
|
+
implementation. `account`, when a subclass sets it, is the storage
|
|
20
|
+
account the client reaches."""
|
|
21
|
+
|
|
22
|
+
__slots__ = ()
|
|
23
|
+
|
|
24
|
+
account: "str | None" = None
|
|
25
|
+
|
|
26
|
+
#: Bytes of a blob that one request of a streamed read asks for. The SDK
|
|
27
|
+
#: holds about nine times a response while it handles it: a larger number
|
|
28
|
+
#: trades that memory for fewer requests.
|
|
29
|
+
read_chunk = 512 * 1024
|
|
30
|
+
|
|
31
|
+
#: Seconds `AZPath.rename()` and a server-side `copy()` wait for the service
|
|
32
|
+
#: copy to finish. A copy inside one account is normally done when it is
|
|
33
|
+
#: started; past this the copy is aborted (the destination may still appear
|
|
34
|
+
#: if the abort fails) and the call raises `TimeoutError`, the source
|
|
35
|
+
#: untouched.
|
|
36
|
+
copy_timeout = 300.0
|
|
37
|
+
|
|
38
|
+
@_providers.notimplemented
|
|
39
|
+
def client(self):
|
|
40
|
+
"""The `BlobServiceClient` every call of a path goes through."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _default_credential():
|
|
44
|
+
try:
|
|
45
|
+
from azure.identity import DefaultAzureCredential
|
|
46
|
+
except ImportError as error:
|
|
47
|
+
raise ImportError(
|
|
48
|
+
"an AZPath built with account= and no credential authenticates with "
|
|
49
|
+
"azure-identity's DefaultAzureCredential, which is not installed: "
|
|
50
|
+
'pip install "objpath[az]" (the az extra), or pass '
|
|
51
|
+
"backend=AZBackend(account_url=..., credential=...) or "
|
|
52
|
+
"AZBackend(connection_string=...)"
|
|
53
|
+
) from error
|
|
54
|
+
return DefaultAzureCredential()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _account_named_by(client_kwargs: dict) -> "str | None":
|
|
58
|
+
"""The storage account that `connection_string=` (`AccountName`) or
|
|
59
|
+
`account_url=` (the first label of a `*.blob.core.windows.net` host, else
|
|
60
|
+
the first path segment of an emulator's URL) names, or None."""
|
|
61
|
+
connection = client_kwargs.get("connection_string")
|
|
62
|
+
if connection:
|
|
63
|
+
for part in str(connection).split(";"):
|
|
64
|
+
name, _, value = part.partition("=")
|
|
65
|
+
if name.strip().lower() == "accountname" and value:
|
|
66
|
+
return value
|
|
67
|
+
url = client_kwargs.get("account_url")
|
|
68
|
+
if url:
|
|
69
|
+
parsed = _urlparse.urlparse(str(url))
|
|
70
|
+
host = parsed.hostname or ""
|
|
71
|
+
if host.endswith(".blob.core.windows.net"):
|
|
72
|
+
return host.split(".")[0]
|
|
73
|
+
return parsed.path.strip("/").split("/")[0] or None
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AZBackend(LazyClientBackend, BaseAZBackend):
|
|
78
|
+
"""Lazily creates and caches an `azure.storage.blob.BlobServiceClient`. A
|
|
79
|
+
single client is reused across threads -- it's documented as thread-safe.
|
|
80
|
+
|
|
81
|
+
`client_kwargs` go to `BlobServiceClient` unchanged; with
|
|
82
|
+
`connection_string`, to `BlobServiceClient.from_connection_string`
|
|
83
|
+
(`credential` and the other options included). `account`, when given
|
|
84
|
+
and neither `account_url` nor `connection_string` is, derives
|
|
85
|
+
`account_url="https://<account>.blob.core.windows.net"`, authenticated
|
|
86
|
+
by azure-identity's `DefaultAzureCredential` unless `credential` is
|
|
87
|
+
passed -- the backend an `AZPath` built with `account=` uses. Without
|
|
88
|
+
`account`, the account is the one `connection_string` or `account_url`
|
|
89
|
+
names, when they name one. A context manager: leaving the block closes the
|
|
90
|
+
client."""
|
|
91
|
+
|
|
92
|
+
__slots__ = ("client_kwargs", "account")
|
|
93
|
+
|
|
94
|
+
def __init__(self, account: "str | None" = None, **client_kwargs):
|
|
95
|
+
super().__init__()
|
|
96
|
+
self.client_kwargs = client_kwargs
|
|
97
|
+
self.account = (
|
|
98
|
+
account if account is not None else _account_named_by(client_kwargs)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def __repr__(self) -> str:
|
|
102
|
+
shown = [] if self.account is None else [f"account={self.account!r}"]
|
|
103
|
+
return f"AZBackend({', '.join([*shown, *sorted(self.client_kwargs)])})"
|
|
104
|
+
|
|
105
|
+
def _build_client(self):
|
|
106
|
+
BlobServiceClient = import_client("azure.storage.blob", "az").BlobServiceClient
|
|
107
|
+
|
|
108
|
+
kwargs = dict(self.client_kwargs)
|
|
109
|
+
if "connection_string" in kwargs:
|
|
110
|
+
connection_string = kwargs.pop("connection_string")
|
|
111
|
+
return BlobServiceClient.from_connection_string(connection_string, **kwargs)
|
|
112
|
+
if "account_url" not in kwargs and self.account:
|
|
113
|
+
kwargs["account_url"] = f"https://{self.account}.blob.core.windows.net"
|
|
114
|
+
if "credential" not in kwargs:
|
|
115
|
+
kwargs["credential"] = _default_credential()
|
|
116
|
+
return BlobServiceClient(**kwargs)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_DEFAULT_BACKENDS: "dict[str, AZBackend]" = {}
|
|
120
|
+
_DEFAULT_BACKENDS_LOCK = _threading.Lock()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _default_backend(account: str) -> AZBackend:
|
|
124
|
+
"""One shared default backend per account, so separately built paths
|
|
125
|
+
reuse one client (and one credential) instead of building their own."""
|
|
126
|
+
with _DEFAULT_BACKENDS_LOCK:
|
|
127
|
+
backend = _DEFAULT_BACKENDS.get(account)
|
|
128
|
+
if backend is None:
|
|
129
|
+
backend = _DEFAULT_BACKENDS[account] = AZBackend(account=account)
|
|
130
|
+
return backend
|