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