pathapi 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.
- pathapi/AGENTS.md +2355 -0
- pathapi/README.md +205 -0
- pathapi/__init__.py +62 -0
- pathapi/_commit.py +116 -0
- pathapi/_discard.py +92 -0
- pathapi/_errorpolicy.py +106 -0
- pathapi/_filestat.py +194 -0
- pathapi/_lru.py +138 -0
- pathapi/_names.py +70 -0
- pathapi/_permissions.py +102 -0
- pathapi/_stubs.py +76 -0
- pathapi/_tree.py +136 -0
- pathapi/archive/__init__.py +44 -0
- pathapi/archive/_backend.py +393 -0
- pathapi/archive/_names.py +63 -0
- pathapi/archive/_pack.py +394 -0
- pathapi/archive/_path.py +352 -0
- pathapi/archive/_snapshot.py +269 -0
- pathapi/archive/_tar.py +126 -0
- pathapi/archive/_write.py +359 -0
- pathapi/archive/_zip.py +563 -0
- pathapi/archive/_zipentries.py +272 -0
- pathapi/checksum.py +61 -0
- pathapi/exceptions.py +22 -0
- pathapi/fspath.py +513 -0
- pathapi/ftp/__init__.py +27 -0
- pathapi/ftp/_backend.py +418 -0
- pathapi/ftp/_facts.py +117 -0
- pathapi/ftp/_path.py +582 -0
- pathapi/glob/__init__.py +27 -0
- pathapi/glob/_entries.py +103 -0
- pathapi/glob/_patterns.py +164 -0
- pathapi/glob/_recursion.py +180 -0
- pathapi/glob/_selection.py +303 -0
- pathapi/http/__init__.py +84 -0
- pathapi/http/_body.py +156 -0
- pathapi/http/_builtin.py +553 -0
- pathapi/http/_client.py +211 -0
- pathapi/http/_errors.py +79 -0
- pathapi/http/_follow.py +230 -0
- pathapi/http/_headers.py +233 -0
- pathapi/http/_pool.py +169 -0
- pathapi/http/_proxies.py +142 -0
- pathapi/http/_response.py +255 -0
- pathapi/http/_settings.py +12 -0
- pathapi/http/_status.py +68 -0
- pathapi/http/_transports.py +173 -0
- pathapi/http/testing/__init__.py +80 -0
- pathapi/http/testing/__main__.py +10 -0
- pathapi/http/testing/suite/conftest.py +98 -0
- pathapi/http/testing/suite/test_client_default.py +285 -0
- pathapi/http/testing/suite/test_client_errors.py +181 -0
- pathapi/http/testing/suite/test_client_peer_text.py +86 -0
- pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
- pathapi/http/testing/suite/test_client_redirects.py +231 -0
- pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
- pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
- pathapi/http/testing/suite/test_transport_body.py +302 -0
- pathapi/http/testing/suite/test_transport_send.py +662 -0
- pathapi/http/testing/suite/transport_fakes.py +133 -0
- pathapi/http/testing/suite/transport_guard.py +174 -0
- pathapi/http/testing/suite/transport_wire.py +511 -0
- pathapi/mempath.py +438 -0
- pathapi/path/__init__.py +19 -0
- pathapi/path/_info.py +10 -0
- pathapi/path/_path.py +198 -0
- pathapi/path/_pure.py +522 -0
- pathapi/path/_relation.py +10 -0
- pathapi/path/_transfer.py +10 -0
- pathapi/protocols/__init__.py +35 -0
- pathapi/protocols/_bundles.py +22 -0
- pathapi/protocols/_errors.py +12 -0
- pathapi/protocols/_info.py +65 -0
- pathapi/protocols/_relation.py +191 -0
- pathapi/protocols/_removal.py +69 -0
- pathapi/protocols/_support.py +122 -0
- pathapi/protocols/_transfer.py +100 -0
- pathapi/protocols/checksum.py +80 -0
- pathapi/protocols/fs.py +429 -0
- pathapi/protocols/io.py +224 -0
- pathapi/protocols/links.py +135 -0
- pathapi/protocols/pure.py +31 -0
- pathapi/protocols/resolve.py +97 -0
- pathapi/protocols/transfer.py +656 -0
- pathapi/protocols/tree.py +492 -0
- pathapi/providers.py +48 -0
- pathapi/py.typed +1 -0
- pathapi/sync/__init__.py +8 -0
- pathapi/sync/_compare.py +157 -0
- pathapi/sync/_events.py +97 -0
- pathapi/sync/_links.py +93 -0
- pathapi/sync/_offer.py +33 -0
- pathapi/sync/_paths.py +50 -0
- pathapi/sync/_replace.py +98 -0
- pathapi/sync/_syncer.py +989 -0
- pathapi/testing/__init__.py +96 -0
- pathapi/testing/_contract.py +522 -0
- pathapi/testing/_fixtures.py +83 -0
- pathapi/testing/_read.py +279 -0
- pathapi-0.1.0.dist-info/METADATA +242 -0
- pathapi-0.1.0.dist-info/RECORD +104 -0
- pathapi-0.1.0.dist-info/WHEEL +4 -0
- pathapi-0.1.0.dist-info/entry_points.txt +2 -0
- pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""How a target stands to a source: the same entry, another name, outside or inside."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno as _errno
|
|
6
|
+
import os as _os
|
|
7
|
+
import pathlib as _pathlib
|
|
8
|
+
import typing as _ty
|
|
9
|
+
|
|
10
|
+
if _ty.TYPE_CHECKING:
|
|
11
|
+
from ..path import Path
|
|
12
|
+
from .transfer import _Held
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# How `move()` and `copy()` find a target standing to the source (`_relation`).
|
|
16
|
+
_SAME = "same" # one directory entry, spelled differently
|
|
17
|
+
_OTHER = "other" # another name of the same file (a hard link, a link to it)
|
|
18
|
+
_OUTER = "outer" # the target is a directory that holds the source
|
|
19
|
+
_INNER = "inner" # the target lies inside the source
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _local_file(path) -> "_pathlib.Path | None":
|
|
23
|
+
"""The `pathlib.Path` through which `path` is a file of this machine, or
|
|
24
|
+
None: a `LocalPath`, a stdlib path, or a path holding one as `filepath`
|
|
25
|
+
(a `file:` URI class)."""
|
|
26
|
+
if isinstance(path, _pathlib.Path):
|
|
27
|
+
return path
|
|
28
|
+
try:
|
|
29
|
+
local = getattr(path, "filepath", None)
|
|
30
|
+
except Exception:
|
|
31
|
+
return None
|
|
32
|
+
return local if isinstance(local, _pathlib.Path) else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _location(path: "_pathlib.Path", follow: bool) -> str:
|
|
36
|
+
"""Where `path` points: absolute, with every symlink of its directory part
|
|
37
|
+
resolved and, when `follow`, the final component's too."""
|
|
38
|
+
text = _os.path.join(_os.getcwd(), _os.fspath(path))
|
|
39
|
+
head, tail = _os.path.split(text)
|
|
40
|
+
if follow or tail in ("", ".", ".."):
|
|
41
|
+
return _os.path.realpath(text)
|
|
42
|
+
return _os.path.join(_os.path.realpath(head), tail)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _inside(outer: str, inner: str) -> bool:
|
|
46
|
+
"""Whether `inner` lies strictly below `outer` (both `normcase`d)."""
|
|
47
|
+
if outer == inner:
|
|
48
|
+
return False
|
|
49
|
+
try:
|
|
50
|
+
return _os.path.commonpath([outer, inner]) == outer
|
|
51
|
+
except ValueError:
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_one_entry(mine: str, theirs: str) -> bool:
|
|
56
|
+
"""Whether two locations of one file are a single directory entry under
|
|
57
|
+
two spellings (a case or short-name alias) rather than two names of it
|
|
58
|
+
(hard links, which a directory lists both). Unsure counts as one entry:
|
|
59
|
+
that removes nothing."""
|
|
60
|
+
(head, name), (other_head, other_name) = (
|
|
61
|
+
_os.path.split(mine),
|
|
62
|
+
_os.path.split(theirs),
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
if not _os.path.samefile(head, other_head):
|
|
66
|
+
return False
|
|
67
|
+
listed = set(_os.listdir(head))
|
|
68
|
+
except OSError:
|
|
69
|
+
return True
|
|
70
|
+
return not (name != other_name and name in listed and other_name in listed)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _local_relation(mine, theirs, follow: bool) -> "str | None":
|
|
74
|
+
"""`_relation()` for two files of this machine, decided by where each
|
|
75
|
+
really is and, failing that, by their identity (`os.path.samefile`)."""
|
|
76
|
+
try:
|
|
77
|
+
at, there = _location(mine, follow), _location(theirs, follow)
|
|
78
|
+
normal, other = _os.path.normcase(at), _os.path.normcase(there)
|
|
79
|
+
if normal == other:
|
|
80
|
+
return _SAME
|
|
81
|
+
if _inside(other, normal):
|
|
82
|
+
return _OUTER
|
|
83
|
+
if _inside(normal, other):
|
|
84
|
+
return _INNER
|
|
85
|
+
if not _os.path.samefile(mine, theirs):
|
|
86
|
+
return None
|
|
87
|
+
except (OSError, ValueError):
|
|
88
|
+
return None
|
|
89
|
+
return _SAME if _is_one_entry(at, there) else _OTHER
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _relation(
|
|
93
|
+
src: Path,
|
|
94
|
+
target: Path,
|
|
95
|
+
*,
|
|
96
|
+
follow: bool,
|
|
97
|
+
identity: bool = True,
|
|
98
|
+
held: "_Held | None" = None,
|
|
99
|
+
) -> "str | None":
|
|
100
|
+
"""How `target` stands to `src`: `_SAME`, `_OTHER`, `_OUTER`, `_INNER`,
|
|
101
|
+
or None when they are unrelated or this cannot tell.
|
|
102
|
+
|
|
103
|
+
Two files of this machine are compared by where they really are, whatever
|
|
104
|
+
their classes; a type that answers `_node_key()` is compared by that;
|
|
105
|
+
anything else only between two paths of one type that
|
|
106
|
+
`_same_filesystem()` places together, by `samefile()` where `stat()`
|
|
107
|
+
carries `st_dev`/`st_ino` (when `identity`; it costs a stat of each) and
|
|
108
|
+
by `==` otherwise. `follow` says whether a final symlink is looked
|
|
109
|
+
through (a copy reads it) or is the entry itself (a move renames it).
|
|
110
|
+
`held` is the stats of `src` and `target` the caller already took, so
|
|
111
|
+
`samefile()` does not take them again."""
|
|
112
|
+
mine, theirs = _local_file(src), _local_file(target)
|
|
113
|
+
if mine is not None and theirs is not None:
|
|
114
|
+
return _local_relation(mine, theirs, follow)
|
|
115
|
+
try:
|
|
116
|
+
keys = (src._node_key(), target._node_key())
|
|
117
|
+
except Exception:
|
|
118
|
+
keys = (None, None)
|
|
119
|
+
if None not in keys:
|
|
120
|
+
(space, names), (other_space, other_names) = keys
|
|
121
|
+
if space is not other_space:
|
|
122
|
+
return None
|
|
123
|
+
if names == other_names:
|
|
124
|
+
return _SAME
|
|
125
|
+
if names[: len(other_names)] == other_names:
|
|
126
|
+
return _OUTER
|
|
127
|
+
if other_names[: len(names)] == names:
|
|
128
|
+
return _INNER
|
|
129
|
+
return None
|
|
130
|
+
if type(src) is not type(target):
|
|
131
|
+
return None
|
|
132
|
+
if identity:
|
|
133
|
+
try:
|
|
134
|
+
if src.samefile(target) if held is None else held.same_file(src, target):
|
|
135
|
+
return _SAME
|
|
136
|
+
except (NotImplementedError, OSError, TypeError, ValueError):
|
|
137
|
+
pass
|
|
138
|
+
if not src._same_filesystem(target):
|
|
139
|
+
return None
|
|
140
|
+
try:
|
|
141
|
+
if src == target:
|
|
142
|
+
return _SAME
|
|
143
|
+
if target.is_relative_to(src):
|
|
144
|
+
return _INNER
|
|
145
|
+
if src.is_relative_to(target):
|
|
146
|
+
return _OUTER
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def contains(src: Path, target: Path) -> bool:
|
|
153
|
+
"""Whether `target` is `src` or lies inside it (pathlib 3.14's copy()
|
|
154
|
+
refuses both), looking through symlinks. Paths `_same_filesystem()`
|
|
155
|
+
reports in different namespaces are never nested."""
|
|
156
|
+
return _relation(src, target, follow=True, identity=False) in (
|
|
157
|
+
_SAME,
|
|
158
|
+
_OTHER,
|
|
159
|
+
_INNER,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _same_file(src: Path, target: Path, *, identity: bool = True) -> bool:
|
|
164
|
+
"""Whether `src` and `target` name the same file, looking through
|
|
165
|
+
symlinks (see `_relation`, and its `identity`)."""
|
|
166
|
+
mine, theirs = _local_file(src), _local_file(target)
|
|
167
|
+
if mine is not None and theirs is not None:
|
|
168
|
+
# The cheap form of `_local_relation()`: copy() asks this once per
|
|
169
|
+
# file, and only identity matters to it.
|
|
170
|
+
try:
|
|
171
|
+
return _os.path.samefile(mine, theirs)
|
|
172
|
+
except (OSError, ValueError):
|
|
173
|
+
pass
|
|
174
|
+
try:
|
|
175
|
+
return _os.path.normcase(_os.path.abspath(mine)) == _os.path.normcase(
|
|
176
|
+
_os.path.abspath(theirs)
|
|
177
|
+
)
|
|
178
|
+
except (OSError, ValueError):
|
|
179
|
+
return False
|
|
180
|
+
return _relation(src, target, follow=True, identity=identity) in (_SAME, _OTHER)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _drop_second_name(src: Path, src_stat, target: Path, target_stat) -> Path:
|
|
184
|
+
"""`move()` between two names of one file: the target name already holds
|
|
185
|
+
the content, so only the source name goes. A link to the file, or a
|
|
186
|
+
directory reachable twice, is refused: removing either name would leave
|
|
187
|
+
the other pointing at nothing."""
|
|
188
|
+
if src_stat.is_symlink() or target_stat.is_symlink() or src_stat.is_dir():
|
|
189
|
+
raise OSError(_errno.EINVAL, "Source and target are the same file", str(target))
|
|
190
|
+
src.unlink()
|
|
191
|
+
return target
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Helpers of listing and removal: the child listing, junctions and follow policies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib as _contextlib
|
|
6
|
+
import typing as _ty
|
|
7
|
+
|
|
8
|
+
from .._filestat import FileStat
|
|
9
|
+
|
|
10
|
+
if _ty.TYPE_CHECKING:
|
|
11
|
+
from ..path import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _write_batch_of(path) -> "_ty.ContextManager[object]":
|
|
15
|
+
"""`path._write_batch()`, where a path without the hook (a stdlib path)
|
|
16
|
+
gets a block that does nothing."""
|
|
17
|
+
hook = getattr(path, "_write_batch", None)
|
|
18
|
+
return hook() if callable(hook) else _contextlib.nullcontext()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_junction(path: "Path") -> bool:
|
|
22
|
+
"""`path.is_junction()`, where a class that cannot answer says no."""
|
|
23
|
+
try:
|
|
24
|
+
return bool(path.is_junction())
|
|
25
|
+
except (NotImplementedError, OSError, ValueError):
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _follow_policy(policy, path: "Path") -> str:
|
|
30
|
+
"""Resolve a `follow_symlinks=`/`follow_binds=` value for `path`.
|
|
31
|
+
|
|
32
|
+
`False` (the default) means "remove the entry itself, never what is
|
|
33
|
+
behind it" -- `rm -r` semantics; `True` means "remove what is behind
|
|
34
|
+
it"; `None` means "leave it alone entirely". A callable is asked per
|
|
35
|
+
entry (`policy(path)`) and returns one of those three, so one tree can
|
|
36
|
+
keep one mount and follow another.
|
|
37
|
+
|
|
38
|
+
Returns the internal name: "rm", "follow" or "ignore". An answer that is
|
|
39
|
+
none of the three raises `_InvalidPolicy`, which `rm()` never hands to
|
|
40
|
+
its `ignore_error`.
|
|
41
|
+
"""
|
|
42
|
+
if callable(policy):
|
|
43
|
+
policy = policy(path)
|
|
44
|
+
if policy is None:
|
|
45
|
+
return "ignore"
|
|
46
|
+
if policy is True:
|
|
47
|
+
return "follow"
|
|
48
|
+
if policy is False:
|
|
49
|
+
return "rm"
|
|
50
|
+
raise _InvalidPolicy(f"expected True, False, None or a callable, got {policy!r}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _InvalidPolicy(ValueError):
|
|
54
|
+
"""A follow policy that is not `True`, `False` or `None`."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _listing(path: Path) -> "_ty.Iterator[_ty.Tuple[str, _ty.Optional[FileStat]]]":
|
|
58
|
+
"""`path._scandir()` as `(name, non-following stat or None)` pairs. An
|
|
59
|
+
entry that has `name` and `stat()`, as `os.scandir()` yields, is adapted;
|
|
60
|
+
`_scandir()` is documented to yield pairs."""
|
|
61
|
+
for entry in path._scandir():
|
|
62
|
+
if isinstance(entry, tuple) and len(entry) == 2:
|
|
63
|
+
yield entry
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
stat = FileStat.from_stat(entry.stat(follow_symlinks=False))
|
|
67
|
+
except OSError:
|
|
68
|
+
stat = None
|
|
69
|
+
yield entry.name, stat
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""`supports()` and `capabilities()`: which protocols a class implements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib as _importlib
|
|
6
|
+
import typing as _ty
|
|
7
|
+
|
|
8
|
+
from .. import _stubs
|
|
9
|
+
|
|
10
|
+
_PACKAGE = __name__.partition(".")[0]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _is_protocol(candidate: object) -> bool:
|
|
14
|
+
"""Whether `candidate` is a protocol of this package: a class that lists
|
|
15
|
+
its primitives."""
|
|
16
|
+
return (
|
|
17
|
+
isinstance(candidate, type)
|
|
18
|
+
and "__primitives__" in vars(candidate)
|
|
19
|
+
and (candidate.__module__ or "").startswith(_PACKAGE + ".protocols")
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve(cls: type, name: str) -> object:
|
|
24
|
+
"""What `cls.name` resolves to along the MRO, unwrapped to the function
|
|
25
|
+
for a static or class method; None when no class defines it."""
|
|
26
|
+
for base in cls.__mro__:
|
|
27
|
+
if name in vars(base):
|
|
28
|
+
value = vars(base)[name]
|
|
29
|
+
return getattr(value, "__func__", value)
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _defined_outside(value: object) -> bool:
|
|
34
|
+
"""Whether `value` comes from neither this library's protocols nor its
|
|
35
|
+
`Path`: a class's own method, a mixin's or the standard library's."""
|
|
36
|
+
module = getattr(value, "__module__", "") or ""
|
|
37
|
+
return not (
|
|
38
|
+
module.startswith(_PACKAGE + ".protocols")
|
|
39
|
+
or module == _PACKAGE + ".path"
|
|
40
|
+
or module.startswith(_PACKAGE + ".path.")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _implemented(cls: type, name: str) -> bool:
|
|
45
|
+
"""Whether `name` resolves on `cls` to more than a stub, and, for a method
|
|
46
|
+
that is only built on other primitives, whether one of them is
|
|
47
|
+
implemented."""
|
|
48
|
+
value = _resolve(cls, name)
|
|
49
|
+
if value is None or _stubs.is_stub(value):
|
|
50
|
+
return False
|
|
51
|
+
built_on = _stubs.derivation(value)
|
|
52
|
+
return not built_on or any(_implemented(cls, other) for other in built_on)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _has(cls: type, entry: "str | tuple[str, ...]") -> bool:
|
|
56
|
+
"""Whether `cls` implements one entry of `__primitives__`. An entry is a
|
|
57
|
+
primitive name, or a tuple whose first name is the primitive and whose
|
|
58
|
+
others are operations that replace it when something other than the
|
|
59
|
+
protocol's own default defines them (`mkdir` for `_mkdir`, `open` for
|
|
60
|
+
`_open`: `pathlib.Path` has the operation and not the primitive)."""
|
|
61
|
+
names = (entry,) if isinstance(entry, str) else entry
|
|
62
|
+
if _implemented(cls, names[0]):
|
|
63
|
+
return True
|
|
64
|
+
for name in names[1:]:
|
|
65
|
+
replacement = _resolve(cls, name)
|
|
66
|
+
if replacement is not None and _defined_outside(replacement):
|
|
67
|
+
return True
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _static(cls: type, protocol: type) -> bool:
|
|
72
|
+
if not protocol.__primitives__ and protocol not in cls.__mro__:
|
|
73
|
+
# Nothing to implement: the operations come with the protocol.
|
|
74
|
+
return False
|
|
75
|
+
return all(_has(cls, entry) for entry in protocol.__primitives__)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def supports(obj: object, *protocols: type) -> bool:
|
|
79
|
+
"""Whether `obj` (an instance or a class) implements every protocol
|
|
80
|
+
named: each primitive of each resolves to something other than the
|
|
81
|
+
protocol's default stub. A class whose answer depends on its connection
|
|
82
|
+
defines `_supports(protocol) -> bool | None`, asked first for an instance;
|
|
83
|
+
`None` falls through to the answer from the class. That hook makes at most
|
|
84
|
+
one request per backend, cached for the backend's lifetime, and never
|
|
85
|
+
raises: a failed request answers `None`.
|
|
86
|
+
|
|
87
|
+
The answer is advisory, like `supported_checksums()`: a primitive may
|
|
88
|
+
still raise `NotImplementedError` for an argument it cannot handle.
|
|
89
|
+
`TypeError` for no protocol, or for something that is not a protocol of
|
|
90
|
+
this package.
|
|
91
|
+
"""
|
|
92
|
+
if not protocols:
|
|
93
|
+
raise TypeError("supports() needs at least one protocol")
|
|
94
|
+
for protocol in protocols:
|
|
95
|
+
if not _is_protocol(protocol):
|
|
96
|
+
raise TypeError(f"{protocol!r} is not a protocol of {_PACKAGE}")
|
|
97
|
+
instance = None if isinstance(obj, type) else obj
|
|
98
|
+
cls = obj if isinstance(obj, type) else type(obj)
|
|
99
|
+
for protocol in protocols:
|
|
100
|
+
if instance is not None:
|
|
101
|
+
hook = getattr(cls, "_supports", None)
|
|
102
|
+
if callable(hook):
|
|
103
|
+
answer = hook(instance, protocol)
|
|
104
|
+
if answer is not None:
|
|
105
|
+
if not answer:
|
|
106
|
+
return False
|
|
107
|
+
continue
|
|
108
|
+
if not _static(cls, protocol):
|
|
109
|
+
return False
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def capabilities(obj: object) -> "_ty.FrozenSet[type]":
|
|
114
|
+
"""The protocols of this package that `obj` (an instance or a class)
|
|
115
|
+
supports, in the sense of `supports()`."""
|
|
116
|
+
module = _importlib.import_module(_PACKAGE + ".protocols")
|
|
117
|
+
known = [
|
|
118
|
+
value
|
|
119
|
+
for value in (getattr(module, name) for name in module.__all__)
|
|
120
|
+
if _is_protocol(value)
|
|
121
|
+
]
|
|
122
|
+
return frozenset(protocol for protocol in known if supports(obj, protocol))
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""What `copy()` and `move()` hand to one another: stats, identity and modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib as _contextlib
|
|
6
|
+
import contextvars as _contextvars
|
|
7
|
+
import stat as _stat
|
|
8
|
+
import typing as _ty
|
|
9
|
+
|
|
10
|
+
if _ty.TYPE_CHECKING:
|
|
11
|
+
from ..path import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _stat_or_none(path: Path, follow_symlinks: bool):
|
|
15
|
+
"""`path.stat()`, or None where there is none to have: a missing path, a
|
|
16
|
+
backend without `stat()`. The callers then do what they did without it."""
|
|
17
|
+
try:
|
|
18
|
+
return path.stat(follow_symlinks=follow_symlinks)
|
|
19
|
+
except (OSError, ValueError, NotImplementedError):
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Handed:
|
|
24
|
+
"""What `move()` found out about the two roots, for the one `copy()` it
|
|
25
|
+
calls on exactly this source and target. `source_stat` is the source's
|
|
26
|
+
own stat (None: not known to be a plain entry), `target_absent` that
|
|
27
|
+
nothing was at the target, with nothing written or removed there since."""
|
|
28
|
+
|
|
29
|
+
__slots__ = ("source", "target", "source_stat", "target_absent", "taken")
|
|
30
|
+
|
|
31
|
+
def __init__(self, source, target, source_stat, target_absent):
|
|
32
|
+
self.source, self.target = source, target
|
|
33
|
+
self.source_stat, self.target_absent = source_stat, target_absent
|
|
34
|
+
self.taken = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_HANDED: "_contextvars.ContextVar[_Handed | None]" = _contextvars.ContextVar(
|
|
38
|
+
"pathapi_handed_stats", default=None
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@_contextlib.contextmanager
|
|
43
|
+
def _handing(source, target, source_stat, target_absent):
|
|
44
|
+
"""Make `source_stat` and `target_absent` available to the `copy()` of
|
|
45
|
+
`source` onto `target` made inside the block, once, and to nothing else
|
|
46
|
+
(`ObjectStorePath.copy()` and the like pass the very same objects on to
|
|
47
|
+
`Path.copy()`)."""
|
|
48
|
+
token = _HANDED.set(_Handed(source, target, source_stat, target_absent))
|
|
49
|
+
try:
|
|
50
|
+
yield
|
|
51
|
+
finally:
|
|
52
|
+
_HANDED.reset(token)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _take_handed(source, target) -> "_Handed | None":
|
|
56
|
+
"""What `move()` handed to the copy of exactly `source` onto `target`, the
|
|
57
|
+
first time it is asked; None for any other copy."""
|
|
58
|
+
handed = _HANDED.get()
|
|
59
|
+
if (
|
|
60
|
+
handed is None
|
|
61
|
+
or handed.taken
|
|
62
|
+
or handed.source is not source
|
|
63
|
+
or handed.target is not target
|
|
64
|
+
):
|
|
65
|
+
return None
|
|
66
|
+
handed.taken = True
|
|
67
|
+
return handed
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _same_identity(first, second) -> bool:
|
|
71
|
+
"""Whether two stats carry the same `(st_dev, st_ino)`. A stat without
|
|
72
|
+
them (a `FileStat`, an SFTP attribute set) says nothing."""
|
|
73
|
+
one = (getattr(first, "st_dev", None), getattr(first, "st_ino", None))
|
|
74
|
+
other = (getattr(second, "st_dev", None), getattr(second, "st_ino", None))
|
|
75
|
+
return None not in one and None not in other and one == other
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _copy_mode(
|
|
79
|
+
src: Path, target: Path, follow_symlinks: bool, stat: "_ty.Any" = None
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Give `target` the permission bits `src` reports, when it reports any.
|
|
82
|
+
|
|
83
|
+
Only a mode the source backend actually reported is metadata:
|
|
84
|
+
`FileStat`'s placeholder (0o444 for a file) is not, and applying it made
|
|
85
|
+
every copy from MemPath/HTTP/S3/... a read-only file that a re-copy or
|
|
86
|
+
re-sync could not replace. Permission bits only: the file-type bits of
|
|
87
|
+
`st_mode` are not a mode, and a backend such as FTP's SITE CHMOD sends
|
|
88
|
+
them verbatim. Between two classes the setuid, setgid and sticky bits are
|
|
89
|
+
dropped: a mode read from another backend (a tar member, a remote
|
|
90
|
+
server) must not make a privileged file here."""
|
|
91
|
+
try:
|
|
92
|
+
if stat is None:
|
|
93
|
+
stat = src.stat(follow_symlinks=follow_symlinks)
|
|
94
|
+
if getattr(stat, "mode_known", True) and stat.st_mode:
|
|
95
|
+
mode = _stat.S_IMODE(stat.st_mode)
|
|
96
|
+
if type(src) is not type(target):
|
|
97
|
+
mode &= ~(_stat.S_ISUID | _stat.S_ISGID | _stat.S_ISVTX)
|
|
98
|
+
target.chmod(mode)
|
|
99
|
+
except NotImplementedError:
|
|
100
|
+
pass
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing as _ty
|
|
4
|
+
|
|
5
|
+
from .. import _stubs
|
|
6
|
+
|
|
7
|
+
__all__ = ["NativeChecksum"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NativeChecksum(_ty.Protocol):
|
|
11
|
+
"""Composable protocol for backends that can compute a file digest
|
|
12
|
+
server-side, without streaming the file's content through this
|
|
13
|
+
process.
|
|
14
|
+
|
|
15
|
+
Optional per-`Path`-subclass, like every other protocol in this
|
|
16
|
+
package (`io.BinaryOpen`, `fs.Stat`, `fs.Chmod`) -- most backends will
|
|
17
|
+
never implement it, and the base `Path` ABC does not require it.
|
|
18
|
+
`sync.PathSyncer` (and any other caller) is expected to try this
|
|
19
|
+
first and fall back to a streaming checksum (`checksum.md5`/
|
|
20
|
+
`sha256`) on `NotImplementedError`.
|
|
21
|
+
|
|
22
|
+
Two methods, two different jobs: `supported_checksums()` is an
|
|
23
|
+
*advisory* capability query (never raises, so a caller can pick a
|
|
24
|
+
shared algorithm across two paths -- e.g.
|
|
25
|
+
`source.supported_checksums() & target.supported_checksums()` -- before
|
|
26
|
+
calling anything expensive); `checksum()` is the *authoritative*
|
|
27
|
+
per-call contract and still MUST raise `NotImplementedError` on its own
|
|
28
|
+
for any algorithm it can't actually produce, even if a caller never
|
|
29
|
+
consulted `supported_checksums()` first (belt-and-suspenders -- the
|
|
30
|
+
checksum method's own contract, not just the advertisement, is what a
|
|
31
|
+
correctness-sensitive caller can rely on).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__slots__ = ()
|
|
35
|
+
|
|
36
|
+
__primitives__: _ty.ClassVar[tuple[object, ...]] = ("checksum",)
|
|
37
|
+
|
|
38
|
+
def supported_checksums(self) -> "_ty.FrozenSet[str]":
|
|
39
|
+
"""Advisory set of `algorithm` names this path can currently
|
|
40
|
+
produce a native digest for (e.g. `frozenset({"md5"})`), so a
|
|
41
|
+
caller can pick a shared algorithm across two paths -- or fall back
|
|
42
|
+
to streaming -- without probing via trial-and-`NotImplementedError`
|
|
43
|
+
per candidate. Never raises. The default (no override, matching the
|
|
44
|
+
base `Path`/`Pathname` ABC not implementing this protocol at all)
|
|
45
|
+
is an empty `frozenset()` -- "no native support" -- which is also
|
|
46
|
+
the correct answer for a backend whose native-hashing capability
|
|
47
|
+
can vary at runtime (e.g. per-connection extension negotiation) and
|
|
48
|
+
currently has none available.
|
|
49
|
+
|
|
50
|
+
This can be a live/dynamic check (e.g. reflecting whether the
|
|
51
|
+
current server connection actually advertised a given SFTP
|
|
52
|
+
extension), not necessarily a hardcoded class-level constant --
|
|
53
|
+
implementations should recompute it if capability can change
|
|
54
|
+
within the object's lifetime, and are free to cache it if not.
|
|
55
|
+
"""
|
|
56
|
+
return frozenset()
|
|
57
|
+
|
|
58
|
+
@_stubs.primitive
|
|
59
|
+
def checksum(self, algorithm: str = "md5") -> str:
|
|
60
|
+
"""Return a hex-digest checksum of this file's content, computed by
|
|
61
|
+
the backend itself (e.g. an SFTP server implementing the filexfer draft's
|
|
62
|
+
`check-file-handle` extension -- OpenSSH does not --, a WebDAV `getetag`, ...) rather than by streaming the
|
|
63
|
+
content through `open("rb")`.
|
|
64
|
+
|
|
65
|
+
`algorithm` names a `hashlib`-style digest (at least `"md5"` must
|
|
66
|
+
be accepted, matching `PathSyncer`'s current default). A backend
|
|
67
|
+
that cannot produce the requested algorithm -- whether because it
|
|
68
|
+
supports no native hashing at all, or only a different algorithm --
|
|
69
|
+
MUST raise `NotImplementedError` rather than silently returning a
|
|
70
|
+
digest under a different algorithm or a value that isn't a true
|
|
71
|
+
content hash. This is a hard requirement, not a style preference:
|
|
72
|
+
comparing two checksums computed under different algorithms (or
|
|
73
|
+
comparing a real hash to something hash-shaped but not actually a
|
|
74
|
+
content digest, e.g. S3's ETag for a multipart upload) can silently
|
|
75
|
+
produce a false "in sync" verdict. Callers must never catch
|
|
76
|
+
anything broader than `NotImplementedError` here.
|
|
77
|
+
`supported_checksums()` not listing `algorithm` is advisory, not a
|
|
78
|
+
substitute for this method enforcing its own contract.
|
|
79
|
+
"""
|
|
80
|
+
return _stubs._unimplemented("checksum")
|