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,96 @@
1
+ """Test-suite building blocks for verifying custom Path
2
+ implementations satisfy the library's filesystem contract.
3
+
4
+ Not imported by `pathapi/__init__.py` -- this module requires pytest,
5
+ which is a test-only dependency. Import it explicitly. Example::
6
+
7
+ import pytest
8
+
9
+ from pathapi import LocalPath as MyPath # your Path subclass here
10
+ from pathapi.testing import PathContract, populate_fixture_tree
11
+
12
+
13
+ class TestMyPath(PathContract):
14
+ @pytest.fixture
15
+ def root(self, tmp_path):
16
+ root = MyPath(tmp_path)
17
+ populate_fixture_tree(root)
18
+ return root
19
+
20
+ `root` must be a **fresh, function-scoped** directory holding exactly the
21
+ standard tree (`populate_fixture_tree()` builds it through the path's own
22
+ API): the write tests create fixed names and assert their absence first, so
23
+ two contract classes sharing one root fail each other.
24
+
25
+ Rules pathlib guarantees are asserted with pathlib's exception types. Where
26
+ pathlib itself differs by OS (reading or unlinking a directory raises
27
+ `IsADirectoryError` on Linux and `PermissionError` on Windows/macOS), the
28
+ contract accepts that documented set. A backend that genuinely cannot meet a
29
+ rule sets the matching capability attribute to False on its test class; the
30
+ affected tests then report as skipped, never as passed. Every attribute
31
+ defaults to True except `supports_question_mark_names` and the five that
32
+ default to `None`, meaning "ask `supports()`" (`supports_listing`, `supports_mkdir`,
33
+ `supports_delete`, `supports_utime`, `supports_rename`):
34
+
35
+ - `ReadPathContract.supports_listing` -- `iterdir()`, `glob()`, `walk()`
36
+ (a listing's stat whose `size_known` is False is held to its type only).
37
+ - `ReadPathContract.supports_empty_directories` -- `empty_dir/` lists as
38
+ empty (git trees cannot hold an empty directory, so a placeholder file
39
+ lives there).
40
+ - `ReadPathContract.distinguishes_file_types` -- listing a file raises
41
+ `NotADirectoryError` and reading a directory raises (plain HTTP cannot
42
+ tell: one URL serves an index page or a file).
43
+ - `ReadPathContract.supports_pickle` -- a path pickles, and the copy is equal
44
+ to it and hashes alike.
45
+ - `PathContract.supports_rename` -- `rename()`.
46
+ - `PathContract.supports_append` -- `open("a")`/`open("ab")`.
47
+ - `PathContract.supports_exclusive_create` -- `open("x")`.
48
+ - `PathContract.enforces_directory_hierarchy` -- `mkdir()` and writes below
49
+ a missing parent raise `FileNotFoundError`, writing a file over a
50
+ directory raises, and `rename()` onto a non-empty directory raises (object
51
+ stores, whose directories are key prefixes, cannot).
52
+ - `PathContract.supports_mkdir` -- `mkdir()`, and so every test that
53
+ creates a directory (a transport with no directories, such as TFTP, has
54
+ none).
55
+ - `PathContract.supports_delete` -- `unlink()`, `rmdir()` and `rm()`, and
56
+ replacing a file in `copy(overwrite=True)`.
57
+ - `PathContract.supports_move` -- `move()`, by `rename()` or by copying and
58
+ then deleting; False for a backend that can do neither.
59
+ - `PathContract.supports_unusual_names` -- names holding a space, `#`, `%`
60
+ (also followed by two hex digits), `+`, `&`, `=` and mixed case are stored
61
+ and listed exactly as written.
62
+ - `PathContract.supports_question_mark_names` -- the same for names holding
63
+ `?`. False by default: a Windows file system cannot hold such a name, so
64
+ a correct local backend there would fail; set it True for a URI scheme or
65
+ a remote store.
66
+ - `PathContract.supports_utime` -- `_utime()` sets an existing file's
67
+ modification time, and `touch()` of that file moves it. A backend that
68
+ keeps no settable time (an object store, an archive) is correct without
69
+ it: it leaves `_utime()` to raise `NotImplementedError` and sets this False.
70
+
71
+ A test that needs an operation checks the matching switch first, so a
72
+ backend without that operation needs no expected-failure marks. The tree
73
+ itself is yours to build: `populate_fixture_tree()` uses `mkdir()` and
74
+ `write_text()`, so a backend without them seeds `root` another way.
75
+ """
76
+
77
+ from __future__ import annotations
78
+
79
+ from ._fixtures import (
80
+ DIRECTORY_ERRORS,
81
+ FIXTURE_TREE,
82
+ NOT_EMPTY_ERRNOS,
83
+ populate_fixture_tree,
84
+ )
85
+ from ._read import PurePathContract, ReadPathContract
86
+ from ._contract import PathContract
87
+
88
+ __all__ = [
89
+ "DIRECTORY_ERRORS",
90
+ "FIXTURE_TREE",
91
+ "NOT_EMPTY_ERRNOS",
92
+ "PathContract",
93
+ "PurePathContract",
94
+ "ReadPathContract",
95
+ "populate_fixture_tree",
96
+ ]
@@ -0,0 +1,522 @@
1
+ """The contract of a path that can be written."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from ._fixtures import (
8
+ DIRECTORY_ERRORS,
9
+ NOT_EMPTY_ERRNOS,
10
+ QUESTION_MARK_NAMES,
11
+ UNUSUAL_NAMES,
12
+ _payload,
13
+ )
14
+ from ._read import ReadPathContract
15
+
16
+
17
+ class PathContract(ReadPathContract):
18
+ """Mixin of filesystem-contract tests every writable Path implementation
19
+ (a custom `Path` subclass) must satisfy.
20
+
21
+ Subclasses must provide a `root` fixture pointing to a fresh, writable,
22
+ function-scoped directory pre-populated with the standard tree (see
23
+ `populate_fixture_tree()`). Tests create fixed names under it without
24
+ cleaning up.
25
+
26
+ Capability attributes (all default True except
27
+ `supports_question_mark_names`; set one False only for a documented gap):
28
+ `supports_rename`, `supports_append`, `supports_exclusive_create`,
29
+ `enforces_directory_hierarchy`, `supports_mkdir`, `supports_delete`,
30
+ `supports_move`, `supports_unusual_names`, `supports_utime`, and
31
+ `supports_question_mark_names` (default False).
32
+ """
33
+
34
+ supports_rename = None
35
+ supports_append = True
36
+ supports_exclusive_create = True
37
+ enforces_directory_hierarchy = True
38
+ supports_mkdir = None
39
+ supports_delete = None
40
+ supports_move = True
41
+ supports_unusual_names = True
42
+ supports_question_mark_names = False
43
+ supports_utime = None
44
+
45
+ def test_mkdir_and_is_dir(self, root):
46
+ self._require("supports_mkdir", root)
47
+ d = root / "new_dir"
48
+ assert not d.exists()
49
+ d.mkdir()
50
+ assert d.is_dir()
51
+ assert not d.is_file()
52
+
53
+ def test_mkdir_existing_raises_file_exists(self, root):
54
+ self._require("supports_mkdir", root)
55
+ with pytest.raises(FileExistsError):
56
+ (root / "sub").mkdir()
57
+ (root / "sub").mkdir(exist_ok=True)
58
+ # exist_ok never covers a file in the way.
59
+ with pytest.raises(FileExistsError):
60
+ (root / "a.txt").mkdir(exist_ok=True)
61
+ assert (root / "a.txt").read_text() == "a"
62
+
63
+ def test_mkdir_missing_parent_raises_file_not_found(self, root):
64
+ self._require("supports_mkdir", root)
65
+ self._require("enforces_directory_hierarchy")
66
+ with pytest.raises(FileNotFoundError):
67
+ (root / "missing_parent" / "child").mkdir()
68
+ assert not (root / "missing_parent").exists()
69
+
70
+ def test_write_read_text_roundtrip(self, root):
71
+ f = root / "write_f.txt"
72
+ f.write_text("hello")
73
+ assert f.exists()
74
+ assert f.is_file()
75
+ assert not f.is_dir()
76
+ assert f.read_text() == "hello"
77
+
78
+ def test_write_read_bytes_roundtrip(self, root):
79
+ f = root / "write_f.bin"
80
+ f.write_bytes(b"\x00\x01hello")
81
+ assert f.read_bytes() == b"\x00\x01hello"
82
+
83
+ def test_every_byte_value_survives_a_multi_chunk_round_trip(self, root):
84
+ # Larger than any one transfer block, with every byte value and runs
85
+ # of CR/LF: a transport that translates line ends, keeps seven bits,
86
+ # or stops at its first chunk changes the bytes or the length.
87
+ payload = _payload()
88
+ f = root / "payload.bin"
89
+ f.write_bytes(payload)
90
+ assert f.stat().st_size == len(payload)
91
+ assert f.read_bytes() == payload
92
+
93
+ g = root / "payload_streamed.bin"
94
+ with g.open("wb") as fh:
95
+ fh.write(payload[:7])
96
+ fh.write(payload[7:70000])
97
+ fh.write(payload[70000:])
98
+ assert g.read_bytes() == payload
99
+
100
+ with f.open("rb") as fh:
101
+ assert fh.read(100) == payload[:100]
102
+ assert fh.read(70000) == payload[100:70100]
103
+ assert fh.read() == payload[70100:]
104
+
105
+ def test_names_with_url_characters_are_stored_and_listed_as_written(self, root):
106
+ self._require("supports_unusual_names")
107
+ self._round_trip_names(root, UNUSUAL_NAMES)
108
+
109
+ def test_names_with_a_question_mark_are_stored_and_listed_as_written(self, root):
110
+ self._require("supports_question_mark_names")
111
+ self._round_trip_names(root, QUESTION_MARK_NAMES)
112
+
113
+ def _round_trip_names(self, root, names):
114
+ for index, name in enumerate(names):
115
+ f = root / name
116
+ assert f.name == name
117
+ assert not f.exists(), name
118
+ text = f"content {index}"
119
+ f.write_text(text)
120
+ assert f.exists() and f.is_file(), name
121
+ assert f.read_text() == text, name
122
+ assert f.stat().st_size == len(text), name
123
+ if self._has("supports_listing", root):
124
+ expected = {"a.txt", "b.py", ".hidden.txt", "sub", "empty_dir", *names}
125
+ assert {p.name for p in root.iterdir()} == expected
126
+ if self._has("supports_rename", root):
127
+ for index, name in enumerate(names):
128
+ target = root / f"renamed {name}"
129
+ (root / name).rename(target)
130
+ assert not (root / name).exists(), name
131
+ assert target.read_text() == f"content {index}", name
132
+ if self._has("supports_delete", root):
133
+ for name in names:
134
+ path = root / (
135
+ f"renamed {name}" if self._has("supports_rename", root) else name
136
+ )
137
+ path.unlink()
138
+ assert not path.exists(), name
139
+
140
+ def test_write_missing_parent_raises_file_not_found(self, root):
141
+ self._require("enforces_directory_hierarchy")
142
+ with pytest.raises(FileNotFoundError):
143
+ (root / "missing_parent" / "y.txt").write_text("y")
144
+ assert not (root / "missing_parent").exists()
145
+
146
+ def test_write_directory_raises(self, root):
147
+ self._require("enforces_directory_hierarchy")
148
+ with pytest.raises(DIRECTORY_ERRORS):
149
+ (root / "sub").write_bytes(b"x")
150
+ assert (root / "sub").is_dir()
151
+ assert (root / "sub" / "c.py").read_text() == "c"
152
+
153
+ def test_open_write_truncates(self, root):
154
+ f = root / "write_trunc.txt"
155
+ f.write_text("long content")
156
+ with f.open("w") as fh:
157
+ fh.write("x")
158
+ assert f.read_text() == "x"
159
+ with f.open("wt") as fh:
160
+ fh.write("yz")
161
+ assert f.read_text() == "yz"
162
+ with f.open("wb") as fh:
163
+ fh.write(b"\x00")
164
+ assert f.read_bytes() == b"\x00"
165
+
166
+ def test_open_append(self, root):
167
+ self._require("supports_append")
168
+ f = root / "append.txt"
169
+ with f.open("a") as fh: # creates a missing file
170
+ fh.write("a")
171
+ assert f.read_text() == "a"
172
+ with f.open("a") as fh:
173
+ fh.write("b")
174
+ with f.open("ab") as fh:
175
+ fh.write(b"c")
176
+ assert f.read_text() == "abc"
177
+
178
+ def test_open_exclusive_create(self, root):
179
+ self._require("supports_exclusive_create")
180
+ f = root / "exclusive.txt"
181
+ with f.open("x") as fh:
182
+ fh.write("new")
183
+ assert f.read_text() == "new"
184
+ with pytest.raises(FileExistsError):
185
+ f.open("x")
186
+ with pytest.raises(FileExistsError):
187
+ f.open("xb")
188
+ assert f.read_text() == "new"
189
+
190
+ def test_unlink(self, root):
191
+ self._require("supports_delete", root)
192
+ f = root / "write_unlink.txt"
193
+ f.write_text("x")
194
+ assert f.exists()
195
+ f.unlink()
196
+ assert not f.exists()
197
+
198
+ def test_unlink_missing_raises_then_missing_ok(self, root):
199
+ self._require("supports_delete", root)
200
+ f = root / "missing_unlink.txt"
201
+ with pytest.raises(FileNotFoundError):
202
+ f.unlink()
203
+ f.unlink(missing_ok=True)
204
+
205
+ def test_unlink_directory_raises(self, root):
206
+ self._require("supports_delete", root)
207
+ with pytest.raises(DIRECTORY_ERRORS):
208
+ (root / "empty_dir").unlink()
209
+ assert (root / "empty_dir").is_dir()
210
+
211
+ def test_rmdir_requires_empty(self, root):
212
+ self._require("supports_mkdir", root)
213
+ self._require("supports_delete", root)
214
+ d = root / "new_rmdir"
215
+ d.mkdir()
216
+ (d / "f.txt").write_text("x")
217
+ with pytest.raises(OSError) as info:
218
+ d.rmdir()
219
+ assert info.value.errno in NOT_EMPTY_ERRNOS
220
+ assert (d / "f.txt").read_text() == "x"
221
+ (d / "f.txt").unlink()
222
+ d.rmdir()
223
+ assert not d.exists()
224
+
225
+ def test_rmdir_missing_raises_file_not_found(self, root):
226
+ self._require("supports_delete", root)
227
+ with pytest.raises(FileNotFoundError):
228
+ (root / "missing_rmdir").rmdir()
229
+
230
+ def test_rmdir_file_raises_not_a_directory(self, root):
231
+ self._require("supports_delete", root)
232
+ with pytest.raises(NotADirectoryError):
233
+ (root / "a.txt").rmdir()
234
+ assert (root / "a.txt").read_text() == "a"
235
+
236
+ def test_rm_recursive(self, root):
237
+ self._require("supports_mkdir", root)
238
+ self._require("supports_delete", root)
239
+ d = root / "new_rm_rec"
240
+ d.mkdir()
241
+ (d / "f.txt").write_text("x")
242
+ (d / "sub_rec").mkdir()
243
+ (d / "sub_rec" / "g.txt").write_text("y")
244
+ d.rm(recursive=True)
245
+ assert not d.exists()
246
+
247
+ def test_rm_recursive_accepts_the_follow_policies(self, root):
248
+ # `rm()` takes `follow_symlinks=` and `follow_binds=` on every
249
+ # implementation; a tree holding no link or binding comes out the
250
+ # same under each of them.
251
+ self._require("supports_mkdir", root)
252
+ self._require("supports_delete", root)
253
+ policies = (
254
+ {"follow_symlinks": False, "follow_binds": False},
255
+ {"follow_symlinks": True, "follow_binds": True},
256
+ {"follow_symlinks": None, "follow_binds": None},
257
+ {"follow_symlinks": lambda path: False, "follow_binds": lambda path: None},
258
+ )
259
+ for index, keywords in enumerate(policies):
260
+ d = root / f"new_rm_policy_{index}"
261
+ d.mkdir()
262
+ (d / "f.txt").write_text("x")
263
+ (d / "sub_pol").mkdir()
264
+ (d / "sub_pol" / "g.txt").write_text("y")
265
+ d.rm(recursive=True, **keywords)
266
+ assert not d.exists()
267
+
268
+ def test_rm_rejects_a_policy_that_is_not_one(self, root):
269
+ self._require("supports_mkdir", root)
270
+ self._require("supports_delete", root)
271
+ d = root / "new_rm_bad_policy"
272
+ d.mkdir()
273
+ (d / "f.txt").write_text("x")
274
+ with pytest.raises(ValueError):
275
+ d.rm(recursive=True, ignore_error=True, follow_symlinks="always")
276
+ assert (d / "f.txt").read_text() == "x"
277
+
278
+ def test_rm_non_recursive_directory_requires_empty(self, root):
279
+ self._require("supports_delete", root)
280
+ with pytest.raises(OSError) as info:
281
+ (root / "sub").rm()
282
+ assert info.value.errno in NOT_EMPTY_ERRNOS
283
+ assert (root / "sub" / "c.py").exists()
284
+ (root / "empty_dir").rm()
285
+ assert not (root / "empty_dir").exists()
286
+
287
+ def test_rm_missing_ok(self, root):
288
+ self._require("supports_delete", root)
289
+ with pytest.raises(FileNotFoundError):
290
+ (root / "missing_rm").rm()
291
+ (root / "missing_rm").rm(missing_ok=True)
292
+
293
+ def test_one_error_predicate_means_the_same_to_rm_and_copy(self, root):
294
+ # `(error, path) -> bool`: True skips the entry, False raises, and a predicate that
295
+ # raises ends the call with its own exception. `rm()` is offered a missing path,
296
+ # `copy()` a child whose target is a directory.
297
+ self._require("supports_mkdir", root)
298
+
299
+ class Stop(Exception):
300
+ pass
301
+
302
+ def attempt(call):
303
+ try:
304
+ call()
305
+ except Stop:
306
+ return "stop"
307
+ except OSError:
308
+ return "error"
309
+ return "returned"
310
+
311
+ outcomes = {}
312
+ for decision in ("swallow", "decline", "raise"):
313
+ offered = []
314
+
315
+ def predicate(error, path):
316
+ offered.append((isinstance(error, OSError), path.name))
317
+ if decision == "raise":
318
+ raise Stop()
319
+ return decision == "swallow"
320
+
321
+ source = root / f"predicate_{decision}_src"
322
+ target = root / f"predicate_{decision}_dst"
323
+ source.mkdir()
324
+ (source / "clash.txt").write_text("source")
325
+ (target / "clash.txt").mkdir(parents=True)
326
+ (target / "clash.txt" / "kept.txt").write_text("target")
327
+
328
+ removed = attempt(
329
+ lambda: (root / f"predicate_{decision}_gone").rm(ignore_error=predicate)
330
+ )
331
+ copied = attempt(
332
+ lambda: source.copy(
333
+ target, recursive=True, overwrite=True, on_error=predicate
334
+ )
335
+ )
336
+ outcomes[decision] = (removed, copied)
337
+ assert offered == [
338
+ (True, f"predicate_{decision}_gone"),
339
+ (True, "clash.txt"),
340
+ ], decision
341
+ assert (target / "clash.txt" / "kept.txt").read_text() == "target"
342
+
343
+ assert outcomes == {
344
+ "swallow": ("returned", "returned"),
345
+ "decline": ("error", "error"),
346
+ "raise": ("stop", "stop"),
347
+ }
348
+
349
+ def test_copy_preserves_source(self, root):
350
+ src = root / "a.txt"
351
+ dst = root / "dst_copy.txt"
352
+ src.copy(dst)
353
+ assert dst.read_text() == "a"
354
+ assert src.exists()
355
+
356
+ def test_copy_existing_target_raises_without_overwrite(self, root):
357
+ src = root / "a.txt"
358
+ dst = root / "dst_copy_existing.txt"
359
+ dst.write_text("existing")
360
+ with pytest.raises(FileExistsError):
361
+ src.copy(dst)
362
+ assert dst.read_text() == "existing"
363
+ if self._has("supports_delete", root): # replacing a file removes it first
364
+ src.copy(dst, overwrite=True)
365
+ assert dst.read_text() == "a"
366
+
367
+ def test_copy_missing_source_raises_file_not_found(self, root):
368
+ dst = root / "dst_copy_missing.txt"
369
+ with pytest.raises(FileNotFoundError):
370
+ (root / "nonexistent.txt").copy(dst)
371
+ assert not dst.exists()
372
+
373
+ def test_copy_onto_itself_raises_and_keeps_content(self, root):
374
+ # A second spelling of the same file, built separately: truncating
375
+ # the "target" before reading the source would destroy the only copy.
376
+ src = root / "a.txt"
377
+ with pytest.raises(OSError):
378
+ src.copy(root / "a.txt", overwrite=True)
379
+ assert src.read_text() == "a"
380
+
381
+ def test_same_filesystem_holds_within_one_root(self, root):
382
+ # `copy()`, `move()` and `PathSyncer` only compare two paths that
383
+ # `_same_filesystem()` places together; paths derived from one root
384
+ # always are. A type fronting several hosts or stores overrides it.
385
+ child = root / "a.txt"
386
+ if type(child) is type(root):
387
+ assert root._same_filesystem(child)
388
+ assert child._same_filesystem(root)
389
+ assert child._same_filesystem(root / "sub" / "c.py")
390
+
391
+ def test_copy_recursive(self, root):
392
+ self._require("supports_mkdir", root)
393
+ dst = root / "sub_copy"
394
+ (root / "sub").copy(dst, recursive=True)
395
+ assert (dst / "c.py").read_text() == "c"
396
+ assert (dst / "nested" / "d.py").read_text() == "d"
397
+ assert (root / "sub" / "nested" / "d.py").exists()
398
+
399
+ (root / "sub" / "c.py").write_text("changed")
400
+ with pytest.raises(FileExistsError):
401
+ (root / "sub").copy(dst, recursive=True)
402
+ assert (dst / "c.py").read_text() == "c"
403
+ if self._has("supports_delete", root): # replacing a file removes it first
404
+ (root / "sub").copy(dst, recursive=True, overwrite=True)
405
+ assert (dst / "c.py").read_text() == "changed"
406
+ assert (dst / "nested" / "d.py").read_text() == "d"
407
+
408
+ def test_move(self, root):
409
+ self._require("supports_move")
410
+ # We write a temp file to move so we don't destroy a.txt for other tests
411
+ src = root / "src_move.txt"
412
+ src.write_text("data")
413
+ dst = root / "dst_move.txt"
414
+ src.move(dst)
415
+ assert not src.exists()
416
+ assert dst.read_text() == "data"
417
+
418
+ def test_move_existing_target_raises_without_overwrite(self, root):
419
+ self._require("supports_move")
420
+ src = root / "src_move_existing.txt"
421
+ src.write_text("data")
422
+ dst = root / "b.py"
423
+ with pytest.raises(FileExistsError):
424
+ src.move(dst)
425
+ assert src.read_text() == "data"
426
+ assert dst.read_text() == "b"
427
+ src.move(dst, overwrite=True)
428
+ assert not src.exists()
429
+ assert dst.read_text() == "data"
430
+
431
+ def test_move_directory(self, root):
432
+ self._require("supports_move")
433
+ self._require("supports_mkdir", root)
434
+ src = root / "sub"
435
+ dst = root / "moved_sub"
436
+ src.move(dst)
437
+ assert not src.exists()
438
+ assert (dst / "c.py").read_text() == "c"
439
+ assert (dst / "nested" / "d.py").read_text() == "d"
440
+
441
+ def test_rename(self, root):
442
+ self._require("supports_rename", root)
443
+ src = root / "src_rename.txt"
444
+ src.write_text("data")
445
+ dst = root / "dst_rename.txt"
446
+ result = src.rename(dst)
447
+ assert result.name == "dst_rename.txt"
448
+ assert not src.exists()
449
+ assert dst.read_text() == "data"
450
+ with pytest.raises(FileNotFoundError):
451
+ src.rename(root / "never.txt")
452
+
453
+ def test_rename_onto_a_non_empty_directory_raises_and_keeps_both(self, root):
454
+ self._require("supports_rename", root)
455
+ self._require("enforces_directory_hierarchy")
456
+ src = root / "src_rename_onto_dir.txt"
457
+ src.write_text("data")
458
+ with pytest.raises(OSError):
459
+ src.rename(root / "sub")
460
+ assert src.read_text() == "data"
461
+ assert (root / "sub" / "c.py").read_text() == "c"
462
+ with pytest.raises(OSError):
463
+ (root / "empty_dir").rename(root / "sub")
464
+ assert (root / "empty_dir").is_dir()
465
+ assert (root / "sub" / "nested" / "d.py").read_text() == "d"
466
+
467
+ def test_touch(self, root):
468
+ f = root / "touch_new.txt"
469
+ f.touch()
470
+ assert f.is_file()
471
+ assert f.read_bytes() == b""
472
+ (root / "a.txt").touch()
473
+ assert (root / "a.txt").read_text() == "a"
474
+
475
+ def test_utime_sets_the_time_of_an_existing_file_and_touch_moves_it(self, root):
476
+ self._require("supports_utime", root)
477
+ f = root / "utime_f.txt"
478
+ f.write_text("kept")
479
+ old = 1_000_000_000
480
+ f._utime(mtime=old)
481
+ assert int(f.stat().st_mtime) == old
482
+ f._utime(mtime=old + 600.5)
483
+ assert int(f.stat().st_mtime) == old + 600
484
+ f._utime(atime=old + 5)
485
+ assert int(f.stat().st_mtime) == old + 600
486
+ f.touch()
487
+ assert f.stat().st_mtime > old + 1_000
488
+ assert f.read_text() == "kept"
489
+ with pytest.raises(FileNotFoundError):
490
+ (root / "utime_absent.txt")._utime(mtime=old)
491
+
492
+ def test_touch_exist_ok_false_raises_without_truncating(self, root):
493
+ f = root / "touch_f.txt"
494
+ f.write_text("keep")
495
+ with pytest.raises(FileExistsError):
496
+ f.touch(exist_ok=False)
497
+ assert f.read_text() == "keep"
498
+
499
+ def test_mkdir_parents(self, root):
500
+ self._require("supports_mkdir", root)
501
+ d = root / "parent_a" / "parent_b" / "parent_c"
502
+ d.mkdir(parents=True)
503
+ assert d.is_dir()
504
+ with pytest.raises(FileExistsError):
505
+ d.mkdir(parents=True, exist_ok=False)
506
+ d.mkdir(parents=True, exist_ok=True)
507
+
508
+ def test_listing_reflects_writes(self, root):
509
+ self._require("supports_listing")
510
+ (root / "empty_dir" / "new.txt").write_text("n")
511
+ if self._has("supports_mkdir", root):
512
+ (root / "empty_dir" / "new_sub").mkdir()
513
+ expected = (
514
+ {"new.txt", "new_sub"} if self._has("supports_mkdir", root) else {"new.txt"}
515
+ )
516
+ assert {p.name for p in (root / "empty_dir").iterdir()} == expected
517
+ if not self._has("supports_delete", root):
518
+ return
519
+ (root / "empty_dir" / "new.txt").unlink()
520
+ if self._has("supports_mkdir", root):
521
+ (root / "empty_dir" / "new_sub").rmdir()
522
+ assert list((root / "empty_dir").iterdir()) == []