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