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/_uri_store.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""`ObjectStoreURIPath`, what `S3URIPath`, `GSURIPath` and `AZURIPath` share: a
|
|
2
|
+
URI path that hands every request to the path of its store that needs no URI,
|
|
3
|
+
and keeps what is about URIs -- the bucket or container the authority and the
|
|
4
|
+
first segment name, the backend the paths of one process share, and the
|
|
5
|
+
paths a callback is given."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typing as _ty
|
|
10
|
+
|
|
11
|
+
from pathapi.providers import check_follow, copy_error_predicate
|
|
12
|
+
from uripath.schemes import DelegatingURIPath
|
|
13
|
+
|
|
14
|
+
from ._keys import parse_names
|
|
15
|
+
from ._keys import checked_copy
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ObjectStoreURIPath(DelegatingURIPath):
|
|
19
|
+
"""A URI path of an object store, done by the path of the store that
|
|
20
|
+
needs no URI (`S3Path`, `GSPath`, `AZPath`) on the backend this path
|
|
21
|
+
carries. Subclasses say which bucket or container a URI names
|
|
22
|
+
(`_make_core()`) and how a path of the store maps back to a URI
|
|
23
|
+
(`_from_core()`)."""
|
|
24
|
+
|
|
25
|
+
__slots__ = ()
|
|
26
|
+
_DELEGATED = ("_scandir", "_listdir", "_open", "_mkdir", "unlink", "rmdir")
|
|
27
|
+
|
|
28
|
+
def _key_text(self, uri) -> str:
|
|
29
|
+
"""The part of the path of `uri` that is the object key, rooted: the
|
|
30
|
+
path itself, or what follows the container."""
|
|
31
|
+
return uri.path or "/"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def key(self) -> str:
|
|
35
|
+
"""The object key this path names: the path without its leading `/`s
|
|
36
|
+
and without one trailing `/`; an interior empty segment is part of
|
|
37
|
+
the key."""
|
|
38
|
+
return "/".join(parse_names(self._key_text(self))).lstrip("/")
|
|
39
|
+
|
|
40
|
+
def _core_of(self, target) -> "ObjectStoreURIPath":
|
|
41
|
+
"""The path of this path's core backend that the URI `target` names."""
|
|
42
|
+
return self._core_path.with_segments(self._key_text(target))
|
|
43
|
+
|
|
44
|
+
def _from_core(self, path) -> "ObjectStoreURIPath":
|
|
45
|
+
"""The URI path on this endpoint for a path of the core backend."""
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
def _store_copy_possible(self, target) -> bool:
|
|
49
|
+
"""Whether one request of the store can copy this object onto `target`,
|
|
50
|
+
from what the two URIs say without asking: the same class, reached by
|
|
51
|
+
one client (one supplied backend, or each path's own default)."""
|
|
52
|
+
return (
|
|
53
|
+
type(target) is type(self)
|
|
54
|
+
and bool(target.key)
|
|
55
|
+
and self._supplied_backend() is target._supplied_backend()
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def is_local(self) -> bool:
|
|
59
|
+
"""Whether the store is this machine: never. The host of the URI is a
|
|
60
|
+
bucket or an account, not a name to resolve."""
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
def supported_checksums(self) -> "_ty.FrozenSet[str]":
|
|
64
|
+
with self._delegated():
|
|
65
|
+
return self._core_path.supported_checksums()
|
|
66
|
+
|
|
67
|
+
def checksum(self, algorithm: str = "md5") -> str:
|
|
68
|
+
with self._delegated():
|
|
69
|
+
return self._core_path.checksum(algorithm)
|
|
70
|
+
|
|
71
|
+
def rename(self, target):
|
|
72
|
+
# A plain str target is a sibling rename (relative to self's parent): a
|
|
73
|
+
# literal name, not re-parsed as a URI. A target in another bucket or
|
|
74
|
+
# container cannot be reached by a native rename (NotImplementedError,
|
|
75
|
+
# so `move()` copies).
|
|
76
|
+
target = self._rename_target(target)
|
|
77
|
+
if isinstance(target, ObjectStoreURIPath) and not self._same_filesystem(target):
|
|
78
|
+
raise NotImplementedError("rename() onto another backend")
|
|
79
|
+
destination = self.with_path(target.path)
|
|
80
|
+
moved = self._core_of(target)
|
|
81
|
+
with self._delegated((str(moved), str(destination))):
|
|
82
|
+
self._core_path.rename(moved)
|
|
83
|
+
# pathlib returns the new path.
|
|
84
|
+
return destination
|
|
85
|
+
|
|
86
|
+
def rm(
|
|
87
|
+
self,
|
|
88
|
+
/,
|
|
89
|
+
recursive=False,
|
|
90
|
+
missing_ok=False,
|
|
91
|
+
ignore_error: "bool | _ty.Callable[[Exception, _ty.Self], bool]" = False,
|
|
92
|
+
*,
|
|
93
|
+
follow_symlinks=False,
|
|
94
|
+
follow_binds=False,
|
|
95
|
+
):
|
|
96
|
+
check_follow("follow_symlinks", follow_symlinks)
|
|
97
|
+
check_follow("follow_binds", follow_binds)
|
|
98
|
+
on_error = ignore_error
|
|
99
|
+
if callable(ignore_error):
|
|
100
|
+
|
|
101
|
+
def on_error(error, path=None):
|
|
102
|
+
return ignore_error(
|
|
103
|
+
error, self if path is None else self._from_core(path)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
with self._delegated():
|
|
107
|
+
return self._core_path.rm(
|
|
108
|
+
recursive=recursive,
|
|
109
|
+
missing_ok=missing_ok,
|
|
110
|
+
ignore_error=on_error,
|
|
111
|
+
follow_symlinks=follow_symlinks,
|
|
112
|
+
follow_binds=follow_binds,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def copy(
|
|
116
|
+
self,
|
|
117
|
+
target,
|
|
118
|
+
*,
|
|
119
|
+
overwrite=False,
|
|
120
|
+
follow_symlinks=True,
|
|
121
|
+
preserve_metadata=True,
|
|
122
|
+
recursive=False,
|
|
123
|
+
ignore_error=None,
|
|
124
|
+
progress=None,
|
|
125
|
+
on_error=None,
|
|
126
|
+
):
|
|
127
|
+
# The core path's walk of a prefix is not used: children are URI paths
|
|
128
|
+
# and are copied through this method, so the refusal of a prefix that
|
|
129
|
+
# holds keys a walk cannot reach is made here, once.
|
|
130
|
+
offering = copy_error_predicate(ignore_error, on_error)
|
|
131
|
+
if isinstance(target, str):
|
|
132
|
+
target = self._coerce_target(target)
|
|
133
|
+
with checked_copy(self._core_path, recursive):
|
|
134
|
+
if self._store_copy_possible(target):
|
|
135
|
+
shown = progress
|
|
136
|
+
if progress is not None:
|
|
137
|
+
|
|
138
|
+
def shown(_source, done, total):
|
|
139
|
+
progress(self, done, total)
|
|
140
|
+
|
|
141
|
+
moved = target._core_path
|
|
142
|
+
with self._delegated((str(moved), str(target))):
|
|
143
|
+
done = self._core_path._copy_in_store(
|
|
144
|
+
moved,
|
|
145
|
+
overwrite=overwrite,
|
|
146
|
+
follow_symlinks=follow_symlinks,
|
|
147
|
+
recursive=recursive,
|
|
148
|
+
progress=shown,
|
|
149
|
+
hint=self._stat_hint,
|
|
150
|
+
)
|
|
151
|
+
if done:
|
|
152
|
+
return
|
|
153
|
+
return super().copy(
|
|
154
|
+
target,
|
|
155
|
+
overwrite=overwrite,
|
|
156
|
+
follow_symlinks=follow_symlinks,
|
|
157
|
+
preserve_metadata=preserve_metadata,
|
|
158
|
+
recursive=recursive,
|
|
159
|
+
ignore_error=ignore_error,
|
|
160
|
+
progress=progress,
|
|
161
|
+
**({} if offering is None else {"on_error": offering}),
|
|
162
|
+
)
|
objpath/_version.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The version of the installed distribution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import metadata as _metadata
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = _metadata.version("objpath")
|
|
9
|
+
except _metadata.PackageNotFoundError:
|
|
10
|
+
# Run from a source tree that was never installed.
|
|
11
|
+
__version__ = "0+unknown"
|
objpath/_writer.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The spooled writer of an object stream."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io as _io
|
|
6
|
+
import tempfile as _tempfile
|
|
7
|
+
|
|
8
|
+
from pathapi import providers as _providers
|
|
9
|
+
|
|
10
|
+
SPOOL_SIZE = 4 * 1024**2
|
|
11
|
+
"""Bytes a write stream keeps in memory before spilling to a temp file, and
|
|
12
|
+
the largest object a store uploads in one request."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SpoolUploadStream(_providers.DiscardOnError, _io.BufferedIOBase):
|
|
16
|
+
"""The write stream of a store that uploads an object from a file: it
|
|
17
|
+
spools the writes (in memory up to `SPOOL_SIZE`, then a temp file) and
|
|
18
|
+
uploads them on close() with `path._upload_file(file, size, exclusive=)`.
|
|
19
|
+
|
|
20
|
+
`exclusive` (`open("x")`) makes the upload a conditional create. With
|
|
21
|
+
`initial` (`open("r+")`) the spool starts with the object's content at
|
|
22
|
+
position 0 and is uploaded only if it was modified."""
|
|
23
|
+
|
|
24
|
+
#: Bytes kept in memory before the spool moves to a temp file.
|
|
25
|
+
spool_size = SPOOL_SIZE
|
|
26
|
+
_dropped = False
|
|
27
|
+
_dirty = False
|
|
28
|
+
|
|
29
|
+
def __init__(self, path, exclusive=False, initial=None):
|
|
30
|
+
super().__init__()
|
|
31
|
+
self._path = path
|
|
32
|
+
self._exclusive = exclusive
|
|
33
|
+
self._file = _tempfile.SpooledTemporaryFile(max_size=self.spool_size)
|
|
34
|
+
self._dirty = initial is None
|
|
35
|
+
if initial is not None:
|
|
36
|
+
while True:
|
|
37
|
+
chunk = initial.read(_io.DEFAULT_BUFFER_SIZE)
|
|
38
|
+
if not chunk:
|
|
39
|
+
break
|
|
40
|
+
self._file.write(chunk)
|
|
41
|
+
self._file.seek(0)
|
|
42
|
+
|
|
43
|
+
def readable(self):
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
def writable(self):
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
def seekable(self):
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
def _check_open(self):
|
|
53
|
+
if self.closed:
|
|
54
|
+
raise ValueError("I/O operation on closed file.")
|
|
55
|
+
|
|
56
|
+
def write(self, data):
|
|
57
|
+
self._check_open()
|
|
58
|
+
self._dirty = True
|
|
59
|
+
return self._file.write(data)
|
|
60
|
+
|
|
61
|
+
def read(self, size=-1):
|
|
62
|
+
self._check_open()
|
|
63
|
+
return self._file.read(size)
|
|
64
|
+
|
|
65
|
+
def read1(self, size=-1):
|
|
66
|
+
return self.read(size)
|
|
67
|
+
|
|
68
|
+
def seek(self, offset, whence=_io.SEEK_SET):
|
|
69
|
+
self._check_open()
|
|
70
|
+
self._file.seek(offset, whence)
|
|
71
|
+
return self._file.tell()
|
|
72
|
+
|
|
73
|
+
def tell(self):
|
|
74
|
+
self._check_open()
|
|
75
|
+
return self._file.tell()
|
|
76
|
+
|
|
77
|
+
def truncate(self, size=None):
|
|
78
|
+
self._check_open()
|
|
79
|
+
self._dirty = True
|
|
80
|
+
if size is None:
|
|
81
|
+
size = self._file.tell()
|
|
82
|
+
return self._file.truncate(size)
|
|
83
|
+
|
|
84
|
+
def _holds_uncommitted(self):
|
|
85
|
+
return self._dirty and not self._dropped
|
|
86
|
+
|
|
87
|
+
def discard(self):
|
|
88
|
+
"""Drop the spool: `close()` uploads nothing. `open()` calls it when
|
|
89
|
+
the `with` block over the stream raised, and the collector when the
|
|
90
|
+
stream is finalized unclosed."""
|
|
91
|
+
self._dropped = True
|
|
92
|
+
self._file.close()
|
|
93
|
+
|
|
94
|
+
def close(self):
|
|
95
|
+
if self.closed:
|
|
96
|
+
return
|
|
97
|
+
try:
|
|
98
|
+
if self._dirty and not self._dropped:
|
|
99
|
+
self._upload()
|
|
100
|
+
finally:
|
|
101
|
+
# Closed even when the upload fails, so `IOBase.__del__` does not
|
|
102
|
+
# retry it (over newer content) at garbage collection.
|
|
103
|
+
try:
|
|
104
|
+
self._file.close()
|
|
105
|
+
finally:
|
|
106
|
+
super().close()
|
|
107
|
+
|
|
108
|
+
def _upload(self):
|
|
109
|
+
body = self._file
|
|
110
|
+
body.seek(0, _io.SEEK_END)
|
|
111
|
+
size = body.tell()
|
|
112
|
+
body.seek(0)
|
|
113
|
+
self._path._upload_file(body, size, exclusive=self._exclusive)
|
objpath/exceptions.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The exceptions `objpath` defines.
|
|
2
|
+
|
|
3
|
+
A failure of a store or of the connection to it is the `OSError` type that
|
|
4
|
+
`pathlib` raises for the same condition (`FileNotFoundError`,
|
|
5
|
+
`PermissionError`, `TimeoutError`, `ConnectionError`, ...); this module holds
|
|
6
|
+
the two that name a cause no builtin does. An exception of a store's SDK is
|
|
7
|
+
never raised across the API. A bad argument is a plain `ValueError` or
|
|
8
|
+
`TypeError`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathapi import PathError
|
|
14
|
+
|
|
15
|
+
__all__ = ["ObjPathError", "ObjectChangedError", "UnreachableKeysError"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ObjPathError(PathError):
|
|
19
|
+
"""Base class of the exceptions `objpath` defines for a failure at run
|
|
20
|
+
time that no builtin describes."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ObjectChangedError(ObjPathError, OSError):
|
|
24
|
+
"""An object was replaced or deleted while a stream was reading it (errno
|
|
25
|
+
`ESTALE`, else `EIO` where the platform has none): a read is one version
|
|
26
|
+
of the object and never the bytes of two."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UnreachableKeysError(ObjPathError, OSError):
|
|
30
|
+
"""A copy or a removal of a prefix was refused because the prefix holds
|
|
31
|
+
keys a listing does not show (errno `EINVAL`); nothing was changed."""
|
objpath/py.typed
ADDED
|
File without changes
|
objpath/uri.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""The `s3:`, `gs:` and `az:` schemes of `uripath`: `S3URIPath`, `GSURIPath` and
|
|
2
|
+
`AZURIPath`, URI paths that delegate to `S3Path`, `GSPath` and `AZPath`.
|
|
3
|
+
|
|
4
|
+
Importing this module needs `uripath`: `pip install "objpath[uri]"`. `uripath`
|
|
5
|
+
loads it by itself, through the `uripath.schemes` entry points, the first time
|
|
6
|
+
a path of one of the schemes is used.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import uripath as _uripath # noqa: F401
|
|
13
|
+
except ImportError as _error:
|
|
14
|
+
raise ImportError(
|
|
15
|
+
'objpath.uri needs the uripath distribution: pip install "objpath[uri]"',
|
|
16
|
+
name="uripath",
|
|
17
|
+
) from _error
|
|
18
|
+
|
|
19
|
+
import errno as _errno
|
|
20
|
+
import typing as _ty
|
|
21
|
+
|
|
22
|
+
from pathapi import FileStat
|
|
23
|
+
from pathapi import providers as _providers
|
|
24
|
+
|
|
25
|
+
from ._az import AZPath
|
|
26
|
+
from ._az_backend import BaseAZBackend
|
|
27
|
+
from ._az_backend import _default_backend as _az_default_backend
|
|
28
|
+
from ._az_errors import translate_errors as _az_translate_errors
|
|
29
|
+
from ._gs import GSPath
|
|
30
|
+
from ._gs_backend import BaseGSBackend
|
|
31
|
+
from ._gs_backend import _default_backend as _gs_default_backend
|
|
32
|
+
from ._s3_backend import BaseS3Backend
|
|
33
|
+
from ._s3_backend import _default_backend as _s3_default_backend
|
|
34
|
+
from ._uri_store import ObjectStoreURIPath
|
|
35
|
+
|
|
36
|
+
if _ty.TYPE_CHECKING:
|
|
37
|
+
from ._s3 import S3Path
|
|
38
|
+
|
|
39
|
+
__all__ = ["AZURIPath", "GSURIPath", "S3URIPath"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class S3URIPath(ObjectStoreURIPath):
|
|
43
|
+
"""`s3:` scheme (`s3://bucket/key/path`): read/write/list via `boto3`,
|
|
44
|
+
done by an `S3Path` on the client of `backend` (a `BaseS3Backend`; without
|
|
45
|
+
one, the backend every `s3:` path of the process shares). Needs the `s3`
|
|
46
|
+
extra. See `S3Path` for the prefix emulation of directories."""
|
|
47
|
+
|
|
48
|
+
__SCHEMES = ("s3",)
|
|
49
|
+
__slots__ = ()
|
|
50
|
+
|
|
51
|
+
if _ty.TYPE_CHECKING:
|
|
52
|
+
backend: BaseS3Backend
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def bucket(self) -> str:
|
|
56
|
+
"""The name of the bucket: the host of the URI."""
|
|
57
|
+
return self.source.host
|
|
58
|
+
|
|
59
|
+
def _initbackend(self):
|
|
60
|
+
return _s3_default_backend()
|
|
61
|
+
|
|
62
|
+
def _make_core(self) -> "S3Path":
|
|
63
|
+
# Imported here: the module reads botocore's exceptions when it is
|
|
64
|
+
# imported, and importing this one loads no SDK.
|
|
65
|
+
from ._s3 import S3Path
|
|
66
|
+
|
|
67
|
+
if not self.bucket:
|
|
68
|
+
raise FileNotFoundError(
|
|
69
|
+
_errno.ENOENT, "an s3: path needs a bucket", str(self)
|
|
70
|
+
)
|
|
71
|
+
return S3Path(self.path or "/", bucket=self.bucket, backend=self.backend)
|
|
72
|
+
|
|
73
|
+
def _from_core(self, path: "S3Path") -> "S3URIPath":
|
|
74
|
+
return self.with_path(f"/{path.key}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class GSURIPath(ObjectStoreURIPath):
|
|
78
|
+
"""`gs:` scheme (`gs://bucket/key/path`): read/write/list via
|
|
79
|
+
`google.cloud.storage`, done by a `GSPath` on the client of `backend` (a
|
|
80
|
+
`BaseGSBackend`; without one, the backend every `gs:` path of the process
|
|
81
|
+
shares). Needs the `gs` extra. See `GSPath` for the prefix emulation of
|
|
82
|
+
directories."""
|
|
83
|
+
|
|
84
|
+
__SCHEMES = ("gs",)
|
|
85
|
+
__slots__ = ()
|
|
86
|
+
|
|
87
|
+
if _ty.TYPE_CHECKING:
|
|
88
|
+
backend: BaseGSBackend
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def bucket(self) -> str:
|
|
92
|
+
"""The name of the bucket: the host of the URI."""
|
|
93
|
+
return self.source.host
|
|
94
|
+
|
|
95
|
+
def _initbackend(self):
|
|
96
|
+
return _gs_default_backend()
|
|
97
|
+
|
|
98
|
+
def _make_core(self) -> GSPath:
|
|
99
|
+
if not self.bucket:
|
|
100
|
+
raise FileNotFoundError(
|
|
101
|
+
_errno.ENOENT, "a gs: path needs a bucket", str(self)
|
|
102
|
+
)
|
|
103
|
+
return GSPath(self.path or "/", bucket=self.bucket, backend=self.backend)
|
|
104
|
+
|
|
105
|
+
def _from_core(self, path: GSPath) -> "GSURIPath":
|
|
106
|
+
return self.with_path(f"/{path.key}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AZURIPath(ObjectStoreURIPath):
|
|
110
|
+
"""`az:` scheme (`az://account/container/key/path`): read/write/list via
|
|
111
|
+
`azure.storage.blob`, done by an `AZPath` on the client of `backend` (a
|
|
112
|
+
`BaseAZBackend`; without one, the backend every path of the account shares,
|
|
113
|
+
authenticated by azure-identity's `DefaultAzureCredential`). Needs the `az`
|
|
114
|
+
extra. The account has the containers as its directories; see `AZPath`
|
|
115
|
+
for the prefix emulation of directories below them."""
|
|
116
|
+
|
|
117
|
+
__SCHEMES = ("az",)
|
|
118
|
+
__slots__ = ()
|
|
119
|
+
|
|
120
|
+
if _ty.TYPE_CHECKING:
|
|
121
|
+
backend: BaseAZBackend
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def account(self) -> str:
|
|
125
|
+
"""The storage account: the host of the URI."""
|
|
126
|
+
return self.source.host
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def container(self) -> str:
|
|
130
|
+
"""The name of the container: the first segment of the path, empty for
|
|
131
|
+
the account itself."""
|
|
132
|
+
return self.path.lstrip("/").split("/", 1)[0]
|
|
133
|
+
|
|
134
|
+
def _key_text(self, uri) -> str:
|
|
135
|
+
return "/" + uri.path.lstrip("/").partition("/")[2]
|
|
136
|
+
|
|
137
|
+
def _initbackend(self):
|
|
138
|
+
if not self.account:
|
|
139
|
+
raise FileNotFoundError(
|
|
140
|
+
_errno.ENOENT, "an az: path needs a storage account", str(self)
|
|
141
|
+
)
|
|
142
|
+
return _az_default_backend(self.account)
|
|
143
|
+
|
|
144
|
+
def _make_core(self) -> AZPath:
|
|
145
|
+
if not self.container:
|
|
146
|
+
# The account holds containers, not blobs; its few operations are
|
|
147
|
+
# written below.
|
|
148
|
+
self.backend
|
|
149
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
|
|
150
|
+
return AZPath(
|
|
151
|
+
self._key_text(self), container=self.container, backend=self.backend
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _from_core(self, path: AZPath) -> "AZURIPath":
|
|
155
|
+
return self.with_path(f"/{self.container}/{path.key}")
|
|
156
|
+
|
|
157
|
+
def _same_location(self, other) -> bool:
|
|
158
|
+
# The authority is the storage account; a rename is a blob copy within
|
|
159
|
+
# one container, so the container must match too.
|
|
160
|
+
if not super()._same_location(other):
|
|
161
|
+
return False
|
|
162
|
+
other_container = next((s for s in other.path.split("/") if s), "")
|
|
163
|
+
return other_container == self.container
|
|
164
|
+
|
|
165
|
+
def _store_copy_possible(self, target) -> bool:
|
|
166
|
+
# The service copies within one container, as rename() does.
|
|
167
|
+
return super()._store_copy_possible(target) and self._same_location(target)
|
|
168
|
+
|
|
169
|
+
def stat(self, *, follow_symlinks=True):
|
|
170
|
+
if self.container:
|
|
171
|
+
return super().stat(follow_symlinks=follow_symlinks)
|
|
172
|
+
self._pop_stat_hint()
|
|
173
|
+
# The account: ask the server whether it answers, as a container root
|
|
174
|
+
# does. A one-item listing needs only the permission the rest uses.
|
|
175
|
+
client = self.backend.client()
|
|
176
|
+
with _az_translate_errors(self):
|
|
177
|
+
for _ in client.list_containers(results_per_page=1):
|
|
178
|
+
break
|
|
179
|
+
return FileStat(is_dir=True)
|
|
180
|
+
|
|
181
|
+
def _scandir(self):
|
|
182
|
+
if self.container:
|
|
183
|
+
yield from super()._scandir()
|
|
184
|
+
return
|
|
185
|
+
# The account: its containers are its directories.
|
|
186
|
+
client = self.backend.client()
|
|
187
|
+
with _az_translate_errors(self):
|
|
188
|
+
names = [item.name for item in client.list_containers()]
|
|
189
|
+
for name in names:
|
|
190
|
+
if _providers.is_safe_child_name(name):
|
|
191
|
+
yield name, FileStat(is_dir=True)
|
|
192
|
+
|
|
193
|
+
def _mkdir(self, mode):
|
|
194
|
+
if self.container:
|
|
195
|
+
return super()._mkdir(mode)
|
|
196
|
+
# The account exists, or stat() says it does not.
|
|
197
|
+
self.stat()
|
|
198
|
+
raise FileExistsError(_errno.EEXIST, "File exists", str(self))
|
|
199
|
+
|
|
200
|
+
def rmdir(self):
|
|
201
|
+
if self.container:
|
|
202
|
+
return super().rmdir()
|
|
203
|
+
self.stat()
|
|
204
|
+
raise PermissionError(
|
|
205
|
+
_errno.EACCES, "removing a container is not supported", str(self)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _open(self, mode="r", buffering=-1):
|
|
209
|
+
if not self.container:
|
|
210
|
+
# The backend of an account-less path is the error it raises.
|
|
211
|
+
self.backend
|
|
212
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
|
|
213
|
+
return super()._open(mode, buffering)
|