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,656 @@
|
|
|
1
|
+
"""Telling two paths apart, copying and moving."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib as _contextlib
|
|
6
|
+
import errno as _errno
|
|
7
|
+
import shutil as _shutil
|
|
8
|
+
import stat as _stat
|
|
9
|
+
import typing as _ty
|
|
10
|
+
|
|
11
|
+
from .. import _names, _stubs
|
|
12
|
+
from .._errorpolicy import copy_error_predicate
|
|
13
|
+
from .._filestat import FileStat
|
|
14
|
+
from ._relation import (
|
|
15
|
+
_INNER,
|
|
16
|
+
_OTHER,
|
|
17
|
+
_OUTER,
|
|
18
|
+
_SAME,
|
|
19
|
+
_drop_second_name,
|
|
20
|
+
_relation,
|
|
21
|
+
_same_file,
|
|
22
|
+
contains,
|
|
23
|
+
)
|
|
24
|
+
from ._removal import _write_batch_of
|
|
25
|
+
from ._support import supports
|
|
26
|
+
from ._transfer import (
|
|
27
|
+
_copy_mode,
|
|
28
|
+
_handing,
|
|
29
|
+
_same_identity,
|
|
30
|
+
_stat_or_none,
|
|
31
|
+
_take_handed,
|
|
32
|
+
)
|
|
33
|
+
from .fs import Create, Stat
|
|
34
|
+
from .io import _ASK_STAT, BinaryOpen
|
|
35
|
+
from .links import Readlink, Symlink
|
|
36
|
+
from .tree import Remove
|
|
37
|
+
|
|
38
|
+
if _ty.TYPE_CHECKING:
|
|
39
|
+
from ..path import Path
|
|
40
|
+
|
|
41
|
+
__all__ = ["Copy", "Identity", "Move"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Identity(Stat, _ty.Protocol):
|
|
45
|
+
"""Whether two paths name the same node, and in which namespace.
|
|
46
|
+
`_same_filesystem()` and `_node_key()` are overridden by a class whose
|
|
47
|
+
instances can front different stores; `samefile()` compares what
|
|
48
|
+
`stat()` reports."""
|
|
49
|
+
|
|
50
|
+
__slots__ = ()
|
|
51
|
+
|
|
52
|
+
__primitives__: _ty.ClassVar[tuple[object, ...]] = ()
|
|
53
|
+
|
|
54
|
+
def _same_filesystem(self, other: "Path") -> bool:
|
|
55
|
+
"""Whether `other`'s segments are resolved in the same namespace as
|
|
56
|
+
this path's: the same host, store or tree. Only when they are do
|
|
57
|
+
`==` and `is_relative_to()` between the two say anything about files,
|
|
58
|
+
so `copy()`, `move()` and `PathSyncer` ask this before refusing a
|
|
59
|
+
transfer as "the same file" or "overlapping".
|
|
60
|
+
|
|
61
|
+
Called only with `type(other) is type(self)`. Must be symmetric and
|
|
62
|
+
must not do I/O. Default: both share one `_backend` object (or neither
|
|
63
|
+
has one), which makes equal paths of a type that says nothing the
|
|
64
|
+
same file. A type whose instances can front different hosts or stores
|
|
65
|
+
MUST override this, or a transfer between two of them that are spelled
|
|
66
|
+
alike is refused."""
|
|
67
|
+
return getattr(self, "_backend", None) is getattr(other, "_backend", None)
|
|
68
|
+
|
|
69
|
+
def _node_key(self) -> "tuple[object, tuple[str, ...]] | None":
|
|
70
|
+
"""Where this path's node lives, for the same-file and nesting checks
|
|
71
|
+
of `copy()` and `move()`: `(namespace, names)`. `namespace` is an
|
|
72
|
+
object two paths share (compared with `is`) exactly when they resolve
|
|
73
|
+
names in one tree; `names` is the node's position in it, normalized
|
|
74
|
+
so that every spelling of one node gives the same tuple and the
|
|
75
|
+
root gives `()`.
|
|
76
|
+
|
|
77
|
+
Default `None`: two paths of one type that `_same_filesystem()`
|
|
78
|
+
places together are compared with `==` and `is_relative_to()`. A type
|
|
79
|
+
whose segments spell one node in several ways (a relative and an
|
|
80
|
+
absolute form, a `..` segment, an alias scheme) overrides this, and
|
|
81
|
+
its paths are then compared across classes. Must not do I/O."""
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def samefile(self, other_path: str | _ty.Self):
|
|
85
|
+
"""Return whether other_path is the same or not as this file, by
|
|
86
|
+
comparing (st_dev, st_ino) when the backend's stat() provides them.
|
|
87
|
+
Raises NotImplementedError otherwise (e.g. our own FileStat doesn't
|
|
88
|
+
carry st_dev/st_ino) -- LocalPath gets a real implementation from
|
|
89
|
+
pathlib.Path via MRO instead of this one.
|
|
90
|
+
"""
|
|
91
|
+
from ..path import Path # `path` imports this module
|
|
92
|
+
|
|
93
|
+
# with_segments(), never the bare constructor or `_coerce_target()`:
|
|
94
|
+
# a str is a path on this backend. `type(self)(str)` gave a MemPath
|
|
95
|
+
# a fresh, empty backend, and a URI parse drops this path's host.
|
|
96
|
+
other = (
|
|
97
|
+
other_path
|
|
98
|
+
if isinstance(other_path, Path)
|
|
99
|
+
else self.with_segments(other_path)
|
|
100
|
+
)
|
|
101
|
+
st1 = self.stat()
|
|
102
|
+
st2 = other.stat()
|
|
103
|
+
ident1 = (getattr(st1, "st_dev", None), getattr(st1, "st_ino", None))
|
|
104
|
+
ident2 = (getattr(st2, "st_dev", None), getattr(st2, "st_ino", None))
|
|
105
|
+
if None in ident1 or None in ident2:
|
|
106
|
+
raise NotImplementedError(
|
|
107
|
+
"samefile() requires stat() to provide st_dev/st_ino"
|
|
108
|
+
)
|
|
109
|
+
return ident1 == ident2
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Copy(Identity, Create, Symlink, Readlink, BinaryOpen, _ty.Protocol):
|
|
113
|
+
"""Copying a file or a tree to another path, of this backend or of
|
|
114
|
+
another. It precedes `BinaryOpen` in a class's bases because both define
|
|
115
|
+
`copy()` and this one takes the options of a path copy."""
|
|
116
|
+
|
|
117
|
+
__slots__ = ()
|
|
118
|
+
|
|
119
|
+
__primitives__: _ty.ClassVar[tuple[object, ...]] = ()
|
|
120
|
+
|
|
121
|
+
def _copy_from(self, source, follow_symlinks=True, preserve_metadata=False):
|
|
122
|
+
"""Copy `source` -- any path with `info`, `iterdir()`, `readlink()` and
|
|
123
|
+
`open()`, such as a `pathlib.Path` -- to this path, creating files and
|
|
124
|
+
directories as pathlib 3.14 does: an existing file is overwritten, an
|
|
125
|
+
existing directory raises. Python 3.14's `pathlib.Path.copy(target)`
|
|
126
|
+
hands the work to `target._copy_from()`; `preserve_metadata` copies the
|
|
127
|
+
permission bits and the modification time where this class can set
|
|
128
|
+
them."""
|
|
129
|
+
info = source.info
|
|
130
|
+
link = not follow_symlinks and info.is_symlink()
|
|
131
|
+
if link:
|
|
132
|
+
self.symlink_to(str(source.readlink()), info.is_dir())
|
|
133
|
+
elif info.is_dir():
|
|
134
|
+
children = source.iterdir()
|
|
135
|
+
self.mkdir()
|
|
136
|
+
for child in children:
|
|
137
|
+
self.joinpath(child.name)._copy_from(
|
|
138
|
+
child, follow_symlinks, preserve_metadata
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
with source.open("rb") as reader, self.open("wb") as writer:
|
|
142
|
+
_shutil.copyfileobj(reader, writer)
|
|
143
|
+
read = getattr(source, "stat", None)
|
|
144
|
+
if preserve_metadata and not link and read is not None:
|
|
145
|
+
stat = read()
|
|
146
|
+
for apply in (
|
|
147
|
+
lambda: self.chmod(_stat.S_IMODE(stat.st_mode)),
|
|
148
|
+
lambda: self._utime(mtime=stat.st_mtime),
|
|
149
|
+
):
|
|
150
|
+
try:
|
|
151
|
+
apply()
|
|
152
|
+
except NotImplementedError:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
def _coerce_target(self, target: str) -> "Path":
|
|
156
|
+
"""Turn a `str` destination (`copy()`, `move()`) into a
|
|
157
|
+
path on the same backend. The default keeps per-instance state (a
|
|
158
|
+
`MemPath`'s in-memory filesystem) via `with_segments()`; the bare
|
|
159
|
+
constructor used before gave every str destination a fresh, empty
|
|
160
|
+
backend, so `move("/c.txt")` wrote there and then deleted the source.
|
|
161
|
+
A class whose strings are URI references overrides this to parse the
|
|
162
|
+
string as one."""
|
|
163
|
+
return self.with_segments(target)
|
|
164
|
+
|
|
165
|
+
def _write_batch(self) -> "_ty.ContextManager[object]":
|
|
166
|
+
"""A context manager that `copy(recursive=True)` and `PathSyncer` enter
|
|
167
|
+
around the writes they make to this path's tree, so a backend that
|
|
168
|
+
pays for every write (a zip rewrites its file) can commit them
|
|
169
|
+
together when the block ends. It must read nothing and raise nothing;
|
|
170
|
+
the default does nothing."""
|
|
171
|
+
return _contextlib.nullcontext()
|
|
172
|
+
|
|
173
|
+
def copy(
|
|
174
|
+
self,
|
|
175
|
+
target: "Path | str",
|
|
176
|
+
*,
|
|
177
|
+
overwrite=False,
|
|
178
|
+
follow_symlinks=True,
|
|
179
|
+
preserve_metadata=True,
|
|
180
|
+
recursive=False,
|
|
181
|
+
ignore_error=None,
|
|
182
|
+
progress: "_ty.Callable[[_ty.Self, int, _ty.Optional[int]], None]" = None,
|
|
183
|
+
on_error: "bool | _ty.Callable[[Exception, _ty.Self], bool] | None" = None,
|
|
184
|
+
):
|
|
185
|
+
"""Copy this file's content to `target`.
|
|
186
|
+
|
|
187
|
+
`follow_symlinks`/`preserve_metadata` are named to match CPython
|
|
188
|
+
3.14's Path.copy() (added after this method); `overwrite` is our
|
|
189
|
+
own extension (3.14 has no equivalent -- it always raises if the
|
|
190
|
+
destination exists). `preserve_metadata` defaults to True here
|
|
191
|
+
(unlike 3.14's False): st_mode is propagated, but not
|
|
192
|
+
timestamps or xattrs -- full metadata preservation is not implemented.
|
|
193
|
+
`ignore_error` accepts a bool or a callable. `True` ignores every
|
|
194
|
+
error; `False` and `None` (the default) fail on the first error. A
|
|
195
|
+
callable is invoked as `ignore_error(error)` and, as it always has
|
|
196
|
+
here, is a *notification* hook: the error is suppressed regardless of
|
|
197
|
+
what it returns, so handlers like `errors.append` (returning None)
|
|
198
|
+
keep working. Only errors from *child* copies during a
|
|
199
|
+
`recursive=True` copy are routed here.
|
|
200
|
+
|
|
201
|
+
`on_error` is `rm()`'s predicate: a callable `(error, path) -> bool`
|
|
202
|
+
(or a bool) asked once per child error, with `path` the source entry
|
|
203
|
+
that failed. `True` skips the entry and goes on, `False` raises the
|
|
204
|
+
error, and a predicate that raises ends the copy with its own
|
|
205
|
+
exception. An error it declined is not offered again by the
|
|
206
|
+
directories above. `ignore_error` and `on_error` are two spellings of
|
|
207
|
+
one policy: passing both raises `TypeError` before anything is copied.
|
|
208
|
+
|
|
209
|
+
A child whose listed name is not one safe component for `target` (a
|
|
210
|
+
drive such as `C:x` or a backslash on a Windows target, which land
|
|
211
|
+
outside it; a trailing dot, which Windows stores under another name)
|
|
212
|
+
is refused with `ValueError` and reported through `ignore_error` or
|
|
213
|
+
`on_error` like any child failure. `PathSyncer` and
|
|
214
|
+
`unpack_archive()` apply the same rule; an archive listing keeps
|
|
215
|
+
such names, as they are ordinary filenames on POSIX.
|
|
216
|
+
|
|
217
|
+
`progress`, when given, is called as `progress(path, bytes_copied,
|
|
218
|
+
total_size)` for every chunk written during each *file* copy (`path`
|
|
219
|
+
is the source `Path` being streamed -- `self` for a single-file
|
|
220
|
+
copy, or the relevant child during a `recursive=True` copy).
|
|
221
|
+
`bytes_copied` increases monotonically per file and reaches
|
|
222
|
+
`total_size` (or `None` if the size couldn't be determined) at the
|
|
223
|
+
end of that file. Directories themselves don't get a progress call
|
|
224
|
+
(only the files inside them do). With `progress=None` (the
|
|
225
|
+
default), behavior is unchanged -- no per-chunk overhead. Native
|
|
226
|
+
backend transfers that bypass the generic streaming copy (e.g.
|
|
227
|
+
an SFTP backend's concurrent fan-out) do not invoke `progress`;
|
|
228
|
+
see `docs/divergences.md`'s "Deliberate extensions" section.
|
|
229
|
+
"""
|
|
230
|
+
offering = copy_error_predicate(ignore_error, on_error)
|
|
231
|
+
if isinstance(target, str):
|
|
232
|
+
target = self._coerce_target(target)
|
|
233
|
+
src = self
|
|
234
|
+
handed = _take_handed(src, target)
|
|
235
|
+
known = None if handed is None else handed.source_stat
|
|
236
|
+
|
|
237
|
+
if not follow_symlinks and known is None and src.is_symlink():
|
|
238
|
+
# pathlib 3.14: copy the link itself, not what it points at.
|
|
239
|
+
# Copying the target's content (and chmod'ing it with the link's
|
|
240
|
+
# own 0o777) silently defeated the flag.
|
|
241
|
+
return src._copy_symlink(target, overwrite=overwrite)
|
|
242
|
+
|
|
243
|
+
# One stat of the source answers its type, its mode and, beside the
|
|
244
|
+
# target's, whether the two are one file; None when it cannot be had.
|
|
245
|
+
src_stat = known if known is not None else _stat_or_none(src, follow_symlinks)
|
|
246
|
+
target_absent = handed is not None and handed.target_absent
|
|
247
|
+
|
|
248
|
+
if recursive and src_stat is not None and _stat.S_ISDIR(src_stat.st_mode):
|
|
249
|
+
if contains(src, target):
|
|
250
|
+
# Checked before anything is created: the new directory would
|
|
251
|
+
# be listed and copied into itself without end.
|
|
252
|
+
raise OSError(
|
|
253
|
+
_errno.EINVAL,
|
|
254
|
+
"Cannot copy a directory into itself",
|
|
255
|
+
str(target),
|
|
256
|
+
)
|
|
257
|
+
# The writes below may be committed together by a backend that
|
|
258
|
+
# pays for each one (see `_write_batch`).
|
|
259
|
+
with _write_batch_of(target):
|
|
260
|
+
# Listed before the target exists, as shutil.copytree does.
|
|
261
|
+
children = list(src.iterdir())
|
|
262
|
+
if not target_absent and target.exists():
|
|
263
|
+
if not target.is_dir():
|
|
264
|
+
raise FileExistsError(target)
|
|
265
|
+
if not overwrite:
|
|
266
|
+
raise FileExistsError(target)
|
|
267
|
+
else:
|
|
268
|
+
target.mkdir()
|
|
269
|
+
windows_target = _names.is_windows_flavoured(target)
|
|
270
|
+
for child in children:
|
|
271
|
+
try:
|
|
272
|
+
# A listed name is untrusted: one that is not a single component inside
|
|
273
|
+
# `target` is refused (see the child-name paragraph of the docstring).
|
|
274
|
+
if not _names.is_safe_child_name(
|
|
275
|
+
child.name, windows=windows_target
|
|
276
|
+
):
|
|
277
|
+
raise ValueError(
|
|
278
|
+
f"refusing unsafe child name {child.name!r} "
|
|
279
|
+
f"under {target}"
|
|
280
|
+
)
|
|
281
|
+
child.copy(
|
|
282
|
+
target / child.name,
|
|
283
|
+
overwrite=overwrite,
|
|
284
|
+
follow_symlinks=follow_symlinks,
|
|
285
|
+
preserve_metadata=preserve_metadata,
|
|
286
|
+
recursive=True,
|
|
287
|
+
ignore_error=ignore_error,
|
|
288
|
+
progress=progress,
|
|
289
|
+
# Passed on only when given, so a `copy()` override
|
|
290
|
+
# without the keyword still works as a child.
|
|
291
|
+
**({} if offering is None else {"on_error": offering}),
|
|
292
|
+
)
|
|
293
|
+
except Exception as e:
|
|
294
|
+
if offering is not None:
|
|
295
|
+
if not offering(e, child):
|
|
296
|
+
raise
|
|
297
|
+
elif callable(ignore_error):
|
|
298
|
+
# A notify-and-suppress hook: its return value is not
|
|
299
|
+
# consulted, so `errors.append` works.
|
|
300
|
+
ignore_error(e)
|
|
301
|
+
elif not ignore_error:
|
|
302
|
+
raise
|
|
303
|
+
if preserve_metadata:
|
|
304
|
+
# After the children: a read-only directory could not take
|
|
305
|
+
# them, and its mode is only settled once they are in.
|
|
306
|
+
_copy_mode(src, target, follow_symlinks, src_stat)
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
# What needs no I/O first; the stats below settle the rest.
|
|
310
|
+
if _same_file(src, target, identity=False):
|
|
311
|
+
raise OSError(
|
|
312
|
+
_errno.EINVAL, "Source and target are the same file", str(target)
|
|
313
|
+
)
|
|
314
|
+
# stat(), not exists(): exists() reads a transient error (a 503, a
|
|
315
|
+
# timeout) as "missing", and overwrite=False must never be decided by
|
|
316
|
+
# a failure. Only FileNotFoundError means the target is absent.
|
|
317
|
+
try:
|
|
318
|
+
target_stat = None if target_absent else target.stat()
|
|
319
|
+
except FileNotFoundError:
|
|
320
|
+
target_stat = None
|
|
321
|
+
target_exists = target_stat is not None
|
|
322
|
+
if target_stat is not None:
|
|
323
|
+
if _same_identity(src_stat, target_stat):
|
|
324
|
+
raise OSError(
|
|
325
|
+
_errno.EINVAL, "Source and target are the same file", str(target)
|
|
326
|
+
)
|
|
327
|
+
if _stat.S_ISDIR(target_stat.st_mode):
|
|
328
|
+
raise IsADirectoryError(target)
|
|
329
|
+
if not overwrite:
|
|
330
|
+
raise FileExistsError(target)
|
|
331
|
+
|
|
332
|
+
# Open the source before the target is touched at all: a missing or
|
|
333
|
+
# unreadable source must leave an existing target intact and must not
|
|
334
|
+
# leave a new empty one behind.
|
|
335
|
+
with src.open("rb") as input:
|
|
336
|
+
if target_exists and supports(target, Remove):
|
|
337
|
+
target.unlink(missing_ok=True)
|
|
338
|
+
created = False
|
|
339
|
+
try:
|
|
340
|
+
with target.open("wb") as output:
|
|
341
|
+
created = True
|
|
342
|
+
BinaryOpen._copy_stream(
|
|
343
|
+
src,
|
|
344
|
+
input,
|
|
345
|
+
output,
|
|
346
|
+
progress=(
|
|
347
|
+
None
|
|
348
|
+
if progress is None
|
|
349
|
+
else lambda copied, total: progress(src, copied, total)
|
|
350
|
+
),
|
|
351
|
+
total_size=(
|
|
352
|
+
_ASK_STAT
|
|
353
|
+
if src_stat is None
|
|
354
|
+
else (
|
|
355
|
+
getattr(src_stat, "st_size", None)
|
|
356
|
+
if getattr(src_stat, "size_known", True)
|
|
357
|
+
else None
|
|
358
|
+
)
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
except BaseException:
|
|
362
|
+
# A half-written target is not a copy of anything; do not
|
|
363
|
+
# leave it looking like one.
|
|
364
|
+
if created and supports(target, Remove):
|
|
365
|
+
try:
|
|
366
|
+
target.unlink(missing_ok=True)
|
|
367
|
+
except Exception:
|
|
368
|
+
pass
|
|
369
|
+
raise
|
|
370
|
+
|
|
371
|
+
if preserve_metadata:
|
|
372
|
+
_copy_mode(src, target, follow_symlinks, src_stat)
|
|
373
|
+
|
|
374
|
+
def _into(self, target_dir: "Path | str") -> "Path":
|
|
375
|
+
"""`target_dir / self.name`, the target of `copy_into()` and
|
|
376
|
+
`move_into()`. A `str` directory is a path on this backend, as for
|
|
377
|
+
`copy()`'s `str` target."""
|
|
378
|
+
name = self.name
|
|
379
|
+
if not name:
|
|
380
|
+
raise ValueError("%r has an empty name" % (self,))
|
|
381
|
+
if isinstance(target_dir, str):
|
|
382
|
+
target_dir = self._coerce_target(target_dir)
|
|
383
|
+
return target_dir / name
|
|
384
|
+
|
|
385
|
+
def copy_into(
|
|
386
|
+
self,
|
|
387
|
+
target_dir: "Path | str",
|
|
388
|
+
*,
|
|
389
|
+
overwrite=False,
|
|
390
|
+
follow_symlinks=True,
|
|
391
|
+
preserve_metadata=True,
|
|
392
|
+
recursive=False,
|
|
393
|
+
ignore_error=None,
|
|
394
|
+
progress: "_ty.Callable[[_ty.Self, int, _ty.Optional[int]], None]" = None,
|
|
395
|
+
on_error: "bool | _ty.Callable[[Exception, _ty.Self], bool] | None" = None,
|
|
396
|
+
) -> "Path":
|
|
397
|
+
"""Copy this file or directory into the directory `target_dir`, under
|
|
398
|
+
its own name, and return the new path (pathlib 3.14 parity).
|
|
399
|
+
|
|
400
|
+
`copy(target_dir / self.name, ...)` with the same keywords and the
|
|
401
|
+
same defaults as `copy()`: an existing target needs `overwrite=True`
|
|
402
|
+
and a directory source needs `recursive=True`. `ValueError` for a
|
|
403
|
+
path with no name."""
|
|
404
|
+
target = self._into(target_dir)
|
|
405
|
+
self.copy(
|
|
406
|
+
target,
|
|
407
|
+
overwrite=overwrite,
|
|
408
|
+
follow_symlinks=follow_symlinks,
|
|
409
|
+
preserve_metadata=preserve_metadata,
|
|
410
|
+
recursive=recursive,
|
|
411
|
+
ignore_error=ignore_error,
|
|
412
|
+
progress=progress,
|
|
413
|
+
**({} if on_error is None else {"on_error": on_error}),
|
|
414
|
+
)
|
|
415
|
+
return target
|
|
416
|
+
|
|
417
|
+
def _copy_symlink(self, target: "Path", *, overwrite=False):
|
|
418
|
+
"""`copy(follow_symlinks=False)` of a symlink: create a link at
|
|
419
|
+
`target` with the same (unresolved) target text. Raises
|
|
420
|
+
NotImplementedError when this backend cannot read links or the
|
|
421
|
+
target's cannot create them -- never falls back to copying content."""
|
|
422
|
+
if not supports(self, Readlink):
|
|
423
|
+
raise NotImplementedError(f"copy(follow_symlinks=False) of {self!r}")
|
|
424
|
+
link = self.readlink()
|
|
425
|
+
if type(link) is not type(target):
|
|
426
|
+
# Another backend: hand over the literal target text.
|
|
427
|
+
text = getattr(link, "path", None)
|
|
428
|
+
link = text if isinstance(text, str) else link.as_posix()
|
|
429
|
+
if _same_file(self, target):
|
|
430
|
+
raise OSError(
|
|
431
|
+
_errno.EINVAL, "Source and target are the same file", str(target)
|
|
432
|
+
)
|
|
433
|
+
target_stat = FileStat.from_path(target, follow_symlink=False)
|
|
434
|
+
if target_stat is not None:
|
|
435
|
+
if target_stat.is_dir() and not target_stat.is_symlink():
|
|
436
|
+
raise IsADirectoryError(target)
|
|
437
|
+
if not overwrite:
|
|
438
|
+
raise FileExistsError(target)
|
|
439
|
+
target.unlink(missing_ok=True)
|
|
440
|
+
target.symlink_to(link, target_is_directory=self.is_dir())
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class Move(Copy, Remove, _ty.Protocol):
|
|
444
|
+
"""Renaming and moving: `rename()` is the primitive, and `move()` falls
|
|
445
|
+
back to a copy followed by a removal where it is not available."""
|
|
446
|
+
|
|
447
|
+
__slots__ = ()
|
|
448
|
+
|
|
449
|
+
__primitives__: _ty.ClassVar[tuple[object, ...]] = ("rename",)
|
|
450
|
+
|
|
451
|
+
def _rename_compatible(self, target: "Path") -> bool:
|
|
452
|
+
"""Whether `rename()` may be attempted onto `target` at all. `move()`
|
|
453
|
+
skips straight to copy + delete when it is not. Default: only onto a
|
|
454
|
+
path of this very class, since `rename()` runs inside this class's
|
|
455
|
+
own store and would otherwise be handed a path of another store (a
|
|
456
|
+
`MemPath` subclass given a `LocalPath` renamed within memory).
|
|
457
|
+
A class for URI references decides per target in `rename()` (raising
|
|
458
|
+
NotImplementedError); `LocalPath` requires a local target, since
|
|
459
|
+
`os.rename()` would otherwise take a remote path's `__fspath__()` as
|
|
460
|
+
a local name."""
|
|
461
|
+
return type(target) is type(self)
|
|
462
|
+
|
|
463
|
+
@_stubs.primitive
|
|
464
|
+
def rename(self, target: "_ty.Self | str"):
|
|
465
|
+
"""Rename this file or directory to the given target."""
|
|
466
|
+
return _stubs._unimplemented("rename")
|
|
467
|
+
|
|
468
|
+
def move(self, target: "Path|str", *, overwrite=False):
|
|
469
|
+
"""Move this file or directory to target, falling back to copy+unlink/rm if rename is unsupported.
|
|
470
|
+
|
|
471
|
+
Nothing is removed until the move is known to be allowed: a missing
|
|
472
|
+
source, a file onto a directory, a target that holds the source
|
|
473
|
+
(`OSError` `ENOTEMPTY`) or lies inside it (`EINVAL`), and a second
|
|
474
|
+
name of the same file that is a link (`EINVAL`) all raise first. The
|
|
475
|
+
same file under another spelling (a case alias, `x.move(x)`) is
|
|
476
|
+
renamed in place. Between two hard links of one file, an existing
|
|
477
|
+
target needs `overwrite=True`, and the source name is then removed.
|
|
478
|
+
"""
|
|
479
|
+
if isinstance(target, str):
|
|
480
|
+
target = self._coerce_target(target)
|
|
481
|
+
src = self
|
|
482
|
+
# rename() only makes sense between paths the same backend can see.
|
|
483
|
+
native = src._rename_compatible(target)
|
|
484
|
+
|
|
485
|
+
# Everything that can fail cheaply is checked before the target is
|
|
486
|
+
# touched: a missing source, or a file onto a directory, must not
|
|
487
|
+
# delete the target and only then raise.
|
|
488
|
+
held = _Held(src)
|
|
489
|
+
src_stat = held.source
|
|
490
|
+
if src_stat is None:
|
|
491
|
+
raise FileNotFoundError(
|
|
492
|
+
_errno.ENOENT, "No such file or directory", str(src)
|
|
493
|
+
)
|
|
494
|
+
if not supports(src, Remove) and not (native and supports(src, Move)):
|
|
495
|
+
# Copied and then not removed, a move would leave two files.
|
|
496
|
+
raise NotImplementedError(f"move() of {src!r}: it cannot be removed")
|
|
497
|
+
# The same entry under another spelling (a case-only rename on a
|
|
498
|
+
# case-insensitive filesystem, or `x.move(x)`) is renamed in place:
|
|
499
|
+
# removing the "existing" target would delete the source itself.
|
|
500
|
+
relation = _relation(src, target, follow=False, held=held)
|
|
501
|
+
target_stat = None
|
|
502
|
+
if relation != _SAME:
|
|
503
|
+
target_stat = held.target(target)
|
|
504
|
+
if target_stat is not None and not overwrite:
|
|
505
|
+
raise FileExistsError(target)
|
|
506
|
+
if relation == _INNER:
|
|
507
|
+
raise OSError(
|
|
508
|
+
_errno.EINVAL, "Cannot move a directory into itself", str(target)
|
|
509
|
+
)
|
|
510
|
+
if target_stat is not None:
|
|
511
|
+
if relation == _OUTER:
|
|
512
|
+
raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(target))
|
|
513
|
+
if relation == _OTHER:
|
|
514
|
+
return _drop_second_name(src, src_stat, target, target_stat)
|
|
515
|
+
if target_stat.is_dir() and not target_stat.is_symlink():
|
|
516
|
+
if not src_stat.is_dir():
|
|
517
|
+
raise IsADirectoryError(target)
|
|
518
|
+
target.rm(recursive=True, missing_ok=True)
|
|
519
|
+
elif (
|
|
520
|
+
native
|
|
521
|
+
and type(target) is type(src)
|
|
522
|
+
and getattr(type(src), "replace", Move.replace) is not Move.replace
|
|
523
|
+
):
|
|
524
|
+
# A class with its own replace() (os.replace() swaps atomically and leaves the
|
|
525
|
+
# target alone on failure). Path.replace() is built on move(): calling it here
|
|
526
|
+
# would never end.
|
|
527
|
+
try:
|
|
528
|
+
return src.replace(target)
|
|
529
|
+
except OSError as error:
|
|
530
|
+
if error.errno != _errno.EXDEV:
|
|
531
|
+
raise
|
|
532
|
+
native = False
|
|
533
|
+
else:
|
|
534
|
+
target.unlink(missing_ok=True)
|
|
535
|
+
|
|
536
|
+
if native:
|
|
537
|
+
try:
|
|
538
|
+
return src.rename(target)
|
|
539
|
+
except NotImplementedError:
|
|
540
|
+
pass
|
|
541
|
+
except OSError as error:
|
|
542
|
+
# Another filesystem or drive: fall back to copy + delete, as
|
|
543
|
+
# shutil.move and 3.14's Path.move do.
|
|
544
|
+
if error.errno != _errno.EXDEV:
|
|
545
|
+
raise
|
|
546
|
+
|
|
547
|
+
# A link is recreated, not read through, wherever the target can hold one, so
|
|
548
|
+
# the result does not depend on rename() being available. The copy is handed
|
|
549
|
+
# what is known (nothing at `target`, `src` not a link); a failed rename changed nothing.
|
|
550
|
+
links = _holds_links(src, target)
|
|
551
|
+
handing = _handing(
|
|
552
|
+
src,
|
|
553
|
+
target,
|
|
554
|
+
held.source_raw if not src_stat.is_symlink() else None,
|
|
555
|
+
relation != _SAME and target_stat is None,
|
|
556
|
+
)
|
|
557
|
+
if src_stat.is_symlink() and links:
|
|
558
|
+
with handing:
|
|
559
|
+
src.copy(target, overwrite=overwrite, follow_symlinks=False)
|
|
560
|
+
src.unlink()
|
|
561
|
+
elif src.is_dir() if src_stat.is_symlink() else src_stat.is_dir():
|
|
562
|
+
with handing:
|
|
563
|
+
src.copy(
|
|
564
|
+
target,
|
|
565
|
+
overwrite=overwrite,
|
|
566
|
+
recursive=True,
|
|
567
|
+
follow_symlinks=not links,
|
|
568
|
+
)
|
|
569
|
+
src.rm(recursive=True)
|
|
570
|
+
else:
|
|
571
|
+
with handing:
|
|
572
|
+
src.copy(target, overwrite=overwrite)
|
|
573
|
+
src.unlink()
|
|
574
|
+
|
|
575
|
+
def move_into(self, target_dir: "Path | str", *, overwrite=False) -> "Path":
|
|
576
|
+
"""Move this file or directory into the directory `target_dir`, under
|
|
577
|
+
its own name, and return the new path (pathlib 3.14 parity).
|
|
578
|
+
|
|
579
|
+
`move(target_dir / self.name, overwrite=overwrite)`: an existing
|
|
580
|
+
target needs `overwrite=True`. `ValueError` for a path with no name."""
|
|
581
|
+
target = self._into(target_dir)
|
|
582
|
+
self.move(target, overwrite=overwrite)
|
|
583
|
+
return target
|
|
584
|
+
|
|
585
|
+
def replace(self, target: "Path | str") -> "Path":
|
|
586
|
+
"""Rename this file or directory to `target`, replacing what is
|
|
587
|
+
there, and return `target` as a path (pathlib parity).
|
|
588
|
+
|
|
589
|
+
A file replaces a file and a directory replaces an empty directory;
|
|
590
|
+
a directory or a file standing in the way of the other kind raises
|
|
591
|
+
as `os.replace()` does, and a directory that holds anything raises
|
|
592
|
+
`OSError(ENOTEMPTY)` rather than being removed. Built on `move()`, so
|
|
593
|
+
it works between any two paths `move()` accepts; `LocalPath` keeps
|
|
594
|
+
stdlib's `os.replace()`."""
|
|
595
|
+
if isinstance(target, str):
|
|
596
|
+
target = self._coerce_target(target)
|
|
597
|
+
if target.is_dir() and next(iter(target._scandir()), None) is not None:
|
|
598
|
+
raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(target))
|
|
599
|
+
self.move(target, overwrite=True)
|
|
600
|
+
return target
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
class _Held:
|
|
604
|
+
"""The non-following stats `move()` takes of its source and its target,
|
|
605
|
+
each at most once, for `move()` and `_relation()` to share. It lives for
|
|
606
|
+
one call and is never kept on a path."""
|
|
607
|
+
|
|
608
|
+
__slots__ = ("source", "source_raw", "_target", "_target_raw", "_taken")
|
|
609
|
+
|
|
610
|
+
def __init__(self, src: Path):
|
|
611
|
+
self.source_raw = self.source = None
|
|
612
|
+
self._target = self._target_raw = None
|
|
613
|
+
self._taken = False
|
|
614
|
+
try:
|
|
615
|
+
self.source_raw = src.stat(follow_symlinks=False)
|
|
616
|
+
except FileNotFoundError:
|
|
617
|
+
return
|
|
618
|
+
self.source = FileStat.from_stat(self.source_raw)
|
|
619
|
+
|
|
620
|
+
def target(self, path: Path):
|
|
621
|
+
"""The `FileStat` of `path` without following a link, None when it is
|
|
622
|
+
missing; an `OSError` is raised again by the next call."""
|
|
623
|
+
if not self._taken:
|
|
624
|
+
try:
|
|
625
|
+
self._target_raw = path.stat(follow_symlinks=False)
|
|
626
|
+
except FileNotFoundError:
|
|
627
|
+
self._target_raw = None
|
|
628
|
+
self._target = (
|
|
629
|
+
None
|
|
630
|
+
if self._target_raw is None
|
|
631
|
+
else FileStat.from_stat(self._target_raw)
|
|
632
|
+
)
|
|
633
|
+
self._taken = True
|
|
634
|
+
return self._target
|
|
635
|
+
|
|
636
|
+
def same_file(self, src: Path, target: Path) -> bool:
|
|
637
|
+
"""`src.samefile(target)` from the stats in hand: for a class that does
|
|
638
|
+
not answer `samefile()` itself and two paths that are not links (a
|
|
639
|
+
link's own stat is not the file `samefile()` compares)."""
|
|
640
|
+
if type(src).samefile is not Identity.samefile or self.source.is_symlink():
|
|
641
|
+
return src.samefile(target)
|
|
642
|
+
there = self.target(target)
|
|
643
|
+
if there is None:
|
|
644
|
+
return False
|
|
645
|
+
if there.is_symlink():
|
|
646
|
+
return src.samefile(target)
|
|
647
|
+
return _same_identity(self.source_raw, self._target_raw)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _holds_links(src: Path, target: Path) -> bool:
|
|
651
|
+
"""Whether a symlink of `src` can be recreated at `target`: `src` reads
|
|
652
|
+
links and the class of `target` implements `_symlink_to()`."""
|
|
653
|
+
return (
|
|
654
|
+
supports(src, Readlink)
|
|
655
|
+
and getattr(type(target), "_symlink_to", None) is not Symlink._symlink_to
|
|
656
|
+
)
|