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/_keys.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Which keys of a prefix a directory walk can reach.
|
|
2
|
+
|
|
3
|
+
A walk lists one level at a time with a delimiter, so it never visits a key
|
|
4
|
+
whose name has an empty, `.` or `..` segment, the subtree below a key that is
|
|
5
|
+
also an object, or the content of a key that ends in the delimiter. A flat
|
|
6
|
+
listing of the prefix holds all of them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib as _contextlib
|
|
12
|
+
import errno as _errno
|
|
13
|
+
import threading as _threading
|
|
14
|
+
import typing as _ty
|
|
15
|
+
|
|
16
|
+
from pathapi import providers as _providers
|
|
17
|
+
|
|
18
|
+
from .exceptions import UnreachableKeysError
|
|
19
|
+
|
|
20
|
+
_state = _threading.local()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def unreachable_keys(
|
|
24
|
+
prefix: str, entries: "_ty.Iterable[tuple[str, int]]"
|
|
25
|
+
) -> "list[str]":
|
|
26
|
+
"""The keys among `entries` (`(key, size)` pairs, a flat listing of
|
|
27
|
+
`prefix`) that a walk of the directory `prefix` never visits.
|
|
28
|
+
|
|
29
|
+
`prefix` is `"dir/"`, or `""` for a bucket or container root. A key is
|
|
30
|
+
reached when every name below the prefix is one path component, no
|
|
31
|
+
directory on the way is also an object (the object wins and its subtree is
|
|
32
|
+
not listed), and a key ending in `/` is an empty marker rather than data.
|
|
33
|
+
The prefix's own marker (`prefix` itself) is reached.
|
|
34
|
+
"""
|
|
35
|
+
entries = list(entries)
|
|
36
|
+
keys = {key for key, _size in entries}
|
|
37
|
+
unreachable = []
|
|
38
|
+
for key, size in entries:
|
|
39
|
+
rest = key[len(prefix) :]
|
|
40
|
+
if not rest:
|
|
41
|
+
continue
|
|
42
|
+
*directories, leaf = rest.split("/")
|
|
43
|
+
marker = leaf == ""
|
|
44
|
+
if not all(_providers.is_safe_child_name(name) for name in directories) or (
|
|
45
|
+
not marker and not _providers.is_safe_child_name(leaf)
|
|
46
|
+
):
|
|
47
|
+
unreachable.append(key)
|
|
48
|
+
elif marker and size:
|
|
49
|
+
unreachable.append(key)
|
|
50
|
+
elif any(
|
|
51
|
+
prefix + "/".join(directories[:depth]) in keys
|
|
52
|
+
for depth in range(1, len(directories) + 1)
|
|
53
|
+
):
|
|
54
|
+
unreachable.append(key)
|
|
55
|
+
return unreachable
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def refusal(path, action: str, keys: "list[str]") -> UnreachableKeysError:
|
|
59
|
+
"""The error for `action` ("copy" or "remove") on a prefix that holds
|
|
60
|
+
`keys` a directory walk cannot reach; nothing has been changed."""
|
|
61
|
+
shown = ", ".join(repr(key) for key in keys[:5])
|
|
62
|
+
more = f" and {len(keys) - 5} more" if len(keys) > 5 else ""
|
|
63
|
+
return UnreachableKeysError(
|
|
64
|
+
_errno.EINVAL,
|
|
65
|
+
f"cannot {action} a prefix holding keys a listing does not show "
|
|
66
|
+
f"({shown}{more}); nothing was changed",
|
|
67
|
+
str(path),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def object_key(path: str) -> str:
|
|
72
|
+
"""The object key a path text names: every leading `/` dropped and exactly
|
|
73
|
+
one trailing `/`. `dir/` is the key `dir`, the way pathlib drops a trailing
|
|
74
|
+
slash, so `dir/` the marker object and `dir` the prefix are not two names;
|
|
75
|
+
`dir//` is the key `dir/`. Interior empty segments (`a//b`) are literal key
|
|
76
|
+
bytes and stay."""
|
|
77
|
+
key = path.lstrip("/")
|
|
78
|
+
return key[:-1] if key.endswith("/") else key
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_names(path: str) -> "tuple[str, ...]":
|
|
82
|
+
"""The segments of a path text: `()` for the empty path, `("", "")` for
|
|
83
|
+
the root, `("", *names)` for a rooted path and `names` for a relative one.
|
|
84
|
+
The names are those of `object_key(path)`, an interior empty name among
|
|
85
|
+
them; a `.` name is dropped, as pathlib drops it."""
|
|
86
|
+
names = [name for name in object_key(path).split("/") if name != "."]
|
|
87
|
+
if names == [""]:
|
|
88
|
+
names = []
|
|
89
|
+
if path.startswith("/"):
|
|
90
|
+
return ("", *names) if names else ("", "")
|
|
91
|
+
return tuple(names)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def split_names(text: str) -> "tuple[str, ...]":
|
|
95
|
+
"""The segments a joined text spells, with no name dropped: the inverse of
|
|
96
|
+
`"/".join(segments)`, which a trailing empty name and an interior one
|
|
97
|
+
survive."""
|
|
98
|
+
if text in ("", "."):
|
|
99
|
+
return ()
|
|
100
|
+
return tuple(text.split("/"))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@_contextlib.contextmanager
|
|
104
|
+
def checked_copy(path, recursive: bool):
|
|
105
|
+
"""Around `Path.copy()` of an object-store `path`: a recursive copy of a
|
|
106
|
+
prefix is refused, before anything is created, when it holds keys the
|
|
107
|
+
walk would leave behind. Children are copied through the same method;
|
|
108
|
+
only the outermost call checks."""
|
|
109
|
+
if not recursive or getattr(_state, "copying", False):
|
|
110
|
+
yield
|
|
111
|
+
return
|
|
112
|
+
unreachable = path._unreachable_keys()
|
|
113
|
+
if unreachable:
|
|
114
|
+
raise refusal(path, "copy", unreachable)
|
|
115
|
+
_state.copying = True
|
|
116
|
+
try:
|
|
117
|
+
yield
|
|
118
|
+
finally:
|
|
119
|
+
_state.copying = False
|
objpath/_optional.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Client libraries a store needs and the extra that installs them.
|
|
2
|
+
|
|
3
|
+
A path always constructs; it does pure-path work (joins, parents, comparison)
|
|
4
|
+
without its client library, and the first operation that needs the library
|
|
5
|
+
raises the `ImportError` built here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib as _importlib
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def missing_extra(package: str, extra: str, cause: "BaseException | None" = None):
|
|
14
|
+
"""The `ImportError` for a client library that cannot be imported, naming
|
|
15
|
+
the extra to install."""
|
|
16
|
+
reason = f" ({cause})" if cause is not None else ""
|
|
17
|
+
return ImportError(
|
|
18
|
+
f"{package} could not be imported{reason}; the '{extra}' extra provides "
|
|
19
|
+
f'it: pip install "objpath[{extra}]"',
|
|
20
|
+
name=package,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Unavailable:
|
|
25
|
+
"""Stands in for a client module that cannot be imported: reading any
|
|
26
|
+
attribute of it raises the `ImportError` of `missing_extra()`."""
|
|
27
|
+
|
|
28
|
+
__slots__ = ("_package", "_extra", "_cause")
|
|
29
|
+
|
|
30
|
+
def __init__(self, package: str, extra: str, cause: BaseException):
|
|
31
|
+
self._package = package
|
|
32
|
+
self._extra = extra
|
|
33
|
+
self._cause = cause
|
|
34
|
+
|
|
35
|
+
def __getattr__(self, name: str):
|
|
36
|
+
if name.startswith("__"):
|
|
37
|
+
# Introspection (`inspect`, `copy`, doctest) probes dunders.
|
|
38
|
+
raise AttributeError(name)
|
|
39
|
+
raise missing_extra(self._package, self._extra, self._cause) from self._cause
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
return f"<{self._package}: not installed, needs the {self._extra!r} extra>"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def import_or_stub(package: str, extra: str):
|
|
46
|
+
"""The module `package`, or a stand-in that raises `missing_extra()` on
|
|
47
|
+
first use when it cannot be imported."""
|
|
48
|
+
try:
|
|
49
|
+
return _importlib.import_module(package)
|
|
50
|
+
except ImportError as error:
|
|
51
|
+
return _Unavailable(package, extra, error)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def import_client(package: str, extra: str):
|
|
55
|
+
"""The module `package`, imported at the point of use; the `ImportError`
|
|
56
|
+
of `missing_extra()` when it cannot be."""
|
|
57
|
+
try:
|
|
58
|
+
return _importlib.import_module(package)
|
|
59
|
+
except ImportError as error:
|
|
60
|
+
raise missing_extra(package, extra, error) from error
|
objpath/_places.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The bucket or container a path is in, and how a pickled path gets it back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def check_place(kind: str, value, donor, owner: str) -> str:
|
|
7
|
+
"""The name of the bucket or container (`kind`) that the constructor of
|
|
8
|
+
`owner` was given as `value`, else the one of the path `donor` it is
|
|
9
|
+
joined from. `TypeError` when there is none or it is not a string,
|
|
10
|
+
`ValueError` when it is empty or holds a `/`."""
|
|
11
|
+
if value is None:
|
|
12
|
+
if donor is None:
|
|
13
|
+
raise TypeError(f"{owner} needs {kind}= (or a segment that is an {owner})")
|
|
14
|
+
return donor._place
|
|
15
|
+
if not isinstance(value, str):
|
|
16
|
+
raise TypeError(f"{kind}= must be a str, not {type(value).__name__!r}")
|
|
17
|
+
if not value or "/" in value:
|
|
18
|
+
raise ValueError(f"{kind}= must be a name with no '/' in it, not {value!r}")
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def rebuild_path(cls, segments, keywords):
|
|
23
|
+
"""Unpickles a path: `cls(**keywords)` with the names it had."""
|
|
24
|
+
path = cls(**keywords)
|
|
25
|
+
path._segments = segments
|
|
26
|
+
return path
|
objpath/_reader.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The ranged reader of an object stream."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io as _io
|
|
6
|
+
|
|
7
|
+
READ_CHUNK = 4 * 1024**2
|
|
8
|
+
"""Bytes of an object that one ranged request of a streamed read asks for."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RangeReader(_io.RawIOBase):
|
|
12
|
+
"""Reads an object as it is asked for, in ranged requests of `chunk` bytes,
|
|
13
|
+
so a read holds one chunk and not the object, and the stream can seek.
|
|
14
|
+
|
|
15
|
+
`fetch(start, length)` returns the bytes of the object from `start`, at
|
|
16
|
+
most `length`, and nothing at or past its end; it raises the `OSError` a
|
|
17
|
+
failed request means. `size()` is the object's size, asked only by
|
|
18
|
+
`seek(..., SEEK_END)`. `first` is the first chunk, already fetched."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, fetch, size, first=b"", chunk=None):
|
|
21
|
+
super().__init__()
|
|
22
|
+
chunk = chunk or READ_CHUNK
|
|
23
|
+
self._fetch, self._size_of, self._chunk_size = fetch, size, chunk
|
|
24
|
+
self._start, self._pos = 0, 0
|
|
25
|
+
self._view = memoryview(first)
|
|
26
|
+
# A first chunk that falls short is the whole object.
|
|
27
|
+
self._size = len(first) if len(first) < chunk else None
|
|
28
|
+
|
|
29
|
+
def readable(self):
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
def seekable(self):
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
def tell(self):
|
|
36
|
+
self._check_open()
|
|
37
|
+
return self._pos
|
|
38
|
+
|
|
39
|
+
def seek(self, offset, whence=_io.SEEK_SET):
|
|
40
|
+
self._check_open()
|
|
41
|
+
if whence == _io.SEEK_SET:
|
|
42
|
+
pos = offset
|
|
43
|
+
elif whence == _io.SEEK_CUR:
|
|
44
|
+
pos = self._pos + offset
|
|
45
|
+
elif whence == _io.SEEK_END:
|
|
46
|
+
if self._size is None:
|
|
47
|
+
self._size = self._size_of()
|
|
48
|
+
pos = self._size + offset
|
|
49
|
+
else:
|
|
50
|
+
raise ValueError(f"invalid whence ({whence}, should be 0, 1 or 2)")
|
|
51
|
+
if pos < 0:
|
|
52
|
+
raise ValueError(f"negative seek value {pos}")
|
|
53
|
+
self._pos = pos
|
|
54
|
+
return pos
|
|
55
|
+
|
|
56
|
+
def readinto(self, buffer):
|
|
57
|
+
self._check_open()
|
|
58
|
+
if not len(buffer):
|
|
59
|
+
return 0
|
|
60
|
+
if not self._start <= self._pos < self._start + len(self._view):
|
|
61
|
+
if self._size is not None and self._pos >= self._size:
|
|
62
|
+
return 0
|
|
63
|
+
# The chunk that is done with goes before the next one arrives.
|
|
64
|
+
self._view = memoryview(b"")
|
|
65
|
+
data = self._fetch(self._pos, self._chunk_size)
|
|
66
|
+
self._start, self._view = self._pos, memoryview(data)
|
|
67
|
+
if len(data) < self._chunk_size:
|
|
68
|
+
self._size = self._pos + len(data)
|
|
69
|
+
if not data:
|
|
70
|
+
return 0
|
|
71
|
+
at = self._pos - self._start
|
|
72
|
+
count = min(len(buffer), len(self._view) - at)
|
|
73
|
+
buffer[:count] = self._view[at : at + count]
|
|
74
|
+
self._pos += count
|
|
75
|
+
return count
|
|
76
|
+
|
|
77
|
+
def _check_open(self):
|
|
78
|
+
if self.closed:
|
|
79
|
+
raise ValueError("I/O operation on closed file.")
|
|
80
|
+
|
|
81
|
+
def close(self):
|
|
82
|
+
self._view = memoryview(b"")
|
|
83
|
+
super().close()
|