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.
Files changed (104) hide show
  1. pathapi/AGENTS.md +2355 -0
  2. pathapi/README.md +205 -0
  3. pathapi/__init__.py +62 -0
  4. pathapi/_commit.py +116 -0
  5. pathapi/_discard.py +92 -0
  6. pathapi/_errorpolicy.py +106 -0
  7. pathapi/_filestat.py +194 -0
  8. pathapi/_lru.py +138 -0
  9. pathapi/_names.py +70 -0
  10. pathapi/_permissions.py +102 -0
  11. pathapi/_stubs.py +76 -0
  12. pathapi/_tree.py +136 -0
  13. pathapi/archive/__init__.py +44 -0
  14. pathapi/archive/_backend.py +393 -0
  15. pathapi/archive/_names.py +63 -0
  16. pathapi/archive/_pack.py +394 -0
  17. pathapi/archive/_path.py +352 -0
  18. pathapi/archive/_snapshot.py +269 -0
  19. pathapi/archive/_tar.py +126 -0
  20. pathapi/archive/_write.py +359 -0
  21. pathapi/archive/_zip.py +563 -0
  22. pathapi/archive/_zipentries.py +272 -0
  23. pathapi/checksum.py +61 -0
  24. pathapi/exceptions.py +22 -0
  25. pathapi/fspath.py +513 -0
  26. pathapi/ftp/__init__.py +27 -0
  27. pathapi/ftp/_backend.py +418 -0
  28. pathapi/ftp/_facts.py +117 -0
  29. pathapi/ftp/_path.py +582 -0
  30. pathapi/glob/__init__.py +27 -0
  31. pathapi/glob/_entries.py +103 -0
  32. pathapi/glob/_patterns.py +164 -0
  33. pathapi/glob/_recursion.py +180 -0
  34. pathapi/glob/_selection.py +303 -0
  35. pathapi/http/__init__.py +84 -0
  36. pathapi/http/_body.py +156 -0
  37. pathapi/http/_builtin.py +553 -0
  38. pathapi/http/_client.py +211 -0
  39. pathapi/http/_errors.py +79 -0
  40. pathapi/http/_follow.py +230 -0
  41. pathapi/http/_headers.py +233 -0
  42. pathapi/http/_pool.py +169 -0
  43. pathapi/http/_proxies.py +142 -0
  44. pathapi/http/_response.py +255 -0
  45. pathapi/http/_settings.py +12 -0
  46. pathapi/http/_status.py +68 -0
  47. pathapi/http/_transports.py +173 -0
  48. pathapi/http/testing/__init__.py +80 -0
  49. pathapi/http/testing/__main__.py +10 -0
  50. pathapi/http/testing/suite/conftest.py +98 -0
  51. pathapi/http/testing/suite/test_client_default.py +285 -0
  52. pathapi/http/testing/suite/test_client_errors.py +181 -0
  53. pathapi/http/testing/suite/test_client_peer_text.py +86 -0
  54. pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
  55. pathapi/http/testing/suite/test_client_redirects.py +231 -0
  56. pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
  57. pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
  58. pathapi/http/testing/suite/test_transport_body.py +302 -0
  59. pathapi/http/testing/suite/test_transport_send.py +662 -0
  60. pathapi/http/testing/suite/transport_fakes.py +133 -0
  61. pathapi/http/testing/suite/transport_guard.py +174 -0
  62. pathapi/http/testing/suite/transport_wire.py +511 -0
  63. pathapi/mempath.py +438 -0
  64. pathapi/path/__init__.py +19 -0
  65. pathapi/path/_info.py +10 -0
  66. pathapi/path/_path.py +198 -0
  67. pathapi/path/_pure.py +522 -0
  68. pathapi/path/_relation.py +10 -0
  69. pathapi/path/_transfer.py +10 -0
  70. pathapi/protocols/__init__.py +35 -0
  71. pathapi/protocols/_bundles.py +22 -0
  72. pathapi/protocols/_errors.py +12 -0
  73. pathapi/protocols/_info.py +65 -0
  74. pathapi/protocols/_relation.py +191 -0
  75. pathapi/protocols/_removal.py +69 -0
  76. pathapi/protocols/_support.py +122 -0
  77. pathapi/protocols/_transfer.py +100 -0
  78. pathapi/protocols/checksum.py +80 -0
  79. pathapi/protocols/fs.py +429 -0
  80. pathapi/protocols/io.py +224 -0
  81. pathapi/protocols/links.py +135 -0
  82. pathapi/protocols/pure.py +31 -0
  83. pathapi/protocols/resolve.py +97 -0
  84. pathapi/protocols/transfer.py +656 -0
  85. pathapi/protocols/tree.py +492 -0
  86. pathapi/providers.py +48 -0
  87. pathapi/py.typed +1 -0
  88. pathapi/sync/__init__.py +8 -0
  89. pathapi/sync/_compare.py +157 -0
  90. pathapi/sync/_events.py +97 -0
  91. pathapi/sync/_links.py +93 -0
  92. pathapi/sync/_offer.py +33 -0
  93. pathapi/sync/_paths.py +50 -0
  94. pathapi/sync/_replace.py +98 -0
  95. pathapi/sync/_syncer.py +989 -0
  96. pathapi/testing/__init__.py +96 -0
  97. pathapi/testing/_contract.py +522 -0
  98. pathapi/testing/_fixtures.py +83 -0
  99. pathapi/testing/_read.py +279 -0
  100. pathapi-0.1.0.dist-info/METADATA +242 -0
  101. pathapi-0.1.0.dist-info/RECORD +104 -0
  102. pathapi-0.1.0.dist-info/WHEEL +4 -0
  103. pathapi-0.1.0.dist-info/entry_points.txt +2 -0
  104. 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")