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/_storepath.py
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""`ObjectStorePath`, what `S3Path`, `GSPath` and `AZPath` share: a key
|
|
2
|
+
namespace with no directories, where a directory is any prefix `key/` that has
|
|
3
|
+
keys under it and `mkdir()` writes an empty `key/` marker."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import errno as _errno
|
|
8
|
+
import stat as _stat
|
|
9
|
+
import typing as _ty
|
|
10
|
+
import urllib.parse as _urlparse
|
|
11
|
+
|
|
12
|
+
from pathapi import FileStat, Path, Pathname
|
|
13
|
+
from pathapi.providers import TreePath, check_follow, copy_error_predicate
|
|
14
|
+
|
|
15
|
+
from ._keys import (
|
|
16
|
+
checked_copy,
|
|
17
|
+
parse_names,
|
|
18
|
+
refusal,
|
|
19
|
+
split_names,
|
|
20
|
+
unreachable_keys,
|
|
21
|
+
)
|
|
22
|
+
from ._places import rebuild_path
|
|
23
|
+
|
|
24
|
+
#: What a path text keeps unescaped when it is spelled as a URI.
|
|
25
|
+
_SAFE_KEY = "!$&'()*+,;=:@/"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ObjectStorePath(TreePath):
|
|
29
|
+
"""The algorithms `S3Path`, `GSPath` and `AZPath` share. A store supplies
|
|
30
|
+
the calls that differ -- one SDK request each, errors already translated
|
|
31
|
+
to `OSError` (see `transport_error()`) -- and gets `stat()`, `rm()`,
|
|
32
|
+
`unlink()`, `rmdir()`, `rename()`, `copy()` and the rest from here.
|
|
33
|
+
|
|
34
|
+
A path holds the names of a key below its bucket or container, joined and
|
|
35
|
+
parsed as `parse_names()` says, and the backend that reaches the store.
|
|
36
|
+
Two paths are equal when they are of one class, name one bucket or
|
|
37
|
+
container and spell one key; a path built from another keeps its place and
|
|
38
|
+
its backend.
|
|
39
|
+
|
|
40
|
+
Primitives a subclass defines:
|
|
41
|
+
|
|
42
|
+
- `_stat_root()` -> `FileStat`: the bucket, container or account exists.
|
|
43
|
+
- `_stat_object(key)` -> `FileStat | None`: None when no object has that
|
|
44
|
+
exact key.
|
|
45
|
+
- `_has_object(key)` -> bool, `_has_prefix(key)` -> bool: whether an
|
|
46
|
+
object has that key, whether any key lies under `"<key>/"` (a one-item
|
|
47
|
+
listing).
|
|
48
|
+
- `_flat_entries(prefix)` -> `[(key, size)]`: every key under `prefix`.
|
|
49
|
+
- `_first_keys(prefix, limit)` -> `[key]`: at most `limit` keys under it.
|
|
50
|
+
- `_put_marker(key)`, `_delete_object(key, missing_ok=)`,
|
|
51
|
+
`_delete_keys(keys, on_error)`.
|
|
52
|
+
- `_rename_source(key)` -> a store-specific record, or None when there is
|
|
53
|
+
no object; `_rename_copy(source, dest, dest_key)`.
|
|
54
|
+
- `_stored_digests(key, algorithm)` -> `{name: hex}`, or None when there is
|
|
55
|
+
no object: the digests of the whole content the store keeps.
|
|
56
|
+
- `_copy_object(target, size)` -> bool: the store's own copy call, this
|
|
57
|
+
object onto `target`'s key; False when the store does not implement it.
|
|
58
|
+
- `_uri_root()` -> the text before the key in `as_uri()`; `_keywords()` ->
|
|
59
|
+
the constructor keywords that name this path's place;
|
|
60
|
+
`_default_keywords()` -> those that make its default backend.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
__slots__ = ("_backend", "_place", "_hint")
|
|
64
|
+
|
|
65
|
+
#: What the store calls its top level, for the refusals to remove it.
|
|
66
|
+
_TOP = "bucket"
|
|
67
|
+
#: The algorithms `supported_checksums()` offers.
|
|
68
|
+
_CHECKSUMS: "frozenset[str]" = frozenset({"md5"})
|
|
69
|
+
#: Exceptions `rm(recursive=True)` offers to `ignore_error` when listing a
|
|
70
|
+
#: prefix fails (a missing bucket is not one).
|
|
71
|
+
_LISTING_ERRORS: "tuple[type[BaseException], ...]" = (OSError,)
|
|
72
|
+
|
|
73
|
+
if _ty.TYPE_CHECKING:
|
|
74
|
+
_segments: "tuple[str, ...]"
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _parse(path: str) -> "tuple[str, ...]":
|
|
78
|
+
return parse_names(path)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def _join(cls, segments):
|
|
82
|
+
# The slash that ends a piece separates it from the next one, as in
|
|
83
|
+
# pathlib: `"a/"` joined with `"b"` is `a/b`.
|
|
84
|
+
path = ""
|
|
85
|
+
donor = None
|
|
86
|
+
family = cls._family()
|
|
87
|
+
for segment in segments:
|
|
88
|
+
if isinstance(segment, family):
|
|
89
|
+
text = segment.as_posix()
|
|
90
|
+
if donor is None:
|
|
91
|
+
donor = segment
|
|
92
|
+
else:
|
|
93
|
+
text = "/".join(cls._text_of(segment))
|
|
94
|
+
if text.startswith("/") or not path:
|
|
95
|
+
path = text
|
|
96
|
+
elif text:
|
|
97
|
+
path = f"{path[:-1] if path.endswith('/') else path}/{text}"
|
|
98
|
+
return path, donor
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _text_of(segment) -> "tuple[str, ...]":
|
|
102
|
+
if isinstance(segment, Path):
|
|
103
|
+
raise NotImplementedError()
|
|
104
|
+
if isinstance(segment, Pathname):
|
|
105
|
+
return tuple(segment.segments)
|
|
106
|
+
if isinstance(segment, str):
|
|
107
|
+
return (segment,)
|
|
108
|
+
raise TypeError(
|
|
109
|
+
"argument should be a str or a Pathname, " f"not {type(segment).__name__!r}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _initialize(self, text: str, place: str, backend) -> None:
|
|
113
|
+
self._segments = self._parse(text)
|
|
114
|
+
self._place = place
|
|
115
|
+
self._backend = backend
|
|
116
|
+
self._hint = None
|
|
117
|
+
|
|
118
|
+
def _derived(self, segments: "tuple[str, ...]"):
|
|
119
|
+
"""A path of this class, place and backend with the names `segments`."""
|
|
120
|
+
path = object.__new__(type(self))
|
|
121
|
+
path._segments = segments
|
|
122
|
+
path._place = self._place
|
|
123
|
+
path._backend = self._backend
|
|
124
|
+
path._hint = None
|
|
125
|
+
return path
|
|
126
|
+
|
|
127
|
+
def _rebuild(self, text: str):
|
|
128
|
+
return self._derived(split_names(text))
|
|
129
|
+
|
|
130
|
+
def with_segments(self, *segments: str):
|
|
131
|
+
return type(self)("/".join(segments), **self._keywords(), backend=self._backend)
|
|
132
|
+
|
|
133
|
+
def _keywords(self) -> dict:
|
|
134
|
+
"""The constructor keywords that name this path's place."""
|
|
135
|
+
raise NotImplementedError
|
|
136
|
+
|
|
137
|
+
def _uri_root(self) -> str:
|
|
138
|
+
raise NotImplementedError
|
|
139
|
+
|
|
140
|
+
def is_local(self) -> bool:
|
|
141
|
+
"""Whether the store is this machine: never. A bucket is not a host
|
|
142
|
+
name, so nothing is resolved; `PathSyncer(quick_check=True)` treats
|
|
143
|
+
both sides of a sync as non-local."""
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def backend(self):
|
|
148
|
+
"""The backend that reaches the store for this path."""
|
|
149
|
+
return self._backend
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def key(self) -> str:
|
|
153
|
+
"""The object key this path names: its names joined by `/`, with no
|
|
154
|
+
leading `/`."""
|
|
155
|
+
return "/".join(self._segments).lstrip("/")
|
|
156
|
+
|
|
157
|
+
def _identity(self) -> tuple:
|
|
158
|
+
return (self._place, self.key)
|
|
159
|
+
|
|
160
|
+
def __eq__(self, other: object):
|
|
161
|
+
if type(self) is not type(other):
|
|
162
|
+
return NotImplemented
|
|
163
|
+
return self._identity() == other._identity()
|
|
164
|
+
|
|
165
|
+
def __hash__(self) -> int:
|
|
166
|
+
return hash((type(self), self._identity()))
|
|
167
|
+
|
|
168
|
+
def _order_key(self):
|
|
169
|
+
return self._identity()
|
|
170
|
+
|
|
171
|
+
def as_uri(self) -> str:
|
|
172
|
+
key = self.key
|
|
173
|
+
text = _urlparse.quote(key, safe=_SAFE_KEY, errors="surrogateescape")
|
|
174
|
+
return f"{self._uri_root()}/{text}" + ("/" if key.endswith("/") else "")
|
|
175
|
+
|
|
176
|
+
def __str__(self) -> str:
|
|
177
|
+
return self.as_uri()
|
|
178
|
+
|
|
179
|
+
def __reduce__(self):
|
|
180
|
+
# The place and the names; a backend travels only when it says it can
|
|
181
|
+
# (its options carry credentials), else the receiving process builds
|
|
182
|
+
# the default one.
|
|
183
|
+
keywords = self._keywords()
|
|
184
|
+
if getattr(self._backend, "picklable", False):
|
|
185
|
+
keywords["backend"] = self._backend
|
|
186
|
+
else:
|
|
187
|
+
keywords.update(self._default_keywords())
|
|
188
|
+
return rebuild_path, (type(self), self._segments, keywords)
|
|
189
|
+
|
|
190
|
+
def _default_keywords(self) -> dict:
|
|
191
|
+
"""The constructor keywords that make the default backend of this
|
|
192
|
+
path's store, for a path that is pickled without its own backend."""
|
|
193
|
+
return {}
|
|
194
|
+
|
|
195
|
+
def _same_filesystem(self, other: "ObjectStorePath") -> bool:
|
|
196
|
+
# One bucket or container reached by one backend: a client is the
|
|
197
|
+
# credentials, so another backend may see another store.
|
|
198
|
+
return self._place == other._place and self._backend is other._backend
|
|
199
|
+
|
|
200
|
+
def _pop_stat_hint(self) -> "FileStat | None":
|
|
201
|
+
"""The stat that the listing of the parent attached to this child,
|
|
202
|
+
once; the next call asks the store."""
|
|
203
|
+
hint, self._hint = self._hint, None
|
|
204
|
+
return hint
|
|
205
|
+
|
|
206
|
+
def _make_child_relpath(self, name: str, stat_hint=None, **kwargs):
|
|
207
|
+
"""The child called exactly `name`, with the stat its listing gave."""
|
|
208
|
+
segments = self._segments
|
|
209
|
+
if segments == ("", ""):
|
|
210
|
+
names = ("", name)
|
|
211
|
+
else:
|
|
212
|
+
names = (*segments, name)
|
|
213
|
+
child = self._derived(names)
|
|
214
|
+
child._hint = stat_hint
|
|
215
|
+
return child
|
|
216
|
+
|
|
217
|
+
def iterdir(self):
|
|
218
|
+
for name, listed in self._scandir():
|
|
219
|
+
yield self._make_child_relpath(name, stat_hint=listed)
|
|
220
|
+
|
|
221
|
+
def _key_path(self, key: str) -> "ObjectStorePath":
|
|
222
|
+
"""The path of the object `key` in this path's bucket or container."""
|
|
223
|
+
return self._derived(("", *key.split("/")) if key else ("", ""))
|
|
224
|
+
|
|
225
|
+
def _stat_root(self) -> FileStat:
|
|
226
|
+
raise NotImplementedError
|
|
227
|
+
|
|
228
|
+
def _stat_object(self, key: str) -> "FileStat | None":
|
|
229
|
+
raise NotImplementedError
|
|
230
|
+
|
|
231
|
+
def _has_object(self, key: str) -> bool:
|
|
232
|
+
raise NotImplementedError
|
|
233
|
+
|
|
234
|
+
def _has_prefix(self, key: str) -> bool:
|
|
235
|
+
raise NotImplementedError
|
|
236
|
+
|
|
237
|
+
def _flat_entries(self, prefix: str) -> "list[tuple[str, int]]":
|
|
238
|
+
raise NotImplementedError
|
|
239
|
+
|
|
240
|
+
def _first_keys(self, prefix: str, limit: int) -> "list[str]":
|
|
241
|
+
raise NotImplementedError
|
|
242
|
+
|
|
243
|
+
def _put_marker(self, key: str) -> None:
|
|
244
|
+
raise NotImplementedError
|
|
245
|
+
|
|
246
|
+
def _delete_object(self, key: str, *, missing_ok: bool) -> None:
|
|
247
|
+
raise NotImplementedError
|
|
248
|
+
|
|
249
|
+
def _delete_keys(self, keys: "list[str]", on_error) -> None:
|
|
250
|
+
raise NotImplementedError
|
|
251
|
+
|
|
252
|
+
def _rename_source(self, key: str):
|
|
253
|
+
raise NotImplementedError
|
|
254
|
+
|
|
255
|
+
def _rename_copy(self, source, dest: "ObjectStorePath", dest_key: str) -> None:
|
|
256
|
+
raise NotImplementedError
|
|
257
|
+
|
|
258
|
+
def _stored_digests(self, key: str, algorithm: str) -> "dict[str, str] | None":
|
|
259
|
+
"""The digests of the whole content of the object `key` that the store
|
|
260
|
+
keeps (at least `algorithm`'s, when it keeps it), by lower-case
|
|
261
|
+
`hashlib` name and as hex; empty when it keeps none a reader can rely
|
|
262
|
+
on. None when no object has that key."""
|
|
263
|
+
raise NotImplementedError
|
|
264
|
+
|
|
265
|
+
def _copy_object(self, target: "ObjectStorePath", size: int) -> bool:
|
|
266
|
+
raise NotImplementedError
|
|
267
|
+
|
|
268
|
+
def stat(self, *, follow_symlinks=True):
|
|
269
|
+
hint = self._pop_stat_hint()
|
|
270
|
+
if hint is not None:
|
|
271
|
+
return hint
|
|
272
|
+
key = self.key
|
|
273
|
+
if key == "":
|
|
274
|
+
return self._stat_root()
|
|
275
|
+
found = self._stat_object(key)
|
|
276
|
+
if found is not None:
|
|
277
|
+
return found
|
|
278
|
+
return self._stat_prefix()
|
|
279
|
+
|
|
280
|
+
def supported_checksums(self) -> "frozenset[str]":
|
|
281
|
+
"""The digest algorithms the store usually keeps for an object, so a
|
|
282
|
+
caller can choose one two paths share (`NativeChecksum`). It asks
|
|
283
|
+
nothing of the store; `checksum()` is what decides, per object."""
|
|
284
|
+
return self._CHECKSUMS
|
|
285
|
+
|
|
286
|
+
def checksum(self, algorithm: str = "md5") -> str:
|
|
287
|
+
"""The hex digest of this object's whole content under `algorithm`
|
|
288
|
+
(a `hashlib` name), as the store keeps it: no byte is read
|
|
289
|
+
(`NativeChecksum`).
|
|
290
|
+
|
|
291
|
+
Raises `NotImplementedError` when the store keeps no such digest of
|
|
292
|
+
the content -- an ETag that is not the plain MD5 (multipart,
|
|
293
|
+
KMS-encrypted), a composite checksum, an absent property -- never
|
|
294
|
+
returning one that might not be. `FileNotFoundError` for a missing
|
|
295
|
+
path and `IsADirectoryError` for a prefix directory; a request that
|
|
296
|
+
fails raises the `OSError` it always does."""
|
|
297
|
+
key = self.key
|
|
298
|
+
digests = self._stored_digests(key, algorithm.lower()) if key else None
|
|
299
|
+
if digests is None:
|
|
300
|
+
# No object at the key: the name of a directory, or of nothing.
|
|
301
|
+
if key:
|
|
302
|
+
self._stat_prefix()
|
|
303
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
|
|
304
|
+
try:
|
|
305
|
+
return digests[algorithm.lower()]
|
|
306
|
+
except KeyError:
|
|
307
|
+
raise NotImplementedError(
|
|
308
|
+
f"the store keeps no {algorithm} digest of the content of {self}"
|
|
309
|
+
) from None
|
|
310
|
+
|
|
311
|
+
def _stat_prefix(self) -> FileStat:
|
|
312
|
+
"""`stat()` of a key already known not to be an object -- emulate a
|
|
313
|
+
directory: any object under the "<key>/" prefix means this is a
|
|
314
|
+
"directory", and none means there is nothing here."""
|
|
315
|
+
if self._has_prefix(self.key):
|
|
316
|
+
return FileStat(is_dir=True)
|
|
317
|
+
raise FileNotFoundError(self)
|
|
318
|
+
|
|
319
|
+
def _holds_keys(self, key: str) -> bool:
|
|
320
|
+
"""Whether any key lies under the prefix `key/`: a write or rename
|
|
321
|
+
onto it would hide those keys behind an object. Credentials that may
|
|
322
|
+
write but not list cannot ask, and are not stopped."""
|
|
323
|
+
if not key:
|
|
324
|
+
return False
|
|
325
|
+
try:
|
|
326
|
+
return self._has_prefix(key)
|
|
327
|
+
except PermissionError:
|
|
328
|
+
return False
|
|
329
|
+
|
|
330
|
+
def _mkdir(self, mode):
|
|
331
|
+
# stat(), not exists(): a failed probe must not read as "missing".
|
|
332
|
+
try:
|
|
333
|
+
self.stat()
|
|
334
|
+
except FileNotFoundError:
|
|
335
|
+
pass
|
|
336
|
+
else:
|
|
337
|
+
raise FileExistsError(self)
|
|
338
|
+
self._put_marker(f"{self.key}/")
|
|
339
|
+
|
|
340
|
+
def unlink(self, missing_ok=False):
|
|
341
|
+
try:
|
|
342
|
+
st = self.stat()
|
|
343
|
+
except FileNotFoundError:
|
|
344
|
+
if missing_ok:
|
|
345
|
+
return
|
|
346
|
+
raise
|
|
347
|
+
if st.is_dir():
|
|
348
|
+
# A prefix: deleting the bare key would delete nothing (S3) or
|
|
349
|
+
# something else.
|
|
350
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
|
|
351
|
+
self._delete_object(self.key, missing_ok=missing_ok)
|
|
352
|
+
|
|
353
|
+
def rmdir(self):
|
|
354
|
+
if not self.stat().is_dir():
|
|
355
|
+
raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
|
|
356
|
+
if not self.key:
|
|
357
|
+
raise PermissionError(
|
|
358
|
+
_errno.EACCES, f"removing a {self._TOP} is not supported", str(self)
|
|
359
|
+
)
|
|
360
|
+
marker = f"{self.key}/"
|
|
361
|
+
if any(name != marker for name in self._first_keys(marker, 2)):
|
|
362
|
+
raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(self))
|
|
363
|
+
self._delete_object(marker, missing_ok=True)
|
|
364
|
+
|
|
365
|
+
def rm(
|
|
366
|
+
self,
|
|
367
|
+
/,
|
|
368
|
+
recursive=False,
|
|
369
|
+
missing_ok=False,
|
|
370
|
+
ignore_error: "bool | _ty.Callable[[Exception, _ty.Self], bool]" = False,
|
|
371
|
+
*,
|
|
372
|
+
follow_symlinks=False,
|
|
373
|
+
follow_binds=False,
|
|
374
|
+
):
|
|
375
|
+
# An object store holds no symlinks or bindings: the policies are
|
|
376
|
+
# checked and have nothing to decide.
|
|
377
|
+
check_follow("follow_symlinks", follow_symlinks)
|
|
378
|
+
check_follow("follow_binds", follow_binds)
|
|
379
|
+
if not recursive:
|
|
380
|
+
return super().rm(
|
|
381
|
+
recursive=recursive,
|
|
382
|
+
missing_ok=missing_ok,
|
|
383
|
+
ignore_error=ignore_error,
|
|
384
|
+
follow_symlinks=follow_symlinks,
|
|
385
|
+
follow_binds=follow_binds,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def on_error(error, path=None):
|
|
389
|
+
if callable(ignore_error):
|
|
390
|
+
return ignore_error(error, self if path is None else path)
|
|
391
|
+
return bool(ignore_error)
|
|
392
|
+
|
|
393
|
+
if not self.key:
|
|
394
|
+
error = PermissionError(f"recursive {self._TOP} delete is not enabled")
|
|
395
|
+
if not on_error(error):
|
|
396
|
+
raise error
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
keys = []
|
|
400
|
+
try:
|
|
401
|
+
if self._has_object(self.key):
|
|
402
|
+
keys.append(self.key)
|
|
403
|
+
except OSError as error:
|
|
404
|
+
# Not "missing": deleting the prefix tree instead of the object
|
|
405
|
+
# would remove the wrong thing.
|
|
406
|
+
if not on_error(error):
|
|
407
|
+
raise
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
if not keys:
|
|
411
|
+
marker = f"{self.key}/"
|
|
412
|
+
try:
|
|
413
|
+
entries = self._flat_entries(marker)
|
|
414
|
+
except FileNotFoundError:
|
|
415
|
+
# A missing bucket holds nothing: the same answer as a missing key.
|
|
416
|
+
entries = []
|
|
417
|
+
except self._LISTING_ERRORS as error:
|
|
418
|
+
if not on_error(error):
|
|
419
|
+
raise
|
|
420
|
+
return
|
|
421
|
+
keys = [key for key, _size in entries]
|
|
422
|
+
# What a walk of the directory would not visit is not removed
|
|
423
|
+
# with it: a move copies by walking and then removes this set.
|
|
424
|
+
unreachable = unreachable_keys(marker, entries)
|
|
425
|
+
if unreachable:
|
|
426
|
+
error = refusal(self, "remove", unreachable)
|
|
427
|
+
if not on_error(error):
|
|
428
|
+
raise error
|
|
429
|
+
skipped = set(unreachable)
|
|
430
|
+
keys = [key for key in keys if key not in skipped]
|
|
431
|
+
if not keys:
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
if not keys:
|
|
435
|
+
if missing_ok:
|
|
436
|
+
return
|
|
437
|
+
error = FileNotFoundError(self)
|
|
438
|
+
if not on_error(error):
|
|
439
|
+
raise error
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
self._delete_keys(keys, on_error)
|
|
443
|
+
|
|
444
|
+
def _unreachable_keys(self) -> "list[str]":
|
|
445
|
+
"""The keys of this prefix directory that a walk does not reach, or
|
|
446
|
+
`[]` for an object or a missing path."""
|
|
447
|
+
key = self.key
|
|
448
|
+
if key and self._has_object(key):
|
|
449
|
+
return []
|
|
450
|
+
prefix = f"{key}/" if key else ""
|
|
451
|
+
return unreachable_keys(prefix, self._flat_entries(prefix))
|
|
452
|
+
|
|
453
|
+
def copy(
|
|
454
|
+
self,
|
|
455
|
+
target,
|
|
456
|
+
*,
|
|
457
|
+
overwrite=False,
|
|
458
|
+
follow_symlinks=True,
|
|
459
|
+
preserve_metadata=True,
|
|
460
|
+
recursive=False,
|
|
461
|
+
ignore_error=None,
|
|
462
|
+
progress=None,
|
|
463
|
+
on_error=None,
|
|
464
|
+
):
|
|
465
|
+
# A prefix is copied by walking it; one that holds keys the walk
|
|
466
|
+
# cannot reach is refused up front rather than copied incompletely.
|
|
467
|
+
# That refusal is not a per-child error: `on_error` is never asked.
|
|
468
|
+
copy_error_predicate(ignore_error, on_error)
|
|
469
|
+
if isinstance(target, str):
|
|
470
|
+
target = self._coerce_target(target)
|
|
471
|
+
with checked_copy(self, recursive):
|
|
472
|
+
if self._store_copy_possible(target) and self._copy_in_store(
|
|
473
|
+
target,
|
|
474
|
+
overwrite=overwrite,
|
|
475
|
+
follow_symlinks=follow_symlinks,
|
|
476
|
+
recursive=recursive,
|
|
477
|
+
progress=progress,
|
|
478
|
+
):
|
|
479
|
+
return
|
|
480
|
+
return super().copy(
|
|
481
|
+
target,
|
|
482
|
+
overwrite=overwrite,
|
|
483
|
+
follow_symlinks=follow_symlinks,
|
|
484
|
+
preserve_metadata=preserve_metadata,
|
|
485
|
+
recursive=recursive,
|
|
486
|
+
ignore_error=ignore_error,
|
|
487
|
+
progress=progress,
|
|
488
|
+
**({} if on_error is None else {"on_error": on_error}),
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
def _store_copy_possible(self, target) -> bool:
|
|
492
|
+
"""Whether one request of the store can copy this object onto
|
|
493
|
+
`target`, from what the two paths say without asking: the same class,
|
|
494
|
+
reached by one backend (a bucket may be another)."""
|
|
495
|
+
return (
|
|
496
|
+
type(target) is type(self)
|
|
497
|
+
and bool(target.key)
|
|
498
|
+
and self._backend is target._backend
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
def _copy_in_store(
|
|
502
|
+
self, target, *, overwrite, follow_symlinks, recursive, progress, hint=None
|
|
503
|
+
) -> bool:
|
|
504
|
+
"""Copy this object onto `target` with the store's own copy call, after
|
|
505
|
+
the checks `Path.copy()` makes before it writes, in the same order: the
|
|
506
|
+
same file, a target that is a directory or holds keys, an existing
|
|
507
|
+
target without `overwrite`. Nothing is written when one fails, and a
|
|
508
|
+
copy call that fails leaves an existing target as it was.
|
|
509
|
+
|
|
510
|
+
False when `Path.copy()` is to do it: the source is not an object (a
|
|
511
|
+
prefix directory, a missing path), the store does not implement the
|
|
512
|
+
call, or the source is the root of a recursive copy, which may be
|
|
513
|
+
either and which `Path.copy()` asks about once, with what `move()`
|
|
514
|
+
already knows. An object has no permission bits, so
|
|
515
|
+
`preserve_metadata` has nothing to carry over.
|
|
516
|
+
|
|
517
|
+
`hint` is the stat a listing gave for this path, when the caller holds
|
|
518
|
+
one that this path does not."""
|
|
519
|
+
hint = self._hint if hint is None else hint
|
|
520
|
+
if (hint is None and recursive) or (
|
|
521
|
+
hint is not None and not _stat.S_ISREG(hint.st_mode)
|
|
522
|
+
):
|
|
523
|
+
return False
|
|
524
|
+
try:
|
|
525
|
+
src_stat = self.stat(follow_symlinks=follow_symlinks)
|
|
526
|
+
except (OSError, ValueError, NotImplementedError):
|
|
527
|
+
return False
|
|
528
|
+
if not _stat.S_ISREG(src_stat.st_mode):
|
|
529
|
+
return False
|
|
530
|
+
# The same object under two spellings of its key is one file too.
|
|
531
|
+
if self._same_filesystem(target) and self.key == target.key:
|
|
532
|
+
raise OSError(
|
|
533
|
+
_errno.EINVAL, "Source and target are the same file", str(target)
|
|
534
|
+
)
|
|
535
|
+
# stat(), not exists(): only FileNotFoundError means the target is
|
|
536
|
+
# absent, never a failed probe.
|
|
537
|
+
try:
|
|
538
|
+
target_stat = target.stat()
|
|
539
|
+
except FileNotFoundError:
|
|
540
|
+
target_stat = None
|
|
541
|
+
if target_stat is not None:
|
|
542
|
+
if _stat.S_ISDIR(target_stat.st_mode):
|
|
543
|
+
raise IsADirectoryError(target)
|
|
544
|
+
if not overwrite:
|
|
545
|
+
raise FileExistsError(target)
|
|
546
|
+
size = src_stat.st_size
|
|
547
|
+
if not self._copy_object(target, size):
|
|
548
|
+
return False
|
|
549
|
+
if progress is not None:
|
|
550
|
+
progress(self, size, size)
|
|
551
|
+
return True
|
|
552
|
+
|
|
553
|
+
def rename(self, target):
|
|
554
|
+
# A plain str target is a literal sibling name; a path of another
|
|
555
|
+
# bucket, another backend or another class cannot be renamed onto, and
|
|
556
|
+
# `move()` copies instead.
|
|
557
|
+
if isinstance(target, str):
|
|
558
|
+
target = self.parent / target
|
|
559
|
+
elif type(target) is not type(self) or not self._same_filesystem(target):
|
|
560
|
+
raise NotImplementedError("rename() onto another bucket or backend")
|
|
561
|
+
dest_key = target.key
|
|
562
|
+
if dest_key == self.key:
|
|
563
|
+
# Copying onto itself and then deleting the source loses the
|
|
564
|
+
# object; a name that is not there is not renamed. pathlib
|
|
565
|
+
# returns the new path.
|
|
566
|
+
if not self._has_object(self.key):
|
|
567
|
+
self._stat_prefix()
|
|
568
|
+
return target
|
|
569
|
+
source = self._rename_source(self.key)
|
|
570
|
+
if source is None:
|
|
571
|
+
# No object at the key: a prefix directory (FileNotFoundError
|
|
572
|
+
# when there is nothing at all). Its keys are not renamed one by
|
|
573
|
+
# one here; move() falls back to copy + rm.
|
|
574
|
+
self._stat_prefix()
|
|
575
|
+
raise NotImplementedError(f"rename() of the prefix directory {self}")
|
|
576
|
+
if self._holds_keys(dest_key):
|
|
577
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(target))
|
|
578
|
+
self._rename_copy(source, target, dest_key)
|
|
579
|
+
return target
|