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