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,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()) == []
|