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,83 @@
|
|
|
1
|
+
"""The standard fixture tree and the names the contracts share."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno as _errno
|
|
6
|
+
|
|
7
|
+
#: The standard tree `ReadPathContract`/`PathContract` expect under `root`:
|
|
8
|
+
#: relative POSIX name -> file content, or `None` for a directory.
|
|
9
|
+
FIXTURE_TREE = {
|
|
10
|
+
"a.txt": "a",
|
|
11
|
+
"b.py": "b",
|
|
12
|
+
".hidden.txt": "hidden",
|
|
13
|
+
"sub": None,
|
|
14
|
+
"sub/c.py": "c",
|
|
15
|
+
"sub/nested": None,
|
|
16
|
+
"sub/nested/d.py": "d",
|
|
17
|
+
"empty_dir": None,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#: What pathlib raises for a file operation (read, write, unlink) aimed at a
|
|
21
|
+
#: directory: `IsADirectoryError` on Linux, `PermissionError` on Windows and
|
|
22
|
+
#: (for unlink) macOS.
|
|
23
|
+
DIRECTORY_ERRORS = (IsADirectoryError, PermissionError)
|
|
24
|
+
|
|
25
|
+
#: errno values `rmdir()` of a non-empty directory may carry (POSIX allows
|
|
26
|
+
#: either; Windows maps ERROR_DIR_NOT_EMPTY to ENOTEMPTY).
|
|
27
|
+
NOT_EMPTY_ERRNOS = (_errno.ENOTEMPTY, _errno.EEXIST)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def populate_fixture_tree(root):
|
|
31
|
+
"""Create the standard contract tree (`FIXTURE_TREE`) under `root`, an
|
|
32
|
+
existing empty directory, through the path's own `mkdir()` and
|
|
33
|
+
`write_text()` -- works for any `Path` implementation (or a stdlib
|
|
34
|
+
`pathlib.Path`). Returns `root`."""
|
|
35
|
+
for name, content in FIXTURE_TREE.items():
|
|
36
|
+
path = root.joinpath(*name.split("/"))
|
|
37
|
+
if content is None:
|
|
38
|
+
path.mkdir()
|
|
39
|
+
else:
|
|
40
|
+
path.write_text(content)
|
|
41
|
+
return root
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _rel(root, path):
|
|
45
|
+
"""`path` relative to `root` as a POSIX string ("." for `root` itself).
|
|
46
|
+
|
|
47
|
+
Built from `name`/`parent` only, so it holds for implementations whose
|
|
48
|
+
`relative_to()` is missing or spells the root differently. A root URI
|
|
49
|
+
with and without its trailing "/" (`http://host` vs `http://host/`)
|
|
50
|
+
counts as the same directory."""
|
|
51
|
+
names = []
|
|
52
|
+
while path != root and str(path).rstrip("/") != str(root).rstrip("/"):
|
|
53
|
+
parent = path.parent
|
|
54
|
+
if parent == path or len(names) > 64:
|
|
55
|
+
raise AssertionError(f"{path!r} is not below {root!r}")
|
|
56
|
+
names.append(path.name)
|
|
57
|
+
path = parent
|
|
58
|
+
return "/".join(reversed(names)) or "."
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
#: Names that URL-shaped or shell-shaped code tends to cut, decode or fold, stored,
|
|
62
|
+
#: listed and read back exactly as written. Several pairs differ only after a cut
|
|
63
|
+
#: at `#` or a percent-decode, so a backend that applies either stores one name.
|
|
64
|
+
UNUSUAL_NAMES = (
|
|
65
|
+
"with space.txt",
|
|
66
|
+
"hash#one.txt",
|
|
67
|
+
"hash#two.txt",
|
|
68
|
+
"pct%41.txt",
|
|
69
|
+
"pctA.txt",
|
|
70
|
+
"100%.txt",
|
|
71
|
+
"plus+amp&eq=.txt",
|
|
72
|
+
"Mixed Case.TXT",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
#: The same, holding `?`, which not every file system can store.
|
|
76
|
+
QUESTION_MARK_NAMES = ("q?one.txt", "q?two.txt", "q?x#y%41.txt")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _payload():
|
|
80
|
+
"""About 290 KiB holding every byte value, runs of CR and LF, and a
|
|
81
|
+
counter that makes a dropped, repeated or reordered chunk visible."""
|
|
82
|
+
block = bytes(range(256)) + b"\r\n\r\n\n\r\x00\x00\x00"
|
|
83
|
+
return b"".join(index.to_bytes(4, "big") + block for index in range(1100))
|
pathapi/testing/_read.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""The contracts of a pure path and of a read-only path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pickle
|
|
6
|
+
import stat as _stat
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from ..protocols import Create, Listing, Move, Remove, Utime, supports
|
|
10
|
+
|
|
11
|
+
from ._fixtures import DIRECTORY_ERRORS, _rel
|
|
12
|
+
|
|
13
|
+
#: The protocol each switch defaults to asking `supports()` about.
|
|
14
|
+
_SWITCH_PROTOCOLS = {
|
|
15
|
+
"supports_listing": Listing,
|
|
16
|
+
"supports_mkdir": Create,
|
|
17
|
+
"supports_delete": Remove,
|
|
18
|
+
"supports_utime": Utime,
|
|
19
|
+
"supports_rename": Move,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PurePathContract:
|
|
24
|
+
"""Contract tests for pure path (logical) operations, not requiring any I/O."""
|
|
25
|
+
|
|
26
|
+
def test_pure_basics(self, root):
|
|
27
|
+
p = root / "dir" / "file.txt"
|
|
28
|
+
assert p.name == "file.txt"
|
|
29
|
+
assert p.suffix == ".txt"
|
|
30
|
+
assert p.stem == "file"
|
|
31
|
+
|
|
32
|
+
# parent and parents
|
|
33
|
+
assert p.parent.name == "dir"
|
|
34
|
+
assert len(p.parents) >= 2
|
|
35
|
+
assert p.parents[0].name == "dir"
|
|
36
|
+
|
|
37
|
+
def test_pure_joinpath_truediv(self, root):
|
|
38
|
+
p = root / "a"
|
|
39
|
+
assert (p / "b").name == "b"
|
|
40
|
+
assert p.joinpath("b", "c").name == "c"
|
|
41
|
+
|
|
42
|
+
def test_pure_match(self, root):
|
|
43
|
+
p = root / "dir" / "file.txt"
|
|
44
|
+
assert p.match("*.txt")
|
|
45
|
+
assert not p.match("*.py")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ReadPathContract(PurePathContract):
|
|
49
|
+
"""Contract tests for read-only path operations.
|
|
50
|
+
|
|
51
|
+
Subclasses must provide a `root` fixture pointing to a fresh directory
|
|
52
|
+
pre-populated with the standard tree -- see `populate_fixture_tree()` and
|
|
53
|
+
`FIXTURE_TREE`:
|
|
54
|
+
|
|
55
|
+
- a.txt (content: "a")
|
|
56
|
+
- b.py (content: "b")
|
|
57
|
+
- .hidden.txt (content: "hidden")
|
|
58
|
+
- sub/c.py (content: "c")
|
|
59
|
+
- sub/nested/d.py (content: "d")
|
|
60
|
+
- empty_dir/
|
|
61
|
+
|
|
62
|
+
Capability attributes (all default True; set one False only for a
|
|
63
|
+
documented gap, and the affected tests skip): `supports_listing`,
|
|
64
|
+
`supports_empty_directories`, `distinguishes_file_types`,
|
|
65
|
+
`supports_pickle`.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
supports_listing = None
|
|
69
|
+
supports_empty_directories = True
|
|
70
|
+
distinguishes_file_types = True
|
|
71
|
+
supports_pickle = True
|
|
72
|
+
|
|
73
|
+
def _require(self, capability, root=None):
|
|
74
|
+
value = getattr(self, capability)
|
|
75
|
+
if value is None:
|
|
76
|
+
# Not set: the class under test says, by the protocol the switch stands for.
|
|
77
|
+
protocol = _SWITCH_PROTOCOLS.get(capability)
|
|
78
|
+
if root is not None and protocol is not None:
|
|
79
|
+
self._support(root, protocol)
|
|
80
|
+
elif not value:
|
|
81
|
+
pytest.skip(f"{type(self).__name__}.{capability} is False")
|
|
82
|
+
|
|
83
|
+
def _has(self, capability, root):
|
|
84
|
+
"""Whether a switch is on: as set, else as the class under test supports it."""
|
|
85
|
+
value = getattr(self, capability)
|
|
86
|
+
if value is None:
|
|
87
|
+
return supports(root, _SWITCH_PROTOCOLS[capability])
|
|
88
|
+
return bool(value)
|
|
89
|
+
|
|
90
|
+
def _support(self, root, *protocols):
|
|
91
|
+
"""Skip when the class under test does not implement a protocol the
|
|
92
|
+
test needs; the skip names it."""
|
|
93
|
+
for protocol in protocols:
|
|
94
|
+
if not supports(root, protocol):
|
|
95
|
+
pytest.skip(
|
|
96
|
+
f"{type(root).__name__} does not support {protocol.__name__}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def test_exists_and_types(self, root):
|
|
100
|
+
assert root.exists()
|
|
101
|
+
assert root.is_dir()
|
|
102
|
+
|
|
103
|
+
a = root / "a.txt"
|
|
104
|
+
assert a.exists()
|
|
105
|
+
assert a.is_file()
|
|
106
|
+
assert not a.is_dir()
|
|
107
|
+
|
|
108
|
+
sub = root / "sub"
|
|
109
|
+
assert sub.exists()
|
|
110
|
+
assert sub.is_dir()
|
|
111
|
+
assert not sub.is_file()
|
|
112
|
+
|
|
113
|
+
assert not (root / "nonexistent").exists()
|
|
114
|
+
assert not (root / "nonexistent").is_file()
|
|
115
|
+
assert not (root / "nonexistent").is_dir()
|
|
116
|
+
|
|
117
|
+
def test_a_pickled_path_is_equal_to_the_original(self, root):
|
|
118
|
+
# A worker process receives a path by pickle. The copy must be the
|
|
119
|
+
# same value: equal, hashing alike, and for a URI path placed on the
|
|
120
|
+
# same filesystem as the original.
|
|
121
|
+
self._require("supports_pickle")
|
|
122
|
+
path = root / "a.txt"
|
|
123
|
+
again = pickle.loads(pickle.dumps(path))
|
|
124
|
+
assert type(again) is type(path)
|
|
125
|
+
assert again == path
|
|
126
|
+
assert hash(again) == hash(path)
|
|
127
|
+
if hasattr(path, "_supplied_backend"):
|
|
128
|
+
assert again._same_filesystem(path)
|
|
129
|
+
assert path._same_filesystem(again)
|
|
130
|
+
|
|
131
|
+
def test_read_text_and_bytes(self, root):
|
|
132
|
+
assert (root / "a.txt").read_text() == "a"
|
|
133
|
+
assert (root / "a.txt").read_bytes() == b"a"
|
|
134
|
+
assert (root / "sub" / "c.py").read_text() == "c"
|
|
135
|
+
assert (root / "sub" / "nested" / "d.py").read_bytes() == b"d"
|
|
136
|
+
|
|
137
|
+
def test_open_read_modes(self, root):
|
|
138
|
+
f = root / ".hidden.txt"
|
|
139
|
+
with f.open() as fh:
|
|
140
|
+
assert fh.read() == "hidden"
|
|
141
|
+
with f.open("r") as fh:
|
|
142
|
+
assert fh.read() == "hidden"
|
|
143
|
+
with f.open("rt") as fh:
|
|
144
|
+
assert fh.read() == "hidden"
|
|
145
|
+
with f.open("rb") as fh:
|
|
146
|
+
assert fh.read() == b"hidden"
|
|
147
|
+
|
|
148
|
+
def test_iterdir_lists_children(self, root):
|
|
149
|
+
self._require("supports_listing", root)
|
|
150
|
+
names = {p.name for p in root.iterdir()}
|
|
151
|
+
assert names == {"a.txt", "b.py", ".hidden.txt", "sub", "empty_dir"}
|
|
152
|
+
if self.supports_empty_directories:
|
|
153
|
+
assert list((root / "empty_dir").iterdir()) == []
|
|
154
|
+
|
|
155
|
+
def test_iterdir_missing_raises_file_not_found(self, root):
|
|
156
|
+
self._require("supports_listing", root)
|
|
157
|
+
with pytest.raises(FileNotFoundError):
|
|
158
|
+
list((root / "nonexistent").iterdir())
|
|
159
|
+
|
|
160
|
+
def test_iterdir_file_raises_not_a_directory(self, root):
|
|
161
|
+
self._require("supports_listing", root)
|
|
162
|
+
self._require("distinguishes_file_types")
|
|
163
|
+
with pytest.raises(NotADirectoryError):
|
|
164
|
+
list((root / "a.txt").iterdir())
|
|
165
|
+
|
|
166
|
+
def test_stat(self, root):
|
|
167
|
+
st = (root / "a.txt").stat()
|
|
168
|
+
assert st.st_size == 1
|
|
169
|
+
assert isinstance(st.st_mtime, (int, float))
|
|
170
|
+
# A second, longer file: a backend that reports one size for all.
|
|
171
|
+
assert (root / ".hidden.txt").stat().st_size == len("hidden")
|
|
172
|
+
|
|
173
|
+
def test_stat_tells_a_directory_from_a_file(self, root):
|
|
174
|
+
self._require("distinguishes_file_types")
|
|
175
|
+
assert _stat.S_ISREG((root / "a.txt").stat().st_mode)
|
|
176
|
+
assert not _stat.S_ISDIR((root / "a.txt").stat().st_mode)
|
|
177
|
+
assert _stat.S_ISDIR((root / "sub").stat().st_mode)
|
|
178
|
+
assert not _stat.S_ISREG((root / "sub").stat().st_mode)
|
|
179
|
+
|
|
180
|
+
def test_listing_metadata_agrees_with_stat(self, root):
|
|
181
|
+
# `_scandir()` carries the metadata `walk()`, `rm()` and `PathSyncer` trust without
|
|
182
|
+
# a `stat()` per entry; `None` means unknown, never a different answer, and a
|
|
183
|
+
# stat with `size_known` False answers the type alone.
|
|
184
|
+
self._require("supports_listing", root)
|
|
185
|
+
listed = dict(root._scandir())
|
|
186
|
+
assert set(listed) == {"a.txt", "b.py", ".hidden.txt", "sub", "empty_dir"}
|
|
187
|
+
for name, entry in listed.items():
|
|
188
|
+
if entry is None:
|
|
189
|
+
continue
|
|
190
|
+
real = (root / name).stat()
|
|
191
|
+
assert _stat.S_ISDIR(entry.st_mode) == _stat.S_ISDIR(real.st_mode), name
|
|
192
|
+
if not _stat.S_ISDIR(real.st_mode) and getattr(entry, "size_known", True):
|
|
193
|
+
assert entry.st_size == real.st_size, name
|
|
194
|
+
|
|
195
|
+
def test_stat_missing_raises_file_not_found(self, root):
|
|
196
|
+
with pytest.raises(FileNotFoundError):
|
|
197
|
+
(root / "nonexistent").stat()
|
|
198
|
+
|
|
199
|
+
def test_read_missing_raises_file_not_found(self, root):
|
|
200
|
+
with pytest.raises(FileNotFoundError):
|
|
201
|
+
(root / "nonexistent.txt").read_bytes()
|
|
202
|
+
with pytest.raises(FileNotFoundError):
|
|
203
|
+
(root / "nonexistent" / "x.txt").read_text()
|
|
204
|
+
|
|
205
|
+
def test_read_directory_raises(self, root):
|
|
206
|
+
self._require("distinguishes_file_types")
|
|
207
|
+
with pytest.raises(DIRECTORY_ERRORS):
|
|
208
|
+
(root / "sub").read_bytes()
|
|
209
|
+
|
|
210
|
+
def test_nothing_exists_below_a_file(self, root):
|
|
211
|
+
below = root / "a.txt" / "x"
|
|
212
|
+
assert below.exists() is False
|
|
213
|
+
assert below.is_file() is False
|
|
214
|
+
assert below.is_dir() is False
|
|
215
|
+
with pytest.raises((FileNotFoundError, NotADirectoryError)):
|
|
216
|
+
below.read_bytes()
|
|
217
|
+
with pytest.raises((FileNotFoundError, NotADirectoryError)):
|
|
218
|
+
below.stat()
|
|
219
|
+
|
|
220
|
+
def test_partial_reads_continue_where_the_last_stopped(self, root):
|
|
221
|
+
with (root / ".hidden.txt").open("rb") as fh:
|
|
222
|
+
assert fh.read(2) == b"hi"
|
|
223
|
+
assert fh.read(3) == b"dde"
|
|
224
|
+
assert fh.read() == b"n"
|
|
225
|
+
assert fh.read() == b""
|
|
226
|
+
|
|
227
|
+
def test_glob(self, root):
|
|
228
|
+
self._require("supports_listing", root)
|
|
229
|
+
assert {_rel(root, p) for p in root.glob("*.py")} == {"b.py"}
|
|
230
|
+
# pathlib matches hidden names with "*".
|
|
231
|
+
assert {_rel(root, p) for p in root.glob("*")} == {
|
|
232
|
+
"a.txt",
|
|
233
|
+
"b.py",
|
|
234
|
+
".hidden.txt",
|
|
235
|
+
"sub",
|
|
236
|
+
"empty_dir",
|
|
237
|
+
}
|
|
238
|
+
assert {_rel(root, p) for p in root.glob("sub/*.py")} == {"sub/c.py"}
|
|
239
|
+
# A trailing separator selects directories only -- with `native=False`,
|
|
240
|
+
# the one rule for every interpreter. The default follows the running
|
|
241
|
+
# one, and pathlib ignored a trailing separator before 3.11.
|
|
242
|
+
assert {_rel(root, p) for p in root.glob("*/", native=False)} == {
|
|
243
|
+
"sub",
|
|
244
|
+
"empty_dir",
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
def test_glob_recursive(self, root):
|
|
248
|
+
self._require("supports_listing", root)
|
|
249
|
+
expected = {"b.py", "sub/c.py", "sub/nested/d.py"}
|
|
250
|
+
assert {_rel(root, p) for p in root.glob("**/*.py")} == expected
|
|
251
|
+
assert {_rel(root, p) for p in root.rglob("*.py")} == expected
|
|
252
|
+
|
|
253
|
+
def test_glob_missing_or_file_parent_selects_nothing(self, root):
|
|
254
|
+
self._require("supports_listing", root)
|
|
255
|
+
assert list((root / "nonexistent").glob("*")) == []
|
|
256
|
+
assert list((root / "a.txt").glob("*")) == []
|
|
257
|
+
assert list(root.glob("*.nonexistent")) == []
|
|
258
|
+
|
|
259
|
+
def test_walk(self, root):
|
|
260
|
+
self._require("supports_listing", root)
|
|
261
|
+
expected = {
|
|
262
|
+
".": (["empty_dir", "sub"], [".hidden.txt", "a.txt", "b.py"]),
|
|
263
|
+
"sub": (["nested"], ["c.py"]),
|
|
264
|
+
"sub/nested": ([], ["d.py"]),
|
|
265
|
+
"empty_dir": ([], []),
|
|
266
|
+
}
|
|
267
|
+
top_down = list(root.walk())
|
|
268
|
+
actual = {
|
|
269
|
+
_rel(root, d): (sorted(dirs), sorted(files)) for d, dirs, files in top_down
|
|
270
|
+
}
|
|
271
|
+
if not self.supports_empty_directories:
|
|
272
|
+
del actual["empty_dir"], expected["empty_dir"]
|
|
273
|
+
assert actual == expected
|
|
274
|
+
assert _rel(root, top_down[0][0]) == "."
|
|
275
|
+
|
|
276
|
+
bottom_up = [_rel(root, d) for d, _, _ in root.walk(top_down=False)]
|
|
277
|
+
assert sorted(bottom_up) == sorted(_rel(root, d) for d, _, _ in top_down)
|
|
278
|
+
assert bottom_up[-1] == "."
|
|
279
|
+
assert bottom_up.index("sub/nested") < bottom_up.index("sub")
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pathapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generic Path Protocol based pathlib
|
|
5
|
+
Project-URL: Homepage, https://github.com/jose-pr/pathapi/
|
|
6
|
+
Project-URL: Documentation, https://jose-pr.github.io/pathapi/
|
|
7
|
+
Project-URL: Issues, https://github.com/jose-pr/pathapi/issues
|
|
8
|
+
Author: Jose A
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: black; extra == 'dev'
|
|
24
|
+
Requires-Dist: build; extra == 'dev'
|
|
25
|
+
Requires-Dist: cryptography; extra == 'dev'
|
|
26
|
+
Requires-Dist: hatchling; extra == 'dev'
|
|
27
|
+
Requires-Dist: hypothesis; extra == 'dev'
|
|
28
|
+
Requires-Dist: pyftpdlib; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
31
|
+
Requires-Dist: twine; extra == 'dev'
|
|
32
|
+
Provides-Extra: docs
|
|
33
|
+
Requires-Dist: mkdocs; extra == 'docs'
|
|
34
|
+
Requires-Dist: mkdocs-material; extra == 'docs'
|
|
35
|
+
Requires-Dist: mkdocstrings[python]; extra == 'docs'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# pathapi
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/pathapi/)
|
|
41
|
+
[](https://pypi.org/project/pathapi/)
|
|
42
|
+
[](https://github.com/jose-pr/pathapi/blob/main/LICENSE)
|
|
43
|
+
[](https://jose-pr.github.io/pathapi/)
|
|
44
|
+
[](https://github.com/jose-pr/pathapi/actions/workflows/test.yml)
|
|
45
|
+
|
|
46
|
+
A **robust, extensible pathlib-like base** for any resource addressable as a
|
|
47
|
+
path. Same method names, signatures, semantics and exception types as
|
|
48
|
+
`pathlib.Path` wherever a `pathlib.Path` equivalent exists: write code once
|
|
49
|
+
against `Path` and it runs against your local disk, an in-memory tree, an FTP
|
|
50
|
+
server or the members of a zip or tar file, and against any path class built on
|
|
51
|
+
it. It has no dependencies. Deliberate differences from `pathlib` are listed in
|
|
52
|
+
[Divergences from pathlib](https://jose-pr.github.io/pathapi/divergences/).
|
|
53
|
+
|
|
54
|
+
`pathapi` was published as `pathlib-next` up to 0.10.0; `pip install pathlib-next`
|
|
55
|
+
now installs a shim that depends on this package. See the changelog for the
|
|
56
|
+
migration from `pathlib_next`.
|
|
57
|
+
|
|
58
|
+
## Features
|
|
59
|
+
|
|
60
|
+
| Class | Read | Write | List | Stat | mkdir | Delete | rename |
|
|
61
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
62
|
+
| local path (`LocalPath`) | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
|
|
63
|
+
| in-memory (`MemPath`) | Yes | Yes | Yes | Yes | Yes | Yes | No (`move()` copies) |
|
|
64
|
+
| FTP and FTPS (`FTPPath`) | Yes | Yes | Yes (MLSD, NLST fallback) | Yes | Yes | Yes | Yes |
|
|
65
|
+
| zip members (`ZipPath`) | Yes | Local archive only | Yes | Yes | Local archive only | Local archive only | Local archive only |
|
|
66
|
+
| tar members (`TarPath`, incl. gz/bz2/xz) | Yes | No | Yes | Yes | No | No | No |
|
|
67
|
+
| `ArchivePath` | As `ZipPath`/`TarPath` for the format it detects | | | | | | |
|
|
68
|
+
|
|
69
|
+
Every class gets `glob()`, `walk()`, `copy()`/`move()`, `rm()` and `PathSyncer`
|
|
70
|
+
from the base classes; `LocalPath` and the other classes replace some of them
|
|
71
|
+
where the backend has a faster way.
|
|
72
|
+
|
|
73
|
+
- **One path interface** over local files, in-memory trees, FTP servers and
|
|
74
|
+
archive members, and over any class built on `Path`.
|
|
75
|
+
- **`MemPath`**: a virtual filesystem for tests, mocks and transient data.
|
|
76
|
+
- **`FTPPath`, `ZipPath`, `TarPath`, `ArchivePath`**: an FTP server and the
|
|
77
|
+
members of a zip or tar file as paths, on the standard library alone.
|
|
78
|
+
- **`pathapi.http`**: `HTTPClient` and a transport contract for HTTP
|
|
79
|
+
requests on the standard library (redirects that never carry
|
|
80
|
+
`Authorization` to another origin, TLS verified by default, a `proxies=`
|
|
81
|
+
provider); the `requests` library plugs in as a transport through the
|
|
82
|
+
`pathapi-requests` distribution.
|
|
83
|
+
- **`PathSyncer`**: one-way, content-compared tree sync between any two
|
|
84
|
+
implementations, with dry runs and event hooks.
|
|
85
|
+
- **Extensible**: subclass `Path` for any resource, and verify it with the
|
|
86
|
+
bundled pytest contracts.
|
|
87
|
+
|
|
88
|
+
Paths for other places are separate distributions built on this one:
|
|
89
|
+
|
|
90
|
+
| Distribution | Provides |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| [`uripath`](https://github.com/jose-pr/uripath) | `URIPath`: one class for any URI, with the schemes `file:`, `data:`, `ftp:`, `zip:`, `tar:`, `http:` and `dav:`, and the `uripath` command |
|
|
93
|
+
| [`sftppath`](https://github.com/jose-pr/sftppath) | `SFTPPath` (paramiko or asyncssh) |
|
|
94
|
+
| [`objpath`](https://github.com/jose-pr/objpath) | `S3Path`, `GSPath` and `AZPath` (Amazon S3, Google Cloud Storage, Azure Blob Storage) |
|
|
95
|
+
| [`repopath`](https://github.com/jose-pr/repopath) | `RepoPath`, `GitHubPath` and `GitLabPath` (the REST APIs of the hosting services) |
|
|
96
|
+
| [`pathapi-requests`](https://github.com/jose-pr/pathapi-requests) | `RequestsTransport`, the HTTP transport that sends through a `requests.Session` |
|
|
97
|
+
|
|
98
|
+
## Installation
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install pathapi
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
There are no extras and no dependencies: everything above runs on the standard
|
|
105
|
+
library. `pytest` is needed only to import `pathapi.testing` and
|
|
106
|
+
`pathapi.http.testing`.
|
|
107
|
+
|
|
108
|
+
## Quick start
|
|
109
|
+
|
|
110
|
+
**Local filesystem**, as `pathlib.Path`:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from pathapi import Path
|
|
114
|
+
|
|
115
|
+
p = Path("data") / "report.txt"
|
|
116
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
p.write_text("hello")
|
|
118
|
+
print(p.read_text())
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**In memory**, no disk I/O:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from pathapi.mempath import MemPath
|
|
125
|
+
|
|
126
|
+
p = MemPath("/config/settings.json")
|
|
127
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
p.write_text('{"debug": true}')
|
|
129
|
+
print([child.name for child in p.parent.iterdir()])
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Archive members**: the segments are the member path and the archive is any
|
|
133
|
+
path:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
import zipfile
|
|
137
|
+
from pathapi import ZipPath
|
|
138
|
+
|
|
139
|
+
with zipfile.ZipFile("backup.zip", "w") as archive:
|
|
140
|
+
archive.writestr("etc/config.ini", "[main]\n")
|
|
141
|
+
|
|
142
|
+
member = ZipPath("etc/config.ini", archive="backup.zip")
|
|
143
|
+
print(member.read_text())
|
|
144
|
+
print([child.name for child in member.parent.iterdir()])
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**FTP**: nothing connects until the first operation:
|
|
148
|
+
|
|
149
|
+
<!-- example: skip: reads from a remote host -->
|
|
150
|
+
```python
|
|
151
|
+
from pathapi import FTPPath
|
|
152
|
+
|
|
153
|
+
for child in FTPPath("/pub", host="ftp.example.com", user="anonymous").iterdir():
|
|
154
|
+
print(child.name, child.stat().st_size)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Copy and sync across classes**:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from pathapi import Path
|
|
161
|
+
from pathapi.mempath import MemPath
|
|
162
|
+
from pathapi.sync import PathSyncer
|
|
163
|
+
|
|
164
|
+
source = MemPath("/site")
|
|
165
|
+
(source / "css").mkdir(parents=True)
|
|
166
|
+
(source / "index.html").write_text("<h1>hi</h1>")
|
|
167
|
+
|
|
168
|
+
PathSyncer(remove_missing=True).sync(source, Path("site-copy"))
|
|
169
|
+
print(sorted(p.name for p in Path("site-copy").iterdir()))
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## API overview
|
|
173
|
+
|
|
174
|
+
| Module | Purpose |
|
|
175
|
+
| --- | --- |
|
|
176
|
+
| `pathapi.path` | `Pathname`/`Path` base classes and the derived operations |
|
|
177
|
+
| `pathapi.fspath` | `LocalPath`, `PosixPathname`, `WindowsPathname` |
|
|
178
|
+
| `pathapi.mempath` | `MemPath` in-memory filesystem |
|
|
179
|
+
| `pathapi.ftp` | `FTPPath`, `FTPBackend` (FTP and FTPS on `ftplib`) |
|
|
180
|
+
| `pathapi.archive` | `ZipPath`, `TarPath`, `ArchivePath` (members of a zip or tar file), `make_archive`, `unpack_archive` |
|
|
181
|
+
| `pathapi.http` | `HTTPClient`, `HTTPTransport`, `HTTPResponse` (HTTP requests on `http.client`) |
|
|
182
|
+
| `pathapi.protocols` | `Stat`, `Chmod`, `BinaryOpen`, `NativeChecksum` |
|
|
183
|
+
| `pathapi.sync` | `PathSyncer`, `SyncEvent`, `PathAndStat` |
|
|
184
|
+
| `pathapi.glob` | the glob engine behind `Path.glob()` |
|
|
185
|
+
| `pathapi.checksum` | `md5`, `sha256`, `stream`, `native` |
|
|
186
|
+
| `pathapi.providers` | the helpers a class built on `Path` calls |
|
|
187
|
+
| `pathapi.exceptions` | `PathError`, `NonRelativePatternError` |
|
|
188
|
+
| `pathapi.testing` | pytest contracts for custom implementations |
|
|
189
|
+
| `pathapi.http.testing` | testing support (not stable API): the pytest suite that holds an HTTP transport to the promises of the built-in one |
|
|
190
|
+
|
|
191
|
+
The full reference is on the [documentation site](https://jose-pr.github.io/pathapi/);
|
|
192
|
+
an agent-oriented API summary ships inside the package as
|
|
193
|
+
`pathapi/AGENTS.md`.
|
|
194
|
+
|
|
195
|
+
## Extending
|
|
196
|
+
|
|
197
|
+
Subclass `Path` for a resource of your own (`MemPath` is the reference), as
|
|
198
|
+
worked through in
|
|
199
|
+
[Extending](https://jose-pr.github.io/pathapi/guides/extending/).
|
|
200
|
+
`pathapi.testing` provides the pytest contracts (`PurePathContract`,
|
|
201
|
+
`ReadPathContract`, `PathContract`) and `populate_fixture_tree()` that every
|
|
202
|
+
class of the package and of the distributions above is verified with.
|
|
203
|
+
|
|
204
|
+
## Supported Python versions
|
|
205
|
+
|
|
206
|
+
Python 3.9 through 3.14. CI runs the full suite on Ubuntu for every version
|
|
207
|
+
from 3.9 to 3.14, on Windows and macOS for 3.9 and 3.14, and with nothing but
|
|
208
|
+
`pytest` and `pyftpdlib` installed on 3.9 and 3.14.
|
|
209
|
+
|
|
210
|
+
## Development
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
pip install -e ".[dev]"
|
|
214
|
+
python -m pytest -q -rs
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Tests that need an optional package are skipped when it is missing, so check
|
|
218
|
+
the skip list (`-rs`). Contributor notes (virtual environments, formatting, CI,
|
|
219
|
+
releases) are in the repository's
|
|
220
|
+
[`AGENTS.md`](https://github.com/jose-pr/pathapi/blob/main/AGENTS.md);
|
|
221
|
+
benchmarks are described in
|
|
222
|
+
[Benchmarks](https://jose-pr.github.io/pathapi/benchmarks/). To preview
|
|
223
|
+
the documentation: `pip install -e ".[docs]"` then `mkdocs serve`.
|
|
224
|
+
|
|
225
|
+
Changes are recorded in the
|
|
226
|
+
[changelog](https://github.com/jose-pr/pathapi/blob/main/CHANGELOG.md).
|
|
227
|
+
|
|
228
|
+
### Releasing
|
|
229
|
+
|
|
230
|
+
1. Move the `[Unreleased]` entries of `CHANGELOG.md` under a new
|
|
231
|
+
`## [x.y.z] - <date>` heading and add its link definition at the bottom.
|
|
232
|
+
2. Bump `version` in `pyproject.toml` in the same commit.
|
|
233
|
+
3. Before 1.0 the minor version changes only when the documented API breaks;
|
|
234
|
+
new methods, new optional arguments and fixes are patch releases.
|
|
235
|
+
4. Run the full suite on the oldest and the newest supported Python,
|
|
236
|
+
`mkdocs build --strict` and `python -m build`.
|
|
237
|
+
5. Push `main`, then the `v*` tag: the release workflow tests, builds and
|
|
238
|
+
publishes to PyPI and creates the GitHub release.
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
MIT; see [LICENSE](https://github.com/jose-pr/pathapi/blob/main/LICENSE).
|