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,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))
@@ -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
+ [![Version](https://img.shields.io/pypi/v/pathapi.svg)](https://pypi.org/project/pathapi/)
41
+ [![Python versions](https://img.shields.io/pypi/pyversions/pathapi.svg)](https://pypi.org/project/pathapi/)
42
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jose-pr/pathapi/blob/main/LICENSE)
43
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/pathapi/)
44
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/pathapi/test.yml)](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).