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,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
+ )