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