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
pathapi/AGENTS.md ADDED
@@ -0,0 +1,2355 @@
1
+ # `pathapi` — public API header
2
+
3
+ Header-file-style reference for the `pathapi` package: every public export
4
+ with its signature, arguments, contract and gotchas, so the package can be used
5
+ without reading its source. It ships inside the package and is self-contained.
6
+ Development documentation lives with the source at
7
+ <https://github.com/jose-pr/pathapi>; the rendered documentation is at
8
+ <https://jose-pr.github.io/pathapi/> and the project overview is the
9
+ `README.md` shipped next to this file (`pathapi/README.md`). Every
10
+ deliberate behavioral difference from `pathlib.Path` is listed, with its
11
+ rationale, at <https://jose-pr.github.io/pathapi/divergences/>; this file
12
+ states the resulting contract.
13
+
14
+ Names are imported from the module named in the heading of the section that
15
+ documents them; the root `pathapi` re-exports the core ones ("Package
16
+ root"). A module or name that starts with an underscore is private. The file
17
+ documents the core alone: path classes for URIs, and for SFTP, object stores
18
+ and repository hosts, are built on it by other distributions that ship their
19
+ own header.
20
+
21
+ `pip install pathapi` (published as `pathlib-next`, import name `pathlib_next`,
22
+ up to 0.10.0). No dependencies: everything in this file runs on
23
+ the standard library, and `pytest` is needed only to import
24
+ `pathapi.testing` and `pathapi.http.testing`.
25
+
26
+ `pathapi.__version__` is the installed distribution's version
27
+ (`importlib.metadata`), `"0+unknown"` for a source tree that is not installed.
28
+
29
+ ## Package root (`pathapi`)
30
+
31
+ `import pathapi` exposes `Path`, `Pathname`, `LocalPath`,
32
+ `PosixPathname`, `WindowsPathname`, `MemPath`, `FileStat`, the protocols `Stat`,
33
+ `Chmod`, `BinaryOpen`, `FsPathLike`, the aliases `PathLike`/`PurePathLike`, the
34
+ exceptions `PathError` and `NonRelativePatternError`, and the modules `glob`
35
+ (`pathapi.glob`) and `sync` (`pathapi.sync`). `FTPPath`, `ZipPath`,
36
+ `TarPath` and `ArchivePath` are exported too, imported on first use so that
37
+ `import pathapi` loads neither `ftplib` nor `ssl` nor any archive code.
38
+ `pathapi.http` (HTTP requests) is not exported there: import it when
39
+ it is needed, since it loads `http.client` and `ssl`.
40
+ `MemPath` is defined in `pathapi.mempath`, which also holds
41
+ `MemPathBackend` and `MemFile`; `pathapi.testing` is never imported
42
+ implicitly.
43
+
44
+ Every public module declares `__all__` with exactly the names this file
45
+ documents for it: the root, `path`, `fspath`, `mempath`, `protocols` (and its
46
+ `fs`, `io`, `checksum`), `testing`, `sync`, `glob`, `checksum`, `archive`,
47
+ `ftp`, `http`, `providers` and `exceptions`. So `from pathapi import *` publishes
48
+ the root's names and nothing else (`P` and `PN` are not among them; they stay
49
+ importable from `pathapi.path`). Every other module of the core starts
50
+ with an underscore and is private.
51
+
52
+ The package ships `py.typed`: `Path(...)`, `LocalPath(...)`, `MemPath(...)` and
53
+ `FileStat(...)` pass mypy and pyright at their defaults. A protocol stub (`stat`,
54
+ `chmod`, `_chown`, `_open`, `checksum`) is an ordinary method that
55
+ raises `NotImplementedError`, and a type checker reads `Pathname`'s abstract
56
+ members as ordinary methods too, because the bare `Path(...)` builds a
57
+ `LocalPath`: a subclass that leaves one out is refused when it is instantiated,
58
+ not by a checker.
59
+
60
+ ### `FileStat` (`pathapi`)
61
+
62
+ ```python
63
+ class FileStat:
64
+ st_mode: int
65
+ st_nlink: int
66
+ st_uid: int
67
+ st_gid: int
68
+ st_size: int
69
+ st_atime: float
70
+ st_mtime: float
71
+ st_ctime: float
72
+ mode_known
73
+ size_known
74
+ def __init__(
75
+ self, st_mode=None, st_size=0, st_mtime=0, is_dir=False, size_known=True,
76
+ ): ...
77
+ @classmethod
78
+ def from_stat(cls, stat) -> FileStat: ...
79
+ @classmethod
80
+ def from_path(cls, path, *, follow_symlink=True): ...
81
+ def settime(self, value): ...
82
+ def setmode(self, value, isdir=None): ...
83
+ def items(self): ...
84
+ ```
85
+
86
+ Slotted stat for non-`os` backends, with the fields of `os.stat_result`
87
+ (`st_mode`, `st_nlink`, `st_uid`, `st_gid`, `st_size`, `st_atime`, `st_mtime`,
88
+ `st_ctime`), `mode_known` and `size_known`. Without `st_mode`, a placeholder
89
+ (`S_IFREG|0o444` / `S_IFDIR|0o555`) with `mode_known=False`. A bare
90
+ permission `st_mode` (`0o644`) gets the type bits of `is_dir` ORed in and
91
+ counts as reported; one that carries a type keeps it. `size_known=False`
92
+ (default `True`) marks a listing's **type-only** stat: the entry is known to
93
+ be a file (or a directory) and `st_size` and `st_mtime` are placeholders (0).
94
+ It answers the type (`is_dir()`, `is_file()`, `exists()`, `walk()`, `glob()`,
95
+ `rm(recursive=True)`) and nothing else: a path class hands such a stat on
96
+ once, for a type question, and `stat()` asks the server; `PathSyncer`, `copy()`'s progress total and
97
+ `quick_check` ask the path for a size or a time instead of reading the
98
+ placeholder (`from_stat()` copies the flag from a foreign stat that has one).
99
+ `from_stat(stat)` (a `FileStat` passes through; `None` fields become 0),
100
+ `from_path(path, *, follow_symlink=True) -> FileStat | None` (`None` only
101
+ on `FileNotFoundError`), `settime(value)`, `setmode(value, isdir=None)`,
102
+ `items()`. `is_dir()`/`is_file()`/... are **methods**.
103
+
104
+ ## Pure paths and I/O paths (`pathapi.path`)
105
+
106
+ ### `Pathname`
107
+
108
+ ```python
109
+ class Pathname:
110
+ # abstract
111
+ segments: Sequence[str]
112
+ parts
113
+ parent: Self
114
+ def with_segments(self, *segments) -> Self: ...
115
+ def as_uri(self) -> str: ...
116
+ def relative_to(self, other) -> Self: ...
117
+ # derived
118
+ name: str
119
+ suffix: str
120
+ suffixes
121
+ stem
122
+ def with_name(self, name) -> Self: ...
123
+ def with_stem(self, stem) -> Self: ...
124
+ def with_suffix(self, suffix) -> Self: ...
125
+ parents: Sequence[Self]
126
+ def is_relative_to(self, other): ...
127
+ def joinpath(self, *args) -> Self: ...
128
+ root: str
129
+ drive: str
130
+ anchor: str
131
+ def match(self, path_pattern, *, case_sensitive=None): ...
132
+ def full_match(self, pattern, *, case_sensitive=None) -> bool: ...
133
+ def as_posix(self) -> str: ...
134
+ def has_glob_pattern(self): ...
135
+ def is_absolute(self) -> bool: ...
136
+ ```
137
+
138
+ ABC for a pure (no I/O) path. Abstract: `segments`,
139
+ `parts`, `parent`, `with_segments()`, `as_uri()`,
140
+ `relative_to()`. Derived: `name`, `suffix`, `suffixes`, `stem`
141
+ (suffix rules of the running interpreter), `with_name`/`with_stem`/
142
+ `with_suffix` (`with_name()` and `with_stem()` raise `ValueError` for `""`,
143
+ `.` or a separator; the edge cases of `with_stem("")` and `with_suffix(".")`
144
+ are those of the running `pathlib`: from 3.13 `with_stem("")` raises when the
145
+ name has a suffix), `parents`,
146
+ `is_relative_to()`, `joinpath()`, `/` and `"prefix" / path`,
147
+ `root`/`drive`/`anchor` (`root` is `"/"` when the first segment is empty;
148
+ `drive` is `""`), `match()` (pathlib's
149
+ right-anchored per-segment match; empty pattern → `ValueError`),
150
+ `full_match()` (3.13 semantics: the root is
151
+ a component of its own, so a rooted path needs a rooted pattern or a leading
152
+ `**` that reaches past it, and a lone `*` never matches it; `.` and empty
153
+ components of the pattern are ignored; a bracket expression never matches a
154
+ separator), `as_posix()`, `has_glob_pattern()`, `is_absolute()` (the path
155
+ has a root; a class whose rooted paths are not all absolute overrides it).
156
+
157
+ - `__eq__`/`__hash__` default to `(type(self), tuple(self.segments))`: exact
158
+ type, so a subclass never equals its base. `LocalPath`/`PosixPathname`/
159
+ `WindowsPathname` keep `pathlib.PurePath` equality; a class that compares
160
+ something else (a URI's text) overrides both together.
161
+ - `<`, `<=`, `>`, `>=` compare `_order_key()` (default: the segments)
162
+ between two paths of the same exact type, so `sorted()` works over
163
+ `MemPath`; any other operand gives `NotImplemented`
164
+ (`TypeError`), as `pathlib` does across flavours. A type with its own
165
+ `__eq__` overrides `_order_key()` with the value that `__eq__` compares.
166
+ `LocalPath`/`PosixPathname`/`WindowsPathname` keep `pathlib`'s ordering.
167
+ ### `Path`
168
+
169
+ ```python
170
+ class Path(
171
+ Pathname, Move, Copy, Identity, Create, Symlink, Remove, Listing, Resolve,
172
+ Readlink, Hardlink, Owned, Stat, Chmod, Chown, Utime, BinaryOpen,
173
+ ):
174
+ info
175
+ def is_hidden(self): ...
176
+ def is_junction(self) -> bool: ...
177
+ def is_mount(self) -> bool: ...
178
+ def is_dir_binding(self) -> bool: ...
179
+ def samefile(self, other_path): ...
180
+ def iterdir(self) -> Iterator[Self]: ...
181
+ def glob(
182
+ self, pattern, *, case_sensitive=None, include_hidden=True,
183
+ recursive=None, dironly=None, recurse_symlinks=False, native=True,
184
+ on_error=None, bound_loops=False,
185
+ ): ...
186
+ def rglob(
187
+ self, pattern, *, case_sensitive=None, include_hidden=True,
188
+ recursive=True, dironly=None, recurse_symlinks=False, native=True,
189
+ on_error=None, bound_loops=False,
190
+ ): ...
191
+ def walk(self, top_down=True, on_error=None, follow_symlinks=False): ...
192
+ def touch(self, mode=None, exist_ok=True): ...
193
+ def mkdir(self, mode=0o777, parents=False, exist_ok=False): ...
194
+ def unlink(self, missing_ok=False): ...
195
+ def rmdir(self): ...
196
+ def rm(
197
+ self, recursive=False, missing_ok=False, ignore_error=False, *,
198
+ follow_symlinks=False, follow_binds=False,
199
+ ): ...
200
+ def rename(self, target): ...
201
+ def symlink_to(
202
+ self, target, target_is_directory=False, *, force=False,
203
+ ) -> None: ...
204
+ def readlink(self) -> Self: ...
205
+ def hardlink_to(self, target) -> None: ...
206
+ def absolute(self) -> Self: ...
207
+ def resolve(self, strict=False) -> Self: ...
208
+ def owner(self, *, follow_symlinks=True) -> str: ...
209
+ def group(self, *, follow_symlinks=True) -> str: ...
210
+ def copy(
211
+ self, target, *, overwrite=False, follow_symlinks=True,
212
+ preserve_metadata=True, recursive=False, ignore_error=None,
213
+ progress=None, on_error=None,
214
+ ): ...
215
+ def copy_into(
216
+ self, target_dir, *, overwrite=False, follow_symlinks=True,
217
+ preserve_metadata=True, recursive=False, ignore_error=None,
218
+ progress=None, on_error=None,
219
+ ) -> Path: ...
220
+ def move_into(self, target_dir, *, overwrite=False) -> Path: ...
221
+ def replace(self, target) -> Path: ...
222
+ def move(self, target, *, overwrite=False): ...
223
+ # override hooks
224
+ def _same_filesystem(self, other) -> bool: ...
225
+ def _node_key(self) -> tuple[object, tuple[str, ...]] | None: ...
226
+ def _scandir(self) -> Iterator[Tuple[str, Optional[FileStat]]]: ...
227
+ def _mkdir(self, mode): ...
228
+ def _symlink_to(self, target, target_is_directory=False) -> None: ...
229
+ def _coerce_target(self, target) -> Path: ...
230
+ def _rename_compatible(self, target) -> bool: ...
231
+ def _symlink_target(self, target) -> Self: ...
232
+ ```
233
+
234
+ Base class for I/O paths.
235
+ `Path(*args)` on the bare class constructs a `LocalPath`.
236
+
237
+ - Operation precedence: `Path.__init_subclass__` re-asserts pathapi's
238
+ `copy`, `move`, `copy_into`, `move_into`, `exists`, `rglob`, `read_text`,
239
+ `write_text` and `symlink_to` on any subclass that would otherwise inherit stdlib
240
+ `pathlib`'s. A class mixing a concrete stdlib path with `Path` but not
241
+ `LocalPath` is a local class too: it gets every function `LocalPath`
242
+ defines (`stat`, `chmod`, `is_junction`, `is_mount`, `glob`, `walk`,
243
+ `_scandir`, `_symlink_to`, `_chown`, `is_dir`/`is_file` before 3.13, ...)
244
+ wherever stdlib or a generic default would answer, so `rm(recursive=True)`
245
+ stays out of a junction there as on `LocalPath`. A method defined in the
246
+ subclass itself, or in a mixin of your own, always wins.
247
+ - `is_hidden()` — name starts with `"."`. `__iter__()` is `iterdir()`.
248
+ - Every operation above is defined by a protocol (see "Protocols"): `Path` adds
249
+ only `is_hidden()`, `__new__` and `__init_subclass__`. `readlink()`,
250
+ `hardlink_to()`, `absolute()`, `resolve()`, `owner()` and `group()` are
251
+ defined on every `Path`, and raise `NotImplementedError` until a class supplies
252
+ `readlink()`, `_hardlink_to()`, `_cwd()`, `_user_name()` and `_group_name()`;
253
+ `supports(path, Readlink)` and the like say which it did. `LocalPath` keeps
254
+ pathlib's own.
255
+ - `samefile()` — compares `(st_dev, st_ino)`; `NotImplementedError`
256
+ when `stat()` lacks them (`LocalPath` uses pathlib's).
257
+ - `_same_filesystem()` — override hook: whether `other`'s
258
+ segments are resolved in the same namespace (host, store, tree) as this
259
+ path's. `==` ignores that, so `copy()`, `move()` and `PathSyncer` ask it
260
+ before treating two paths as the same file, nested, or overlapping.
261
+ Called only with `type(other) is type(self)`; must be symmetric and do no
262
+ I/O. Default: both paths share one `_backend` object, or neither has
263
+ one, so equal paths of a type that says nothing ARE the same file. **A
264
+ type whose instances can front different hosts or stores must override
265
+ it**, or a transfer between two of them spelled alike is refused
266
+ (`OSError(EINVAL)` "same file" / `ValueError` "overlap"). `MemPath`:
267
+ same `MemPathBackend` instance.
268
+ - `_node_key()` — override hook for a type
269
+ whose segments spell one node several ways (`MemPath`: `a.txt`,
270
+ `/a.txt`, `/d/../a.txt`; an archive member: the spellings of its
271
+ archive). `namespace` is an object two paths share exactly
272
+ when they resolve names in one tree (compared with `is`); `names` is the
273
+ node's normalized position in it (`()` for the root). `copy()` and
274
+ `move()` compare two paths that both answer it, whatever their classes.
275
+ Default `None`: two paths of one type that `_same_filesystem()` places
276
+ together are compared with `==` and `is_relative_to()`. No I/O.
277
+ - `iterdir()` — **stub** (`NotImplementedError`); a
278
+ listable `Path` must implement it (or `_scandir()`).
279
+ - `_scandir()` — non-following stat
280
+ per entry; default: `iterdir()` + one `stat(follow_symlinks=False)` per
281
+ child. Consumed by `walk()`, `glob()`, `rm(recursive=True)` and
282
+ `PathSyncer`; override it when the listing call already returns metadata.
283
+ `None` means "unknown", never "missing". Never yield a name that is not
284
+ one path component (`providers.is_safe_child_name()`: `""`, `.`, `..`, or one
285
+ containing `/` or NUL): every listing of the package skips such a name, and
286
+ an override must too.
287
+ - `glob()` —
288
+ pathlib semantics: hidden entries included, `**` does not descend into
289
+ directory symlinks unless `recurse_symlinks=True`, a
290
+ trailing `**` selects files too on 3.13+, a missing or non-directory base
291
+ yields nothing, `""` → `ValueError`, an absolute pattern →
292
+ `NonRelativePatternError`. `recursive=None` enables recursion when a
293
+ component is `**`; an explicit value wins. Validates eagerly, selects
294
+ lazily. On a remote scheme a recursive glob lists every directory of the
295
+ subtree, once. A path two `**` reach along several splits is yielded once
296
+ (3.13+ `pathlib` yields it once per split). A literal component is checked
297
+ as the running `pathlib` checks it: before 3.13 one that is not the last
298
+ must be a directory (`a.txt/..` selects nothing), and from 3.12 the last
299
+ is tested without following a link, so a dangling symlink is selected.
300
+ An explicit `case_sensitive` compares literals against the listing too
301
+ and yields the name as stored, and so does 3.12 without it (3.12 lists
302
+ every literal; later versions join it as typed); `.` and `..` stay
303
+ literal. From 3.13 a final literal with a trailing separator is
304
+ tested as `os.path.lexists(name + os.sep)`, so the platform decides: Windows
305
+ selects a dangling directory link, macOS a link to a file (`LocalPath` and
306
+ stdlib paths; `native=False` tests it by following the link). A pattern with
307
+ no component (`.`) is a `ValueError`; `rglob(".")` selects every entry
308
+ below.
309
+ - **`pattern=None`** expands the pattern THIS PATH CARRIES
310
+ (`LocalPath("/etc/*.conf").glob(None)`), splitting at the first
311
+ wildcard — the supported form for a path that is itself a pattern.
312
+ `""` still raises.
313
+ - **`on_error(error)`** is called when a directory cannot be listed,
314
+ the same contract as `walk()`/`os.walk`: raising from it propagates,
315
+ returning treats that directory as empty. Without it the listing is
316
+ skipped in silence (pathlib's behaviour), so a caller could not tell
317
+ an unreadable directory from an absent one. A base that is missing or
318
+ not a directory is reported (once) whatever the first component is.
319
+ `error.filename` names the directory even when the backend left it
320
+ unset.
321
+ - **`bound_loops=True`** skips a directory whose `(st_dev, st_ino)` is
322
+ already on the CURRENT DESCENT PATH — a directory reachable below
323
+ itself, which is what a loop is. It bounds a Windows junction loop,
324
+ which `recurse_symlinks=False` cannot (a junction reports
325
+ `is_symlink() == False`) and which `pathlib` itself walks until the
326
+ recursion limit. One directory deliberately reachable under two
327
+ SIBLING names (a shared layer junctioned in twice) is not a loop and
328
+ both names expand — the rule is the ancestor chain, not everything
329
+ seen, the same line `find -L` draws. A backend whose stat carries no
330
+ identity (`MemPath`, most remote backends, a `st_ino` of 0 or `None`) is
331
+ walked unbounded.
332
+ - **`recurse_symlinks=True`** lets `**` descend through symlinks to
333
+ directories, as pathlib 3.13 does (the keyword is accepted on every
334
+ version). A link to a directory the descent is already inside
335
+ (`(st_dev, st_ino)` equal) is an entry and is not entered, and where the
336
+ stat carries no identity no more than 40 links are followed in one
337
+ descent, so a loop ends where pathlib follows it until the system refuses
338
+ it; those repeated entries are the only difference. A backend without
339
+ symlinks answers as before.
340
+ - **`native=True`** (default) follows the running interpreter on the three
341
+ rules pathlib changed mid-series: a trailing `/` is ignored before 3.11
342
+ and selects directories only from 3.11; `a**` raises `ValueError`
343
+ before 3.13 and is a plain wildcard from 3.13; a trailing `**` selects
344
+ directories only before 3.13 and files too from 3.13. `native=False`
345
+ applies one rule on every version (trailing `/` → directories only,
346
+ `a**` → a plain wildcard, trailing `**` → files too), so a pattern
347
+ answers the same on every interpreter and backend;
348
+ `pathapi.testing`'s contract suite uses it.
349
+ - `rglob()` — `glob(f"**/{pattern}", recursive=True)`.
350
+ `pattern=None` is `glob(None)`. `LocalPath` raises the audit events
351
+ `pathlib.Path.glob` and `pathlib.Path.rglob` with the arguments `pathlib`
352
+ passes on the running version (3.13+ also raises `glob` for the call
353
+ `rglob` stands for); `pattern=None` raises none.
354
+ - `walk()` — drives
355
+ `_scandir()`; its stats are trusted only with `follow_symlinks=False`.
356
+ Without `follow_symlinks` a symlink to a directory and a Windows junction
357
+ (`is_junction()`) are listed in `filenames` and not entered, as
358
+ `pathlib.Path.walk()` does; with it they are entered, and nothing protects
359
+ the walk from a loop (`LocalPath` included: its walk is this one).
360
+ A listed name that is not one path component (`providers.is_safe_child_name()`)
361
+ is left out of `dirnames`/`filenames`; `on_error`, when given, is called
362
+ with a `ValueError` for it (`error.filename` is the directory).
363
+ - `touch()` — `FileExistsError` when `exist_ok=False`
364
+ and the path exists; never truncates; creates with `open("x")` (falls
365
+ back to `"w"` when `x` is unsupported); `chmod(mode)` only when `mode` is
366
+ passed (unmasked), and a class that cannot chmod (`NotImplementedError`)
367
+ raises it and removes the file it created, so a mode asked for is never
368
+ dropped; an existing file is left as it is, mode included, as `pathlib`
369
+ does; sets an existing file's access and modification times
370
+ to now through `_utime()`, and leaves them as they are where the class
371
+ cannot set one (`NotImplementedError`). `LocalPath` uses pathlib's
372
+ `touch()`.
373
+ - `_mkdir()` (stub) / `mkdir()`.
374
+ - `unlink()`, `rmdir()` — stubs.
375
+ - `rm()` — extension.
376
+ `ignore_error` is a bool or `callable(error, path) -> bool` (True
377
+ swallows); each error is offered once, and a predicate that raises ends
378
+ the call with its own exception, which is not offered to it again. Recursive removal is bottom-up and
379
+ never descends through a directory symlink or a binding (a Windows
380
+ junction, a mount point — `is_dir_binding()`): the entry itself is
381
+ removed, never what is behind it, which is what `rm -r` does.
382
+ `follow_symlinks=` (symlinks) and `follow_binds=` (bindings) choose per
383
+ call: `False` (default, remove the entry), `True` (remove the contents
384
+ behind it too, then the entry: a followed symlink is unlinked), `None`
385
+ (leave it in place — the enclosing directory is then not empty and says
386
+ so), or a callable `policy(path) -> bool | None` asked per entry, so one
387
+ tree can keep one mount and follow another. Path components before the
388
+ final one are followed as usual; the path `rm()` is called on is decided by the same
389
+ policy as one met on the way down (without `recursive` a symlink is only
390
+ unlinked). A policy that is not `True`/`False`/`None`/a callable, or a
391
+ callable's answer that is not one of the three, raises `ValueError`
392
+ before anything is removed and is never offered to `ignore_error`. A class
393
+ that overrides `rm()` takes and checks both keywords; one whose backend holds
394
+ no symlinks or bindings decides nothing with them, and one with a native
395
+ recursive removal uses it only for the default `follow_symlinks=False`
396
+ and runs this walk otherwise. A listed name that is not one path component
397
+ (`providers.is_safe_child_name()` with `windows=False`: the names come from
398
+ the directory's own listing) is never joined onto it: it raises
399
+ `ValueError`, offered to `ignore_error` as `(error, directory)` once per
400
+ entry, and nothing is removed for it.
401
+ - `rename()` — stub. Implementations return the new path.
402
+ - `_symlink_to()` (stub; receives a path
403
+ object) / `symlink_to()`
404
+ — `force=True` unlinks an existing non-directory entry first (not atomic;
405
+ never removes a directory). A `str` target goes through the overridable
406
+ `_symlink_target()` and is stored verbatim; relative stays relative:
407
+ `LocalPath` hands the text on untouched (`./t/` stays `./t/`, as
408
+ `pathlib` stores it) except on Windows, which cannot resolve a relative
409
+ target spelled with `/`, so the path class rewrites it there; a class for
410
+ URI references must not parse it as one, and any other class normalizes it
411
+ with `with_segments()`, which keeps per-instance state. Implemented by
412
+ `LocalPath`, and by classes of other distributions whose backend has links.
413
+ - `copy()`
414
+ - A file copy calls `stat()` once on the source (type, mode, size and
415
+ identity) and once on the target.
416
+ - Existing target: `FileExistsError` unless `overwrite=True`; a directory
417
+ target of a file copy → `IsADirectoryError`; a directory source needs
418
+ `recursive=True`; copying into its own subtree → `OSError(EINVAL)`; onto
419
+ the same file (or a case-insensitive alias) → `OSError(EINVAL)`. Two
420
+ files of this machine (`LocalPath`, a subclass, `pathlib.Path`, a
421
+ `file:` URI) are compared by identity whatever their classes, a
422
+ directory reached through a symlink included. A type that answers
423
+ `_node_key()` is compared by that, across classes. Otherwise "same" is
424
+ `samefile()` where `stat()` carries `st_dev`/`st_ino`, else equal paths
425
+ of one type that `_same_filesystem()` places together.
426
+ - The source is opened before the target is touched; a failed stream
427
+ removes the partial target. `overwrite=True` removes an existing file
428
+ target with `unlink(missing_ok=True)` just before writing: a backend
429
+ whose write replaces the file and which cannot delete implements
430
+ `unlink()` so that `missing_ok=True` returns without removing.
431
+ - A recursive copy runs inside `target._write_batch()`: onto a zip
432
+ archive it is one rewrite of the file, and a copy that raises leaves
433
+ the archive as it was.
434
+ - A recursive copy refuses any child name that would not stay inside
435
+ `target` — `..`, and when the target reads names with Windows rules
436
+ (`providers.is_windows_flavoured()`) `\`, `:`, a trailing dot or space, and
437
+ the reserved device names (`CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`,
438
+ `LPT1`-`LPT9`, with or without an extension). The names come from a
439
+ listing the destination does not control (an archive, a remote index,
440
+ an object-store key); on a Windows target `"C:x"` joins to a
441
+ drive-relative path outside it and `"report."` is stored as `"report"`.
442
+ Raised as `ValueError` through `ignore_error`, per child, like any other
443
+ child failure.
444
+ - `follow_symlinks=False` on a symlink recreates the link
445
+ (`NotImplementedError` if either side cannot); only the target text is
446
+ copied, not the link's own mode, times or owner. A dangling symlink at
447
+ the target is written through, as `shutil.copyfile` does.
448
+ - `preserve_metadata=True` copies permission bits only, and only a mode the
449
+ source backend really reported (`FileStat.mode_known`): a file's, and a
450
+ directory's after its children are copied, so a read-only directory
451
+ still receives them. Between two different classes the setuid, setgid
452
+ and sticky bits are dropped, so a mode read from a tar member or a
453
+ remote server never makes a privileged file here.
454
+ - `ignore_error`: `True` suppresses child errors of a recursive copy,
455
+ `False`/`None` raise; a callable is called as `ignore_error(error)` and
456
+ the error is **always** suppressed (its return value is ignored — not
457
+ `rm()`'s `(error, path)` predicate).
458
+ - `on_error`: `rm()`'s predicate for a recursive copy, `(error, path) ->
459
+ bool` (or a bool), asked once per child error with `path` the source
460
+ entry that failed. `True` skips that entry and the copy goes on, `False`
461
+ raises the error, and a predicate that raises ends the copy with its own
462
+ exception. An error it declined, or raised, is not offered again by the
463
+ directories above (not even for a nested `copy()`). Errors outside the
464
+ children — the root, the target directory, a refused prefix of an object
465
+ store — are raised and never offered. Passing `ignore_error` and
466
+ `on_error` together raises `TypeError` before anything is copied.
467
+ Every `copy()` override takes it and passes it on only when it was given;
468
+ a native walk may call it from a worker thread, and children already
469
+ running when one is declined are each offered their own error before the
470
+ copy stops.
471
+ - `progress(path, bytes_copied, total_size | None)` per chunk of each file
472
+ streamed; not called by a native transfer that bypasses the streaming copy.
473
+ - A `str` target goes through `_coerce_target()`: `with_segments()` by
474
+ default, a URI parse on a class for URI references (so a `str` can name
475
+ another scheme).
476
+ - `copy_into()` — `copy(target_dir / self.name, ...)`: the same
477
+ keywords and defaults as `copy()`, and the new path is returned (pathlib
478
+ 3.14 returns it too). A `str` directory is a path on this backend, as for
479
+ `copy()`. `ValueError` for a path with no name. Present on every backend
480
+ and version; on 3.14 it replaces stdlib's, which overwrote and copied
481
+ trees by default.
482
+ - `move_into()` — `move(target_dir /
483
+ self.name, overwrite=overwrite)`, returning the new path.
484
+ - `replace()` — `move(target, overwrite=True)`, returning
485
+ `target` as a path. A file replaces a file and a directory an empty
486
+ directory; a directory that holds anything raises `OSError(ENOTEMPTY)`
487
+ and a file onto a directory `IsADirectoryError`, both before anything is
488
+ removed. `LocalPath` keeps stdlib's `os.replace()`.
489
+ - `move()` — validates first (missing source →
490
+ `FileNotFoundError`, file onto directory → `IsADirectoryError`, existing
491
+ target without `overwrite` → `FileExistsError`; a same-file spelling is
492
+ renamed in place), and refuses before it removes anything a target that
493
+ holds the source (`OSError(ENOTEMPTY)`), lies inside it
494
+ (`OSError(EINVAL)`) or is a link to the same file (`OSError(EINVAL)`).
495
+ Sameness is decided as for `copy()`. Two hard links of one file are two
496
+ names: an existing target needs `overwrite=True`, and the source name is
497
+ then removed, so the target name keeps the content. Tries `rename()`
498
+ when `_rename_compatible(target)` (default: only onto a path of the very
499
+ same class; a class for URI references leaves it to `rename()`, which raises
500
+ `NotImplementedError` for a target it cannot reach; `LocalPath` requires a
501
+ local target), falling back to `copy(recursive=True)` + `rm`/`unlink` on
502
+ `NotImplementedError` or `OSError(EXDEV)`. The fallback recreates a
503
+ symlink, and the links inside a moved tree, at a target whose class
504
+ implements `_symlink_to()`, so the result does not depend on whether
505
+ `rename()` was available; any other target receives what the links point
506
+ at. `overwrite=True` replaces a
507
+ local file atomically (`replace()`); elsewhere the target is unlinked just
508
+ before the rename. Returns `rename()`'s result (`None` on the fallback).
509
+
510
+ ### `FsPathLike`, `PathLike`, `PurePathLike`
511
+
512
+ ```python
513
+ class FsPathLike(Protocol):
514
+ def __fspath__(self) -> str: ...
515
+ ```
516
+
517
+ `Protocol` with `__fspath__()`, for annotations. A class qualifies by defining
518
+ `__fspath__` itself, which is also how `os.PathLike` recognises it:
519
+ `LocalPath`, `PosixPathname` and `WindowsPathname` do (from `pathlib.PurePath`),
520
+ and `Pathname`, `MemPath`, `FTPPath` and the archive paths do not, so
521
+ `isinstance(path, os.PathLike)` is False for them and `os.fspath(path)` is a
522
+ `TypeError`. A class of your own that has a local name defines `__fspath__`
523
+ and is then an `os.PathLike`.
524
+ **`PathLike`** = `str | Path`; **`PurePathLike`** = `str | Pathname`.
525
+
526
+ ### For path implementers (`pathapi.path`, `pathapi.providers`, `pathapi.archive`)
527
+
528
+ The names a class built on `Path` overrides or calls beyond the stubs above,
529
+ in one place: the classes of the package use all of them, so they are contract. The
530
+ helpers are imported from `pathapi.providers`, except `detect_format`,
531
+ which is in `pathapi.archive`.
532
+
533
+ ```python
534
+ class Pathname:
535
+ def _anchor_segments(self) -> tuple[str, ...]: ...
536
+
537
+ class Path(Pathname, Move, Copy, Identity, Create, Symlink, Remove, Listing,
538
+ Resolve, Readlink, Hardlink, Owned, Stat, Chmod, Chown, Utime, BinaryOpen):
539
+ def _scandir(self) -> Iterator[Tuple[str, Optional[FileStat]]]: ...
540
+ def _same_filesystem(self, other) -> bool: ...
541
+ def _coerce_target(self, target) -> Path: ...
542
+ def _open(self, mode="r", buffering=-1) -> IOBase: ...
543
+ def _chown(self, uid, gid, *, follow_symlinks=True) -> None: ...
544
+ def _utime(self, *, atime=None, mtime=None) -> None: ...
545
+ def _copy_from(
546
+ self, source, follow_symlinks=True, preserve_metadata=False,
547
+ ) -> None: ...
548
+ def _write_batch(self) -> ContextManager[object]: ...
549
+
550
+ def check_follow(name, policy) -> None: ...
551
+ def contains(src, target) -> bool: ...
552
+ def copy_error_predicate(ignore_error, on_error) -> OfferedOnce | None: ...
553
+ def detect_format(name, peek=None) -> str: ...
554
+
555
+ class DiscardOnError:
556
+ def discard(self) -> None: ...
557
+ def _holds_uncommitted(self) -> bool: ...
558
+
559
+ class CommitOnClose(DiscardOnError, BytesIO):
560
+ def __init__(self, commit, initial=None): ...
561
+ def discard(self) -> None: ...
562
+ ```
563
+
564
+ - Primitives, by protocol ("Protocols" has the table): `stat`; `chmod`;
565
+ `_chown`; `_user_name`, `_group_name` (for `owner()`/`group()`); `_utime`;
566
+ `_mkdir`; `_open`; `iterdir`; `unlink`, `rmdir`; `readlink`; `_symlink_to`;
567
+ `_hardlink_to`; `_cwd`; `rename`; `checksum`. Implement what the backend can
568
+ and leave the rest: `pathapi.protocols.supports(path, Protocol)` then
569
+ tells a caller, without a call that raises, which protocols the class has.
570
+ - `_supports(protocol)` — optional: return `True` or `False` for a protocol
571
+ whose answer depends on the connection (a server that lacks an extension),
572
+ `None` to leave it to the class. `supports()` asks it first for an instance.
573
+ It may make at most one request per backend, cached for the backend's
574
+ lifetime, and it never raises: when the request fails it returns `None`.
575
+ - `_anchor_segments()` — the leading `segments` that anchor the path instead
576
+ of naming a directory (`("",)` for a rooted path, else `()`); `parents`
577
+ stops there. Override it when the root is spelled differently (a class for
578
+ `file:` URIs anchors at a Windows drive or share).
579
+ - `info` — an object with the methods of `pathlib.types.PathInfo`
580
+ (Python 3.14): `exists()`, `is_dir()`, `is_file()` (each with
581
+ `follow_symlinks=True`) and `is_symlink()`. A stat that fails with
582
+ `OSError`/`ValueError` reads as "no such entry". It calls `stat()` at most
583
+ once following links and once not (an entry that is missing or not a link
584
+ shares its non-following stat), and remembers the answers on the info object
585
+ alone: every `path.info` is a fresh question, since a path holds no state.
586
+ `LocalPath` has pathlib's own on 3.14 and this one before.
587
+ - `_copy_from(source, follow_symlinks=True, preserve_metadata=False)` — the
588
+ hook Python 3.14's `pathlib.Path.copy(target)` calls on a target that is
589
+ not a stdlib path (after `target.with_segments`, `target.parents` and `==`
590
+ have said it is not the source): `pathlib.Path(f).copy(MemPath("/x"))`
591
+ works on 3.14, and returns `target.joinpath()`. It copies `source` (anything
592
+ with `info`, `iterdir()`, `readlink()`, `open()` and `stat()`) as 3.14 does:
593
+ an existing file is overwritten, an existing directory raises, children are
594
+ copied one by one, `follow_symlinks=False` makes a link through
595
+ `symlink_to()`, and `preserve_metadata` copies the permission bits and the
596
+ modification time where the class can set them (`chmod()`, `_utime()`).
597
+ `Path.copy()` of this library does not call it. A class that mixes a
598
+ stdlib path keeps stdlib's.
599
+ - `_write_batch()` — a context manager that `copy(recursive=True)` enters
600
+ around the writes it makes below its target and `PathSyncer.sync()`
601
+ around a whole sync of its root target (never for a dry run). Override it
602
+ in a class whose every write is expensive, to hold them and commit them
603
+ together when the block ends (`ArchiveUri` returns its `batch()`): entries
604
+ nest and must join, and an exception must undo what was held. It must
605
+ read nothing and raise nothing; the default does nothing.
606
+ - `_scandir()`, `_same_filesystem()`, `_coerce_target()` — see `Path`.
607
+ `_open()`, `_chown()` and `_utime()` — see `BinaryOpen` and `Chmod`.
608
+ - `check_follow(name, policy)` — `ValueError` naming `name` unless `policy` is
609
+ `True`, `False`, `None` or a callable. An `rm()` override calls it for
610
+ `follow_symlinks` and `follow_binds` before it removes anything.
611
+ - `contains(src, target)` — whether `target` is `src` or lies inside it,
612
+ looking through symlinks and deciding as `copy()` does (`_node_key()`,
613
+ else `_same_filesystem()` and `==`). A `copy()` override that copies a
614
+ tree natively refuses a target inside the source with it.
615
+ - `copy_error_predicate(ignore_error, on_error)` — `None` when `on_error` was
616
+ not given, else the `OfferedOnce` predicate to ask about each failing child;
617
+ `TypeError` when `ignore_error` was given too. A `copy()` override calls it
618
+ first, passes `on_error=` on to `super().copy()` and to its children only
619
+ when it is not `None`, and offers the source child that failed.
620
+ - `detect_format(name, peek=None)` — `"zip"` or `"tar"` from the extension of
621
+ `name`, else from the first bytes `peek()` returns (called only then).
622
+ - `CommitOnClose(commit, initial=None)` — an in-memory `BytesIO` that calls
623
+ `commit(buffer)` once at `close()`, before the buffer is closed: always for
624
+ a new file, only after a write for one opened with `initial` content. Return
625
+ it from `_open()` for a backend that cannot write a file piecewise. It
626
+ inherits `DiscardOnError`; its `discard()` drops the buffer so that a later
627
+ `close()` commits nothing. A buffer garbage collected unclosed while it
628
+ holds something to commit is discarded and emits a `ResourceWarning`: close
629
+ the file to commit it.
630
+ - `DiscardOnError` — mixin for a write stream that commits when it is closed
631
+ (an upload, a rewrite of an archive), so that a write which failed commits
632
+ nothing. Its `__exit__` calls `discard()` when the `with` block over the
633
+ stream ended in an exception (any `BaseException`) and then closes; a block
634
+ that ended normally just closes, and `close()` outside a `with` block
635
+ commits, as before. The subclass defines `discard()` (the mixin's raises
636
+ `NotImplementedError`): drop what was written so that `close()` commits
637
+ nothing, and do nothing more when called twice or after `close()`. Put the
638
+ mixin first in the bases (`class Upload(DiscardOnError, io.BytesIO)`), so
639
+ that its `__exit__` wins over `io.IOBase`'s. The target is then left as it
640
+ was, and is not created if it was absent. A stream garbage collected
641
+ unclosed is discarded the same way, with a `ResourceWarning`, when
642
+ `_holds_uncommitted()` (true by default) says closing would commit something;
643
+ override it to return false for a stream that was never modified.
644
+ - A stream returned by `_open()` — in binary mode `open()` returns it
645
+ untouched, so its own `__exit__` is the mechanism: a stream that defines
646
+ `discard()` without inheriting `DiscardOnError` (or doing the same in its own
647
+ `__exit__`) is not discarded there. In text mode the wrapper `open()` builds
648
+ calls the stream's `discard()` whenever it has a callable one, mixin or not,
649
+ and drops the text it still holds instead of writing it to the stream; it
650
+ also discards when its final flush fails or when it cannot be built (an
651
+ unknown `encoding=`). A stream without `discard()` is used as it is, which is
652
+ right for one that writes through as it goes (`LocalPath`, `MemPath`, an SFTP
653
+ path): a failed block leaves what it had written.
654
+
655
+ ## Local filesystem (`pathapi.fspath`)
656
+
657
+ ### `LocalPath`
658
+
659
+ ```python
660
+ class LocalPath(pathlib.Path, Path):
661
+ def stat(self, *, follow_symlinks=True): ...
662
+ def chmod(self, mode, *, follow_symlinks=True): ...
663
+ def is_dir(self, *, follow_symlinks=True): ...
664
+ def is_file(self, *, follow_symlinks=True): ...
665
+ def exists(self, *, follow_symlinks=True): ...
666
+ def read_text(self, encoding=None, errors=None, newline=None) -> str: ...
667
+ def write_text(self, data, encoding=None, errors=None, newline=None): ...
668
+ def _scandir(self): ...
669
+ def _chown(self, uid, gid, *, follow_symlinks=True) -> None: ...
670
+ def _utime(self, *, atime=None, mtime=None) -> None: ...
671
+ # Path's signatures: glob, rglob, walk, symlink_to, _symlink_to, copy,
672
+ # copy_into, move, move_into, rm
673
+ ```
674
+
675
+ `pathlib.WindowsPath`/`PosixPath` (by `os.name`) with
676
+ `Path` mixed in; stdlib behavior except where overridden: `_scandir()`
677
+ (tuples from `os.scandir` lstat), `walk()`, `glob()`,
678
+ `stat()`/`chmod()` (`follow_symlinks=` on 3.9; `chmod` accepts octal
679
+ strings), `is_dir()`/`is_file()` (`follow_symlinks=` before 3.13),
680
+ `_symlink_to()`, `_chown()` (`shutil.chown`; `NotImplementedError` where
681
+ `os.chown` is missing, i.e. Windows), `_utime()` (`os.utime`), plus pathapi's `copy`, `move`,
682
+ `copy_into`, `move_into`, `exists`, `rglob`, `read_text`, `write_text` and
683
+ `symlink_to`.
684
+ `exists()` returns `False` for any `OSError`/`ValueError` on every Python
685
+ version; `is_dir()`, `is_file()`, `is_fifo()`, `is_socket()`,
686
+ `is_block_device()` and `is_char_device()` are stdlib's: before 3.13 they
687
+ raise for an error other than ENOENT, ENOTDIR, EBADF or ELOOP (a
688
+ `PermissionError` from `stat()`, say), from 3.13 they return `False`. A
689
+ stdlib `pathlib.Path` is not a `pathapi.Path`, and `MemPath` is not a
690
+ stdlib path.
691
+
692
+ ### `PosixPathname`, `WindowsPathname`
693
+
694
+ ```python
695
+ class PosixPathname(PurePosixPath, Pathname): ...
696
+ class WindowsPathname(PureWindowsPath, Pathname): ...
697
+ ```
698
+
699
+ pure classes over
700
+ `PurePosixPath`/`PureWindowsPath` implementing `Pathname`.
701
+
702
+ ## In-memory filesystem (`pathapi.mempath`)
703
+
704
+ ### `MemPath`
705
+
706
+ ```python
707
+ class MemPath(Path):
708
+ def __init__(self, *segments, backend=None): ...
709
+ def relative_to(self, other, *, walk_up=False) -> Self: ...
710
+ backend
711
+ normalized
712
+
713
+ class MemBytesIO(BytesIO):
714
+ def __init__(self, dest, *, append=False): ...
715
+ ```
716
+
717
+ `Path` over nested dicts; the
718
+ reference `Path` subclass. Segments may be `str`, `Pathname` or `MemPath`
719
+ (the first `MemPath` argument names the backend, as the left operand does
720
+ for `/` and `joinpath()`, and an explicit `backend=` wins over it; another
721
+ `Path` → `NotImplementedError`). Joined and normalized like `PurePosixPath`.
722
+ An unknown keyword → `TypeError`.
723
+
724
+ - `backend` (a `MemPathBackend`); `normalized` is the list of names with `..`
725
+ applied to the text and clamped at the root (`[""]` for the root);
726
+ `segments` is a tuple (`("", "a")` for `/a`); `parts` is `(segments, backend)`; `as_uri()` →
727
+ `mempath:<quoted posix path>` (no `mempath:` scheme is registered; build
728
+ `MemPath` directly).
729
+ - `..` is applied to the tree, as on a POSIX filesystem: the directory it
730
+ leaves must exist (`/missing/../a` → `FileNotFoundError`, `/f/../a` with
731
+ `f` a file → `NotADirectoryError`), and above the root it stays at the
732
+ root.
733
+ - `stat()` → `FileStat` with `st_size` and `st_mtime` (time of the last
734
+ write); the mode is a placeholder (`mode_known=False`).
735
+ - `open()` supports `r`, `w`, `x`, `a` (binary or text); `+` modes →
736
+ `NotImplementedError`. A binary handle is a `MemBytesIO`, a `BytesIO` that
737
+ writes its buffer back into the file's `bytearray` (`dest`) on `flush()` and
738
+ close; with `append=True` every write lands at the end. Writes are visible after `flush()`/`close()`; two
739
+ `a` handles on one file both land (each adds only what it wrote). A `with`
740
+ block that raised leaves what it had written, as a local file does.
741
+ - `rmdir()` of the root → `OSError(EBUSY)`, so `root.rm(recursive=True)`
742
+ empties the tree and then raises that.
743
+ - `_utime()` sets a file's modification time (the access time is not
744
+ kept); a directory has none (`NotImplementedError`), so `touch()` leaves
745
+ one alone.
746
+ - `relative_to(other, *, walk_up=False)` takes a `MemPath` of the same
747
+ backend or a `str` and returns the remainder as a relative `MemPath` on that
748
+ backend, comparing the names as written (no `..` applied), as `pathlib` does.
749
+ `ValueError` when `self` is not below `other`, when only one of them is
750
+ rooted, or when the backends differ. With `walk_up=True` (on every
751
+ interpreter) a path beside `other` gets a `..` per level climbed; a `..` in
752
+ `other` cannot be walked (`ValueError`).
753
+ - Not implemented (`NotImplementedError`): `rename()` (`move()` copies),
754
+ `chmod()`, `symlink_to()`.
755
+ - A `str` destination to `copy()`/`move()` stays on the same backend.
756
+
757
+ ### `MemPathBackend`
758
+
759
+ ```python
760
+ class MemPathBackend(dict): ...
761
+ ```
762
+
763
+ Storage: `dict` value = directory,
764
+ `bytearray` (`MemFile`, carrying `mtime`, which `copy`, `deepcopy` and
765
+ `pickle` keep on every version) = file. Pass one instance as `backend=` to
766
+ share a tree; each root `MemPath()` otherwise gets its own.
767
+
768
+ ## FTP (`pathapi.ftp`)
769
+
770
+ Standard library only (`ftplib`, `ssl`); needs no extra and no URI.
771
+ `pathapi.FTPPath` is the same class, imported on first use.
772
+
773
+ ### `FTPPath`
774
+
775
+ ```python
776
+ class FTPPath(Path):
777
+ backend
778
+ def __init__(
779
+ self, *segments, backend=None, host=None, port=21, user=None,
780
+ password=None, tls=False, timeout=30.0, ssl_context=None, verify=True,
781
+ ) -> None: ...
782
+ ```
783
+
784
+ A `Path` for a directory tree on an FTP server. Nothing connects when a path
785
+ is built.
786
+
787
+ - **Construction.** `FTPPath("/pub/x", host="h", user="u", tls=True)` builds
788
+ the path's own `FTPBackend` from the endpoint keywords (the same as
789
+ `FTPBackend`'s). `backend=` instead shares one connection cache between
790
+ paths built separately, or plugs in a `BaseFTPBackend` of your own. Giving
791
+ `backend=` together with any endpoint keyword is a `TypeError`, and so is
792
+ giving neither (unless a segment is an `FTPPath`, whose backend is kept).
793
+ Segments are `str`, `FTPPath` or a pure path; an absolute segment restarts
794
+ the join, empty and `.` segments collapse. Every path derived from one
795
+ (`/`, `joinpath`, `parent`, `with_name`, `iterdir`, `relative_to`) keeps its
796
+ backend.
797
+ - **Names.** The tree has no working directory: `pub/x` and `/pub/x` name one
798
+ file, read from the server root, and `/` is sent for the root (never an
799
+ empty argument, which a server reads as its working directory). `..` is
800
+ sent as written.
801
+ - **Text forms.** `str()` is the path alone. `repr()` names the endpoint
802
+ (`FTPPath('/pub/x', host='h', port=21)`, plus `user=` and `tls=True` when
803
+ set) and never a password. `as_uri()` is `ftp://[user@]host[:port]/path`
804
+ (`ftps:` with `tls`), without a password; `NotImplementedError` on a
805
+ backend that is not an `FTPBackend`.
806
+ - **Identity.** `==` compares the names only (as `MemPath` does); two paths are
807
+ one tree (`copy()`, `move()` and `PathSyncer` ask) when they share a backend
808
+ object or their backends have equal `endpoint`s. `_node_key()` follows the
809
+ same rule and applies `..` to the text. A pickle holds the text and the
810
+ backend, which carries no connection and a password only if it was given
811
+ one; `copy.copy` and `copy.deepcopy` share the backend.
812
+ - **Behavior.** Everything under "What a path does" below.
813
+
814
+ ### `FTPBackend`, `BaseFTPBackend`
815
+
816
+ ```python
817
+ DEFAULT_PORT = 21
818
+ DEFAULT_TIMEOUT = 30.0
819
+ IDLE_PROBE_SECONDS = 1.0
820
+
821
+ class FTPEndpoint(NamedTuple):
822
+ host: str
823
+ port: int
824
+ user: str | None
825
+ tls: bool
826
+
827
+ class BaseFTPBackend:
828
+ endpoint
829
+ def client(self) -> ftplib.FTP: ...
830
+ def close(self) -> None: ...
831
+ def __enter__(self) -> Self: ...
832
+ def __exit__(self, *exc_info) -> None: ...
833
+
834
+ class FTPBackend(BaseFTPBackend):
835
+ host
836
+ port
837
+ user
838
+ password
839
+ tls
840
+ timeout
841
+ ssl_context
842
+ verify
843
+ def __init__(
844
+ self, host, port=21, *, user=None, password=None, tls=False,
845
+ timeout=30.0, ssl_context=None, verify=True,
846
+ ) -> None: ...
847
+ ```
848
+
849
+ - `FTPBackend` is the connections to one server and account. A thread opens
850
+ its control connection on first use and keeps it, so paths that share a
851
+ backend share a connection per thread; connections are cached per (backend,
852
+ endpoint, thread) in an LRU of 128, closed on eviction and when their thread
853
+ ends. A connection that carried a command within the last
854
+ `IDLE_PROBE_SECONDS` is used as it is; one idle for longer, or not yet used,
855
+ is probed with `NOOP` and replaced when dead.
856
+ - `port` defaults to 21; `tls=True` is explicit FTPS on that same port (port
857
+ 990 is never assumed) with `PROT P`. The certificate and host name are
858
+ verified with `ssl.create_default_context()` before `USER`/`PASS` are sent;
859
+ `ssl_context` (e.g. `ssl.create_default_context(cafile=...)`) wins over
860
+ `verify`; `verify=False` accepts any certificate. Data connections reuse the
861
+ TLS session. No `user` logs in as `anonymous`. `timeout` bounds connect,
862
+ replies and transfers (`None` = forever).
863
+ - `close()` closes every control connection the backend holds, whichever
864
+ thread opened it; one a command is running on closes when that command
865
+ ends. It is harmless twice and before any use, and a later use connects
866
+ afresh. Leaving a `with` block calls it. Both belong to `BaseFTPBackend`,
867
+ so a backend of your own is closed the same way. A `ValueError` for an
868
+ empty `host`.
869
+ - `BaseFTPBackend.client()` is what to override to supply connections: a new
870
+ connected, logged-in `ftplib.FTP`. `endpoint`, when set, is a hashable value
871
+ naming the server and account (`FTPBackend`'s is an `FTPEndpoint`, which holds
872
+ no password); without one only the same backend object is the same tree.
873
+
874
+ ### What a path does
875
+
876
+ - A listing uses `MLSD` (UTC `modify`; mode from `unix.mode` or the `perm`
877
+ fact); `stat()` uses `MLST` of the entry itself where the server's `FEAT`
878
+ lists it (one reply on the control connection, `FEAT` asked once per
879
+ connection), else the parent's `MLSD`; a server without `MLSD` falls back
880
+ to `NLST` for a listing and `SIZE`/`CWD` for a stat. A `size` fact that is
881
+ not a plain non-negative number is unknown (`st_size` 0). A listing is
882
+ read whole inside the guarded call: a transient `4xx` reply is
883
+ `OSError(EAGAIN)`, a name the client cannot decode is `OSError(EILSEQ)`
884
+ for that directory only, and a listing that dies mid-transfer drops the
885
+ connection, so the next request reconnects. `stat()` of another entry in
886
+ a directory that holds an undecodable name is answered by `MLST`, or on a
887
+ server with `MLSD` and no `MLST` by `SIZE`/`CWD` (after a reconnect).
888
+ - Reads download the whole file into memory; `"w"`/`"x"`/`"a"` (`APPE`)/
889
+ `"r+"` buffer in memory and upload on close (`"x"` checks then writes). A
890
+ `with` block that raised uploads nothing: the file is left as it was, or not
891
+ created.
892
+ - `_utime()` sends `MFMT` (the modification time, UTC, whole seconds; the
893
+ access time cannot be set) when the server's `FEAT` lists it (asked once
894
+ per connection), else raises `NotImplementedError` without sending it.
895
+ - `rename()` on the same server (a `str` target is a sibling name; a path of
896
+ another server is `NotImplementedError`, so `move()` copies). Whether an
897
+ existing target is replaced is the server's: a POSIX one replaces it, a
898
+ Windows one answers `550 File exists`, which is `FileExistsError` when the
899
+ target is there (a reply that names a permission problem stays
900
+ `PermissionError`). `chmod()` via `SITE CHMOD` (`NotImplementedError`,
901
+ chained to the server's reply, when the server lacks it or refuses it, or
902
+ `follow_symlinks=False`).
903
+ - Errors are the `OSError` family: a refused login is `PermissionError`, a
904
+ connection that cannot be made or drops is `ConnectionError`.
905
+
906
+ ## Archives (`pathapi.archive`)
907
+
908
+ Standard library only (`zipfile`, `tarfile`); needs no extra and no URI.
909
+ `pathapi.ArchivePath`, `ZipPath` and `TarPath` are the same classes,
910
+ imported on first use.
911
+
912
+ ### `ArchivePath`, `ZipPath`, `TarPath`
913
+
914
+ ```python
915
+ MEMBER_SPOOL_BYTES = 16777216
916
+ MAX_LINKS = 40
917
+
918
+ class ArchivePath(Path):
919
+ archive
920
+ backend
921
+ def __init__(self, *segments, archive=None) -> None: ...
922
+ @classmethod
923
+ def backend_for(cls, archive, *, shared=True, identity=None): ...
924
+ def refresh(self) -> None: ...
925
+ def batch(self) -> ContextManager[None]: ...
926
+ def readlink(self) -> LinkTarget: ...
927
+ def _rm_tree(self, on_error, follows=False) -> bool: ...
928
+ class ZipPath(ArchivePath): ...
929
+ class TarPath(ArchivePath): ...
930
+ class LinkTarget(PurePosixPath):
931
+ path: str
932
+ ```
933
+
934
+ The members of an archive file. The segments are the member path; `archive=`
935
+ is the archive file as a `Path`, or a `str` or other `os.PathLike` (a
936
+ `LocalPath`). `ZipPath` and `TarPath` fix the format, `ArchivePath` reads it
937
+ from the extension (`.zip`/`.jar` vs `.tar`/`.tgz`/`.tar.*`), else from the
938
+ first bytes (a `PK` magic sniff) on first use.
939
+
940
+ - **Construction.** `ZipPath("d/x", archive="a.zip")`. A path with no
941
+ `archive=` takes the one of a segment that is a path of the same family
942
+ (`ZipPath(root, "x")`, `/`, `joinpath`, `parent`, `with_name`, `iterdir`,
943
+ `relative_to` all keep it); with neither it is a `TypeError`, as it is for
944
+ an `archive=` that is not a path, a `str` or an `os.PathLike`. `archive=`
945
+ may also be the backend of another path of the archive: `backend_for(
946
+ archive, *, shared=True, identity=None)` returns the backend to pass, the
947
+ one shared by every path of that file and format, or with `shared=False`
948
+ one of its own; `identity` names a non-local archive file in the registry
949
+ (else its `_node_key()`). It reads nothing.
950
+ - **Names.** Member names are read from the archive root: `d/x` and `/d/x`
951
+ are one member, and the empty path or `/` is the archive itself.
952
+ - **Text forms.** `str()` is the member path alone. `repr()` names the
953
+ archive (`ZipPath('a/b', archive=LocalPath('x.zip'))`) without reading it.
954
+ `as_uri()` is `zip:<archive uri>!/<member>` (`tar:`, `archive:`), with a
955
+ literal `!/` in a member name written `%21/`.
956
+ - **Identity.** `==` compares the names only; two paths are one tree
957
+ (`copy()`, `move()` and `PathSyncer` ask) when they share a handle, and
958
+ `_node_key()` is that handle and the normalized member. A pickle holds the
959
+ text and the archive file; `copy.copy` and `copy.deepcopy` share the handle.
960
+ - `archive` is the archive file, `backend` the shared handle (it settles the
961
+ format of an `ArchivePath` on the first call), `refresh()` is described
962
+ below, `batch()` and `readlink()` under Writes and Links. `_rm_tree(
963
+ on_error, follows=False)` is the one-rewrite removal `rm(recursive=True)`
964
+ uses, for a wrapper that keeps the generic walk for the rest: it returns
965
+ False when the case is not that one, and asks `on_error(error, self)` about
966
+ a failure.
967
+ - `ArchivePath` settles the format on first use, not when the path is built:
968
+ constructing, printing, joining, copying and pickling a path read nothing,
969
+ a non-local archive file that had to be read to decide is not fetched a second
970
+ time, and a failure to read the outer propagates and decides nothing, so
971
+ the next call tries again (an `ArchivePath` to an archive file that is missing
972
+ and has no extension raises `FileNotFoundError` on a write, as it does on
973
+ a read).
974
+ - One shared handle per archive file and format (keyed by the real local
975
+ path, or by the node -- else the URI -- of a non-local archive file),
976
+ released when no path references it, so paths built separately for one
977
+ file see each other's writes. A local zip or tar is read from its file as
978
+ needed (a compressed tar is decompressed from its start for each member it
979
+ reaches) and no OS handle is held between calls; a non-local archive file is
980
+ read whole into memory, once, and kept while any path to it lives.
981
+ `refresh()` forgets what is held of it, so the next use reads it again
982
+ (every path to the same archive shares it); it is how a change at a remote
983
+ source is seen, and a local archive needs none (it is checked against the
984
+ file on every use).
985
+ - Cost. The members, the directories and each entry's stat data are derived
986
+ once per open handle, so a listing, a `walk()` or a `stat()` costs one
987
+ validation of the file and work in the size of the directory, not of the
988
+ archive. A member read is copied out of the shared handle: in memory up to
989
+ `MEMBER_SPOOL_BYTES` (16 MiB) and past that into an
990
+ unnamed temporary file, so memory stays small however large the member is,
991
+ and the disk used is the member's size as the archive declares it (an
992
+ archive can declare more than it holds). `open("r+")` and a write buffer
993
+ hold the whole member in memory. A zip mutation copies the entries it does
994
+ not change as they lie in the archive, without decompressing them, so it
995
+ costs the archive's stored size; an encrypted member, or one stored with a
996
+ compression method `zipfile` lacks, does not stop a change to another
997
+ member.
998
+ - **Member names are normalized POSIX relative paths**, whatever the
999
+ writer emitted and whichever format: a leading `./` (`tar -C dir .`,
1000
+ `shutil.make_archive`), empty segments (`a//b`) and interior `.`/`..`
1001
+ (`a/./b`, `a/b/../c`) resolve, so one member has one name and a listing
1002
+ and a lookup always agree. The spelling as written still addresses the
1003
+ member. A name that would leave the root -- `../x`, `/abs`, or a `..`
1004
+ with nothing to spend it on -- has no name inside the archive: it is
1005
+ never listed and never readable. A name only a *Windows destination* would
1006
+ misread (`C:drive.txt`, `a\b`) IS a member, because it is an ordinary POSIX
1007
+ filename; refusing to join it is the destination's rule, applied by
1008
+ whatever writes there (see `copy()` below, `PathSyncer`,
1009
+ `unpack_archive()`). On Windows `zipfile` rewrites `\` to `/` in a zip
1010
+ member name; elsewhere (and in a tar) a backslash name is a member like any
1011
+ other. When two spellings normalize to one name the
1012
+ later member wins, as in `zipfile`/`tarfile`. Exception types are POSIX on every
1013
+ platform.
1014
+ - The archive root is the archive: `exists()`/`is_dir()`/`stat()` of it
1015
+ open the archive, so a missing one does not exist (`FileNotFoundError`
1016
+ from `stat()`) and a file that is not an archive raises
1017
+ `zipfile.BadZipFile` / `tarfile.ReadError`, as a listing does. So does any
1018
+ other damage that stops the archive from being read -- a bad deflate
1019
+ stream, a short or inconsistent header, a zip version `zipfile` does not
1020
+ know, a truncated or corrupt gzip/bzip2/xz stream -- whichever decoder
1021
+ noticed it, and never as `zlib.error`, `struct.error`, `EOFError` or an
1022
+ `OSError` without an errno. A member that is encrypted raises
1023
+ `RuntimeError`, and one stored with a compression method `zipfile` lacks
1024
+ `NotImplementedError`, as `zipfile` raises them. A member another thread's
1025
+ rewrite removed between the lookup and its stat is `FileNotFoundError`.
1026
+ - **Links.** A tar `SYMTYPE` member and a zip entry made on Unix whose mode
1027
+ says link (`S_IFLNK`; its data is the text) are symbolic links.
1028
+ `is_symlink()` is True and `stat(follow_symlinks=False)` / `lstat()`, in a
1029
+ listing too, is a link (`S_IFLNK`, `st_size` the length of the text).
1030
+ `readlink()` is the text as a `LinkTarget`: a `PurePosixPath`, so spelled
1031
+ normalized, whose `path` is the text exactly as stored (what `copy()` and
1032
+ `PathSyncer` recreate a link from); `OSError(EINVAL)` for a member that is
1033
+ not a link, `OSError(EIO)` when the text cannot be read (a zip link over
1034
+ 4096 bytes, or damaged data). `stat()`, `exists()`, `is_dir()`, `is_file()`,
1035
+ `open()` and `iterdir()` follow links inside the archive, on the way
1036
+ (`ln/x` is the member `x` of what `ln` points at) and at the end; a relative
1037
+ text is read from the directory that holds the link. A link that is
1038
+ absolute, goes above the root, has no readable text or points at nothing
1039
+ resolves to nothing: `FileNotFoundError` whose `filename` names the link,
1040
+ never a path outside the archive. A loop, or more than `MAX_LINKS` (40)
1041
+ links followed by one lookup, is `OSError(ELOOP)`. `walk()` and `glob()`
1042
+ list a link once and by default do not enter it; `rm(recursive=True)`
1043
+ removes the entry, never what it points at; `copy(follow_symlinks=False)`
1044
+ and a `PathSyncer(follow_symlinks=False)` recreate it with `symlink_to()`
1045
+ where the target can hold one. With `follow_symlinks=True` (`copy()`'s
1046
+ default) a listing follows one directory link per path and never a link to
1047
+ the directory that holds it or to one above it: such a link, and one met
1048
+ below another link, answers `OSError(ELOOP)` to a following `stat()`,
1049
+ `is_dir()` or read, so `walk()` and `glob()` list it as an entry and
1050
+ `copy()`, `PathSyncer` and `rm()` meet it as one that cannot be entered
1051
+ (an error for that entry offered to `ignore_error`, for `rm()` a link
1052
+ removed as a link). The entries a recursive operation visits are at most
1053
+ `(members + links) * (directory links + 1)` however the links point; spell
1054
+ the path (`root / "l1" / "l2" / "f"`, or start the walk there) to go
1055
+ further. Nothing is written
1056
+ through a link (`NotImplementedError`: a write, `mkdir()`, `unlink()` or
1057
+ `rename()` whose path passes through one, or a write that names one);
1058
+ `unlink()`, `rename()` and `rm()` of the link itself act on its entry, and
1059
+ no link can be created in an archive. A tar hard link is a second name for
1060
+ a member, not a symlink: it reads and stats as that member and
1061
+ `is_symlink()` is False.
1062
+ - A file has no children: with a file `y` and a member `y/z`, `y/z` does
1063
+ not exist for `exists()`, `stat()`, `open()`, `unlink()` and `rename()`
1064
+ (`FileNotFoundError`; writing or `mkdir()` below `y` is
1065
+ `NotADirectoryError`), and no listing shows it.
1066
+ - Writes: zip only, and only with a local archive file (else
1067
+ `NotImplementedError`). `"w"`/`"x"`/`"r+"`, `mkdir()`, `unlink()`,
1068
+ `rmdir()`, `rename()` (same archive; replaces like POSIX `rename`;
1069
+ a destination below a file or a missing directory is
1070
+ `NotADirectoryError` / `FileNotFoundError`, a directory into itself
1071
+ `OSError(EINVAL)`); parents must exist; `"a"` unsupported. A `with` block
1072
+ over a written member that raised changes nothing (inside `batch()` the
1073
+ member is not staged and the block's other changes still commit). Every mutation
1074
+ replaces the archive atomically (temp file + `os.replace`) and keeps
1075
+ other members' metadata, the comment and any prefix bytes. A write uses
1076
+ the normalized name. A new member is stored deflated; an existing member that is
1077
+ overwritten keeps its compression method. The archive is replaced by a
1078
+ new file, so another hard link to it keeps the old content and the
1079
+ writing user owns the result; an archive the caller may not write
1080
+ (`os.access(..., W_OK)`) is refused with `PermissionError`, on every
1081
+ platform, before anything is written.
1082
+ - `batch()` — for a writable archive (`NotImplementedError` otherwise, as
1083
+ for any write): inside the block every change (`"w"`/`"x"`/`"r+"`,
1084
+ `mkdir()`, `unlink()`, `rmdir()`, `rename()`, `rm()`) goes to a member
1085
+ list held in memory (written data up to `MEMBER_SPOOL_BYTES`, then an
1086
+ unnamed temporary file), every path of the archive reads it, and the
1087
+ file is not touched. When the outermost block ends without an exception
1088
+ the archive is replaced ONCE, with the members, order and metadata that
1089
+ the same changes made one by one would leave; an archive that does not
1090
+ exist yet is created then. An exception leaves the file byte for byte as
1091
+ it was and drops the changes, and so does an archive another process
1092
+ replaced meanwhile (`OSError(EBUSY)`; their file stays). A block inside
1093
+ another joins it and commits or drops nothing itself. The block holds the
1094
+ archive for its thread: another thread's use of the archive waits for it.
1095
+ A change patches the pending member list, so the list is derived twice
1096
+ however many changes the block makes (once for the archive as the block
1097
+ finds it, once when it first holds a change); only a change to a name
1098
+ the archive spells two ways derives it again.
1099
+ `copy(recursive=True)` into a zip and `PathSyncer.sync()` onto one enter
1100
+ it themselves, so each is one replacement; a copy or sync that raises
1101
+ leaves the archive as it was, where the members written before the error
1102
+ used to stay.
1103
+ - Removing a file never removes its directory: when `unlink()`, `rename()`
1104
+ or `rm()` takes the last member out of a directory that exists only
1105
+ through its members, the directory's own entry (`name/`) is written in
1106
+ the same rewrite. `rmdir()` of the root succeeds on an empty archive
1107
+ (`OSError(ENOTEMPTY)` otherwise) and removes nothing.
1108
+ `rm(recursive=True)` of a directory is ONE rewrite (unless a policy other
1109
+ than `follow_symlinks=False` has to meet each link under it), and raises
1110
+ `OSError(ENOTEMPTY)` removing nothing when the tree holds a member under
1111
+ a file.
1112
+ - A member the archive spells several ways (`norm` and `./norm`) is one
1113
+ member: `unlink()`, `rename()`, `rmdir()` and `rm()` act on every entry
1114
+ that names it, and renaming over it replaces them all.
1115
+ - `TarPath` (plain, gz, bz2, xz) is read-only.
1116
+
1117
+ ### `make_archive`, `unpack_archive` (`pathapi.archive`)
1118
+
1119
+ ```python
1120
+ def make_archive(src, format, target) -> None: ...
1121
+ def unpack_archive(archive, dest) -> None: ...
1122
+ ```
1123
+
1124
+ `make_archive()` (`format` `"zip"` or
1125
+ `"tar"`, else `ValueError`; `src` file or directory of any `Path`; built in
1126
+ a temporary buffer, written to `target` only when complete; zip64 always;
1127
+ every directory is a member, empty ones too; a member carries the source's
1128
+ modification time and, in a tar, its permission bits — a source that
1129
+ reports no time gets 1980-01-01 (zip) or the epoch (tar), and one that
1130
+ reports no mode gets 0o644 for a file and 0o755 for a directory; a tar
1131
+ member is sized from the bytes read, not from `stat()`)
1132
+ and `unpack_archive()` (format from the name, else magic
1133
+ bytes; creates `dest`; non-seekable streams are buffered; members that
1134
+ would leave `dest` are skipped, and for a Windows-flavoured `dest` so are
1135
+ members with a part Windows would rewrite or send to a device
1136
+ (`UserWarning`); tar hard links and links to regular files are extracted as
1137
+ copies, other links — to nothing, to a directory, or in a loop — skipped
1138
+ with `UserWarning`).
1139
+
1140
+ ## HTTP requests (`pathapi.http`)
1141
+
1142
+ Standard library only (`http.client`, `ssl`); needs no extra and no URI.
1143
+ `import pathapi.http` loads `http.client` and `ssl`; `import pathapi`
1144
+ does not. The package sends a request through a **transport**, follows
1145
+ redirects under one rule for credentials, and maps a failed request to the
1146
+ `OSError` types `pathlib` raises. Path classes for HTTP servers in other
1147
+ distributions are built on it.
1148
+
1149
+ ```python
1150
+ class HTTPClient:
1151
+ headers: dict
1152
+ timeout: float | tuple | None
1153
+ user_agent: str
1154
+ def __init__(
1155
+ self, transport=None, *, headers=None, timeout=(10, 60), user_agent=None,
1156
+ ): ...
1157
+ transport
1158
+ def request(
1159
+ self, method, url, *, data=None, headers=None, stream=False,
1160
+ allow_redirects=None, timeout=..., auth=None,
1161
+ ): ...
1162
+ def redirect(
1163
+ self, method, url, resp, *, headers=None, stream=False, timeout=...,
1164
+ auth=None,
1165
+ ): ...
1166
+ def close(self): ...
1167
+
1168
+ DEFAULT_TIMEOUT = (10, 60)
1169
+ MAX_REPLY_BYTES = 8388608
1170
+ ```
1171
+
1172
+ - **`HTTPClient(transport=None, *, headers=None, timeout=(10, 60),
1173
+ user_agent=None)`** holds a transport and what every request is sent with.
1174
+ `transport` is any object with `send()` (the contract below); without one the
1175
+ client builds the transport that `PATHAPI_HTTP_TRANSPORT` names (the
1176
+ built-in `HTTPTransport` by default) on first use, and `close()` or leaving
1177
+ its `with` block closes a transport it built, never one it was given. A
1178
+ transport without a `send()` method is `TypeError`. `headers` are sent with
1179
+ every request, the call's headers merged over them by name (case-blind, a
1180
+ value of `None` removes the name) over `User-Agent: <user_agent>` (default
1181
+ `pathapi/<version>`) and `Accept: */*`. Every request gets
1182
+ `timeout=(10, 60)` seconds (`connect`, `read`) unless the client or the call
1183
+ gives one; `timeout=None` waits without limit. `repr()` shows no header.
1184
+ - **`request(method, url, *, data=None, headers=None, stream=False,
1185
+ allow_redirects=None, timeout, auth=None)`** returns the final
1186
+ `HTTPResponse`. `data` is `bytes`, `str` (UTF-8), a binary file or an
1187
+ iterable of `bytes`; one that is not `bytes` cannot be sent twice, so it is
1188
+ not sent again after a redirect or a dropped connection. `auth` is a
1189
+ `(user, password)` pair sent as `Authorization: Basic`, or a `str` sent as the
1190
+ `Authorization` value (`"Bearer <token>"`), to the origin of `url` unless a
1191
+ header names an `Authorization`. A `url` that carries credentials,
1192
+ a URL or header value with a control character (CR and LF included) and
1193
+ another scheme than `http:` or `https:` are `ValueError`, before anything is
1194
+ sent. A failure of the connection is `TimeoutError`, `ConnectionError`,
1195
+ `ssl.SSLError` or `OSError(EIO)` as the transport raises them. A status of
1196
+ 400 or more is **not** an error here: it is the reply (`raise_for_status()`).
1197
+ With `stream=False` the body has been read when `request()` returns, and one
1198
+ that decodes to more than `MAX_REPLY_BYTES` is `OSError(EFBIG)`; with
1199
+ `stream=True` the caller reads the body and closes the response.
1200
+ - **Redirects.** `GET`, `HEAD` and `OPTIONS` follow up to 30 redirects (a `303`
1201
+ becomes a `GET`); `allow_redirects=False` returns the first answer. A redirect
1202
+ whose `Location` is missing or that cannot be requested (another scheme, a
1203
+ port out of range, a host that is not a name) is `OSError(EIO)` naming the
1204
+ status, never `ValueError`. A redirect that changes the scheme, host or port
1205
+ drops `Authorization` for that hop and every later one (a return to the first
1206
+ origin does not bring it back), also from `https:` to `http:`, and nothing
1207
+ else: every header other than `Authorization` goes to the new host as well,
1208
+ so keep a secret in `Authorization`. Every other method (`PUT`, `POST`,
1209
+ `PATCH`, `DELETE`, `PROPFIND`, ...) is sent without following: a 307 or 308
1210
+ (for `PROPFIND` also a 301, 302 or 303) to the same scheme, host and port is
1211
+ sent once more with the same method, headers and body, and any other 3xx, a
1212
+ redirect to another origin or a second redirect is `OSError(EIO)` naming the
1213
+ status and the `Location` (userinfo, query and fragment removed). A body
1214
+ that is not `bytes` cannot be sent again, so it raises too.
1215
+ - **`redirect(method, url, resp, *, headers=None, stream=False, timeout,
1216
+ auth=None)`** sends `method` to where `resp`, the answer to a request for
1217
+ `url` made with `allow_redirects=False`, redirects to, and follows what that
1218
+ redirects to in turn. `Authorization` is left off whenever the target is
1219
+ another scheme, host or port than `url`'s. No usable `Location` is
1220
+ `OSError(EIO)`.
1221
+
1222
+ ```python
1223
+ class HTTPTransport:
1224
+ def __init__(self, *, ssl_context=None, verify=True, proxies=None): ...
1225
+ def send(
1226
+ self, method, url, *, headers, body=None, timeout, stream=False,
1227
+ ): ...
1228
+ def close(self): ...
1229
+
1230
+ class HTTPResponse:
1231
+ status: int
1232
+ reason: str
1233
+ headers: Headers
1234
+ url: str
1235
+ def __init__(
1236
+ self, status, reason, headers, url, body=b"", *, decode=True,
1237
+ ): ...
1238
+ def read(self, size=-1): ...
1239
+ def iter_bytes(self, chunk_size=65536): ...
1240
+ def buffered(self): ...
1241
+ def close(self): ...
1242
+
1243
+ class Headers:
1244
+ def get_all(self, name): ...
1245
+ ```
1246
+
1247
+ - **The transport contract.** Any object with `send(method, url, *, headers,
1248
+ body=None, timeout, stream=False) -> HTTPResponse` and `close()` is a
1249
+ transport (a context manager is optional). `send()` makes exactly one
1250
+ request: it follows no redirect, adds no credentials and retries nothing
1251
+ but what is stated below. `body` is `None`, `bytes`, a binary file or an
1252
+ iterable of `bytes` (never `str`: `TypeError`). A body is sent under a
1253
+ `Content-Length` when its length is known without reading it: `bytes`, a
1254
+ list or tuple of `bytes`, a file that is seekable (the bytes from `tell()`
1255
+ to its end, the position restored) and a regular file (its size less
1256
+ `tell()`). Any other body (an iterator, a generator, a pipe or another
1257
+ stream that cannot report its size) is sent `Transfer-Encoding: chunked`. A
1258
+ `Content-Length` in `headers` is trusted: that many bytes are sent and
1259
+ nothing is chunked, a longer body is cut there, a count that is not a number
1260
+ or is larger than a body whose length is known is `ValueError` before
1261
+ anything is sent, and a body that runs out first is `OSError(EIO)` with its
1262
+ connection closed. No body is sent past its length (a file that grew after it
1263
+ was measured is cut there). A file or iterator is never rewound and never
1264
+ sent twice. `timeout` is seconds, a `(connect, read)` pair or `None` (no
1265
+ limit); the read half bounds each wait on the server, not the whole reply.
1266
+ With `stream=False` the body has been read when `send()` returns; with
1267
+ `stream=True` it is read from the response, which the caller closes (an
1268
+ unread rest of a body closes its connection). A transport raises only
1269
+ `TimeoutError`, `ConnectionError`, `ssl.SSLError`, `ValueError` (before
1270
+ anything is sent, for a header or URL with a control character, a URL with
1271
+ credentials, or another scheme) or else `OSError(EIO)`; no third-party
1272
+ exception and no URL or header value is in its text. A reply below 200 is
1273
+ never the one `send()` returns: `HTTPTransport` skips interim (1xx) replies,
1274
+ at most 8, and past that is `OSError(EIO)` with its connection closed.
1275
+ - **`HTTPResponse`**: `status`, `reason`, `headers` (read-only, names compare
1276
+ without case, a name sent twice reads as `"a, b"` and `headers.get_all()`
1277
+ lists them; `repr()` shows names only), `url` (the URL requested, without
1278
+ fragment; an empty path is `/`), `read(size=-1)`, `iter_bytes(chunk_size)`,
1279
+ `buffered()` (reads the rest of the body into memory, closes the connection
1280
+ and returns the response, which stays readable: what `send(stream=False)`
1281
+ returns), `close()` and a context manager. A `gzip` or `deflate` body is
1282
+ decoded, in steps of at most 256 KiB of output per step, unless the request
1283
+ set an `Accept-Encoding` naming a coding of its own (`identity` counts as
1284
+ none); `headers` stay as received, so `Content-Length` counts the encoded
1285
+ bytes. A body that ends before the length the server stated, a chunked body
1286
+ cut short, and a coded body that is not valid are `OSError(EIO)`, never a
1287
+ shorter read. A transport builds one from `bytes` or from an object with
1288
+ `read(amount)` and `close()`; a fake transport in a test does the same.
1289
+ - **`HTTPTransport(ssl_context=None, verify=True, proxies=None)`** is standard
1290
+ library only. `https:` is verified against `ssl_context`, else against
1291
+ `ssl.create_default_context()` (the system trust store, not certifi);
1292
+ `verify=False` is the one way to turn verification off (each transport built
1293
+ with it logs a warning under the `pathapi.http` logger), and `ssl_context`
1294
+ wins over it. Each thread keeps one idle connection per server (at most 8,
1295
+ closed when the thread ends or on `close()`), reused for its next request
1296
+ unless the server has closed it or sent bytes on it since (checked before
1297
+ anything is sent). A request that fails before any byte of a reply on a
1298
+ reused connection that the server had closed is sent once more on a new
1299
+ connection **only** if it is `GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE` or
1300
+ `PROPFIND` and its body is `None` or in memory; any other request uses an
1301
+ idle connection only if it carried a reply within the last second. A timeout
1302
+ is never retried. `stream=False` holds the whole body in memory; read a body
1303
+ that may be large with `stream=True`.
1304
+ - **Proxies.** `proxies=None` reads the environment (`HTTP_PROXY`,
1305
+ `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`, through `urllib.request`, which
1306
+ matches names and suffixes in `NO_PROXY`; a CIDR block there, such as
1307
+ `10.0.0.0/8` or `fd00::/8`, also sends an IP-literal host direct, and a host
1308
+ name is never resolved to be matched against one). Otherwise `proxies` is
1309
+ indexed with the request URL (no fragment): it returns `None` (direct), one
1310
+ proxy, or an iterable of them to try in order when a connection cannot be
1311
+ made (`None` in the order is a direct attempt, and an empty iterable is
1312
+ direct as well); `KeyError` means direct and the environment is not read;
1313
+ `{}` sends everything direct (unlike other clients, which still read the
1314
+ environment); an exception of the provider other than `KeyError` reaches the
1315
+ caller as it is. A proxy is a `str` URL or any object with a `url`
1316
+ attribute, so a proxy resolver from another library works as it is, with none
1317
+ imported here:
1318
+
1319
+ ```
1320
+ class Resolver: # tries the first proxy, then connects directly
1321
+ def __getitem__(self, url):
1322
+ return ["http://user:secret@proxy.example:3128", None]
1323
+
1324
+ transport = HTTPTransport(proxies=Resolver())
1325
+ ```
1326
+
1327
+ A plain `dict` may use the keys the `requests` library does (`"http"`,
1328
+ `"https"`, `"all"`, `"scheme://host"`). Only `http://` proxies work:
1329
+ absolute-form requests for `http:` and `CONNECT` for `https:`, with
1330
+ `Proxy-Authorization: Basic` from the proxy URL's userinfo (sent to the proxy
1331
+ only); any other scheme (`socks5`, `https`) is `OSError(EPROTONOSUPPORT)`
1332
+ naming the scheme and `pathapi-requests`. A proxy that refuses `CONNECT`
1333
+ is a `ConnectionError` and the next proxy is not tried. Proxy credentials are
1334
+ in no message, `repr()` or traceback.
1335
+
1336
+ ### Replies and errors (`pathapi.http`)
1337
+
1338
+ ```python
1339
+ class HTTPStatusError(Exception):
1340
+ response: HTTPResponse
1341
+ def __init__(self, response): ...
1342
+
1343
+ def raise_for_status(resp): ...
1344
+ def read_limited(resp, what, limit=None): ...
1345
+ def json_body(resp, what, limit=None): ...
1346
+ def drop_body(resp, limit=None): ...
1347
+ def translate_errors(subject, *, conflict=FileExistsError): ...
1348
+ def os_error(error_class, subject): ...
1349
+ def server_size(text): ...
1350
+ def peer_text(text, limit=120): ...
1351
+ def split_userinfo(url): ...
1352
+ def is_redirect(resp): ...
1353
+ ```
1354
+
1355
+ - `raise_for_status(resp)` raises `HTTPStatusError` (holding `response`) for a
1356
+ status of 400 or more.
1357
+ - `read_limited(resp, what, limit=None)` is the whole decoded body of `resp`,
1358
+ which may be at most `limit` bytes (default `MAX_REPLY_BYTES`, 8 MiB after
1359
+ decoding, however small it is on the wire; assign
1360
+ `pathapi.http.MAX_REPLY_BYTES` to allow more, it is read each time):
1361
+ more is `OSError(EFBIG)` naming `what`, and the connection is closed.
1362
+ `json_body()` is that, parsed (`ValueError` when it is not JSON).
1363
+ - `drop_body(resp, limit=None)` gives up the body of a reply nobody looks at: it
1364
+ is read up to `limit` bytes so the connection can serve the next request, and
1365
+ `resp` is closed, which closes the connection when the body was longer.
1366
+ - `translate_errors(subject, *, conflict=FileExistsError)` is a context manager
1367
+ that maps what a request raised to what `pathlib` raises for `subject` (a path
1368
+ or URL, shown through `str()`): an `HTTPStatusError` of 404 or 410 becomes
1369
+ `FileNotFoundError`, 401 or 403 `PermissionError`, 405 or 501 `PermissionError`
1370
+ naming the status, 409 `conflict` (a write passes `FileNotFoundError`: RFC 4918
1371
+ uses 409 for a missing parent collection), any other status `OSError(EIO)`
1372
+ naming it (`HTTP Error 502 Bad Gateway for <subject>`). A `TimeoutError` or
1373
+ `ConnectionError` is raised again with a message holding `subject` and the
1374
+ exception's type name. The cause is never chained, so a URL, a header or a
1375
+ credential that the transport's error quoted is in no message or traceback.
1376
+ `os_error(error_class, subject)` builds one such exception as `pathlib` does,
1377
+ with `errno` and `filename` set.
1378
+ - `peer_text(text, limit=120)` is text that came from the other end (a reason
1379
+ phrase, a `Location`, a header value) as an error message may show it:
1380
+ control, line-separator and bidirectional characters escaped, cut to `limit`.
1381
+ `server_size(text)` is a byte count a server stated as an `int`, or `None` when
1382
+ it is not a plain non-negative decimal number. `split_userinfo(url)` is
1383
+ `(url_without_userinfo, (user, password) | None)`, each part the octets its
1384
+ percent-escapes stand for (`str` when ASCII, else `bytes`); `is_redirect(resp)`
1385
+ says whether a reply is a 301, 302, 303, 307 or 308 that names a `Location`.
1386
+
1387
+ ### For transport implementers (`pathapi.http`)
1388
+
1389
+ ```python
1390
+ TRANSPORT_GROUP = "pathapi.http_transports"
1391
+ TRANSPORT_ENV = "PATHAPI_HTTP_TRANSPORT"
1392
+ def transport_names() -> tuple[str, ...]: ...
1393
+ def get_transport(name: str) -> type: ...
1394
+ def default_transport(): ...
1395
+
1396
+ class PreparedRequest(NamedTuple):
1397
+ method: str
1398
+ url: str
1399
+ headers: dict[str, str]
1400
+ body: Any
1401
+ timeout: tuple[float | None, float | None]
1402
+ decode: bool
1403
+
1404
+ def prepare_request(
1405
+ method, url, *, headers, body=None, timeout,
1406
+ ) -> PreparedRequest: ...
1407
+ ```
1408
+
1409
+ A distribution provides an HTTP transport by declaring an entry point in the
1410
+ group `pathapi.http_transports`: the name is the transport's (`requests`)
1411
+ and the value names a class that is built with no arguments (`pyproject.toml`:
1412
+ `[project.entry-points."pathapi.http_transports"]`, `requests =
1413
+ "pathapi_requests:RequestsTransport"`). The class implements "The transport
1414
+ contract" above: `send()` and `close()`.
1415
+
1416
+ - `transport_names()` — the transport names, sorted: `builtin`, every entry of
1417
+ the group of an installed distribution, and `requests` whether or not its
1418
+ distribution is installed. Nothing is imported to list them.
1419
+ - `get_transport(name)` — the class called `name`; the name is case-sensitive.
1420
+ `builtin` is always `HTTPTransport`: an entry called `builtin` in the group is
1421
+ ignored. A transport that another distribution provides and that is not
1422
+ installed raises `ImportError` naming it (`pip install pathapi-requests`
1423
+ for `requests`); any other name is a `ValueError` listing `transport_names()`;
1424
+ an entry that does not load a class is a `TypeError`.
1425
+ - `default_transport()` — a new transport of the kind
1426
+ `PATHAPI_HTTP_TRANSPORT` names, which is what a client given no transport
1427
+ builds: `get_transport(name)()`. An empty, differently cased or unknown value
1428
+ is a `ValueError` naming the choices.
1429
+ - `prepare_request(method, url, *, headers, body=None, timeout)` — the arguments
1430
+ of `send()` as the contract reads them, so a transport that starts with it
1431
+ refuses what the built-in transport refuses and frames a body the same way.
1432
+ `method` is upper-cased and the fragment is dropped from `url`. `headers` come
1433
+ back as a `dict` of text values with the `Content-Length` of a body whose
1434
+ length is known (`0` for a `PATCH`, `POST` or `PUT` without one) and
1435
+ `Accept-Encoding: gzip, deflate` unless the caller named an encoding. `body` is
1436
+ `None`, `bytes`, an object that yields exactly the `Content-Length` bytes (a
1437
+ file or a list of `bytes`, cut at the length it was measured at) or an
1438
+ iterable of `bytes` to send chunked. `timeout` is the `(connect, read)` pair.
1439
+ `decode` is what to pass as `HTTPResponse(..., decode=decode)`. It raises
1440
+ `ValueError`, before anything is sent, for a method or header name that is not
1441
+ a token, a header value or URL with a control character, a URL with
1442
+ credentials or another scheme, a `Content-Length` that is not a count of bytes
1443
+ or exceeds the body, and a timeout that is not a positive number, `None` or a
1444
+ pair of them; and `TypeError` for a `str` body. No message quotes the URL or a
1445
+ header value.
1446
+
1447
+ The suite that holds a transport to these promises ships with the package:
1448
+ "Testing support" under "Testing helpers".
1449
+
1450
+ ## Protocols (`pathapi.protocols`)
1451
+
1452
+ `Path` is a class statement over these protocols. Each is a `typing.Protocol`
1453
+ with default bodies, subclassed explicitly as a mixin: a class lists the ones
1454
+ it implements, supplies the **primitives** (stubs that raise
1455
+ `NotImplementedError`) and gets the derived operations. A read-only backend is
1456
+ `class X(Pathname, Listing, Stat, BinaryOpen)` with no `Path`; list a protocol
1457
+ before the ones it is built on (`Listing` before `Stat`), as Python's MRO
1458
+ requires. `Path` lists `Copy` before `BinaryOpen`, so `Path.copy` is the path
1459
+ copy and not the stream copy.
1460
+
1461
+ | Protocol | Module | Primitives | Derived | Bases |
1462
+ | --- | --- | --- | --- | --- |
1463
+ | `Named` | `pure` | none (structural only) | `name`, `segments`, `parent`, `with_segments()`, `joinpath()`, `/`, `as_posix()` | none |
1464
+ | `FileStatLike` | `fs` | `st_mode`, `st_size`, `st_mtime` | | none |
1465
+ | `Stat` | `fs` | `stat()` | `lstat()`, `exists()`, `is_dir()`, `is_file()`, `is_symlink()`, `is_block_device()`, `is_char_device()`, `is_fifo()`, `is_socket()`, `info`, `is_junction()`, `is_mount()`, `is_dir_binding()` | none |
1466
+ | `Chmod` | `fs` | `chmod()` | `lchmod()` | `Chown`, `Utime` |
1467
+ | `Owned` | `fs` | `_user_name()`, `_group_name()` | `owner()`, `group()` | `Stat` |
1468
+ | `Chown` | `fs` | `_chown()` | `chown()` | none |
1469
+ | `Utime` | `fs` | `_utime()` | | none |
1470
+ | `Create` | `fs` | `_mkdir()` | `mkdir()`, `touch()` | `Stat`, `Chmod`, `Utime` |
1471
+ | `BinaryOpen` | `io` | `_open()` | `open()`, `read_bytes()`, `read_text()`, `write_bytes()`, `write_text()`, `copy()` | none |
1472
+ | `Listing` | `tree` | `iterdir()` | `_scandir()`, `glob()`, `rglob()`, `walk()`, iteration | `Stat` |
1473
+ | `Remove` | `tree` | `unlink()`, `rmdir()` | `rm()` | `Listing` |
1474
+ | `Readlink` | `links` | `readlink()` | | `Stat` |
1475
+ | `Symlink` | `links` | `_symlink_to()` | `symlink_to()`, `_symlink_target()` | `Remove` |
1476
+ | `Hardlink` | `links` | `_hardlink_to()` | `hardlink_to()` | `Stat` |
1477
+ | `Resolve` | `resolve` | `_cwd()` | `absolute()`, `resolve()` | `Readlink` |
1478
+ | `Identity` | `transfer` | none | `samefile()`, `_same_filesystem()`, `_node_key()` | `Stat` |
1479
+ | `Copy` | `transfer` | none | `copy()`, `copy_into()`, `_copy_from()`, `_coerce_target()`, `_write_batch()` | `Identity`, `Create`, `Symlink`, `Readlink`, `BinaryOpen` |
1480
+ | `Move` | `transfer` | `rename()` | `move()`, `move_into()`, `replace()` | `Copy`, `Remove` |
1481
+ | `NativeChecksum` | `checksum` | `checksum()` | `supported_checksums()` | none |
1482
+
1483
+ One protocol per primitive that a backend can have without the others: an
1484
+ archive reads links and makes none (`Readlink` and not `Symlink`), a server may
1485
+ set times and not modes, and `Owned` reads names where `Chown` writes ids.
1486
+ `BinaryOpen` is one protocol: a single `_open(mode)` serves reading and writing,
1487
+ and a mode the backend cannot honour raises `NotImplementedError` per call.
1488
+ `Create` also calls `open()` and `unlink()` in `touch()`. All names are
1489
+ imported from `pathapi.protocols`; the module column is where each one
1490
+ is defined.
1491
+
1492
+ ### `supports`, `capabilities`
1493
+
1494
+ ```python
1495
+ def supports(obj, *protocols) -> bool: ...
1496
+ def capabilities(obj) -> FrozenSet[type]: ...
1497
+ ```
1498
+
1499
+ `supports(obj_or_class, *protocols)` is true when every primitive of every
1500
+ protocol named is something other than its default stub; `capabilities()`
1501
+ lists the protocols for which that holds. Ask once instead of calling and
1502
+ catching `NotImplementedError`. A primitive also counts as implemented when
1503
+ another library defines the operation it feeds: `pathlib`'s `mkdir` for
1504
+ `_mkdir`, `open` for `_open`, `absolute` for `_cwd`, `hardlink_to` for
1505
+ `_hardlink_to`, `chown` for `_chown`, `symlink_to` for `_symlink_to`, `owner`
1506
+ for `_user_name` and `group` for `_group_name`; so `LocalPath` supports `Owned`
1507
+ where `pathlib` defines `owner()` and `group()`, which includes Windows, where
1508
+ they exist and raise `NotImplementedError` when called. A protocol with no primitives
1509
+ (`Identity`, `Copy`) is supported by a class that has it as a base. A class
1510
+ whose answer depends on its connection defines `_supports(protocol) -> bool |
1511
+ None`, asked first for an instance; `None` falls back to the answer from the
1512
+ class. It may make at most one request per backend, cached for the backend's
1513
+ lifetime, and never raises: a failed request answers `None`. The answer is
1514
+ advisory, like `supported_checksums()`: a primitive may still raise
1515
+ `NotImplementedError` for an argument it cannot handle.
1516
+ `TypeError` for no protocol and for something that is not a protocol of this
1517
+ package (`Named`, `FileStatLike` and the like have no primitives to ask).
1518
+
1519
+ ### `pure.Named`
1520
+
1521
+ ```python
1522
+ class Named(Protocol):
1523
+ name
1524
+ segments
1525
+ parent
1526
+ def with_segments(self, *segments): ...
1527
+ def joinpath(self, *args): ...
1528
+ def as_posix(self): ...
1529
+ ```
1530
+
1531
+ Structural only: `Pathname` and `pathlib.PurePath` satisfy it without naming
1532
+ it, and nothing subclasses it. It is the pure half that `glob`, `walk` and
1533
+ `sync` read.
1534
+
1535
+ ### `fs.FileStatLike`
1536
+
1537
+ ```python
1538
+ class FileStatLike(Protocol):
1539
+ st_mode: int
1540
+ st_size: int
1541
+ st_mtime: float
1542
+ ```
1543
+
1544
+ `st_mode`, `st_size`, `st_mtime`.
1545
+
1546
+ ### `fs.Stat`
1547
+
1548
+ ```python
1549
+ class Stat(Protocol):
1550
+ def stat(self, *, follow_symlinks=True) -> FileStatLike: ...
1551
+ def lstat(self) -> FileStatLike: ...
1552
+ def exists(self, *, follow_symlinks=True): ...
1553
+ def is_dir(self, *, follow_symlinks=True): ...
1554
+ def is_file(self, *, follow_symlinks=True): ...
1555
+ def is_symlink(self): ...
1556
+ def is_block_device(self): ...
1557
+ def is_char_device(self): ...
1558
+ def is_fifo(self): ...
1559
+ def is_socket(self): ...
1560
+ info
1561
+ def is_junction(self) -> bool: ...
1562
+ def is_mount(self) -> bool: ...
1563
+ def is_dir_binding(self) -> bool: ...
1564
+ ```
1565
+
1566
+ `stat()` (stub). A stat may report `size_known=False` (`FileStat`): the
1567
+ backend knows the entry's type and not its size or time, as TFTP without the
1568
+ `tsize` option or an HTTP reply without `Content-Length` do; `st_size` and
1569
+ `st_mtime` are then placeholders, and a consumer that needs them asks again or
1570
+ does without. Derives `lstat()`,
1571
+ `exists()`, `is_dir()`,
1572
+ `is_file()`, `is_symlink()`, `is_block_device()`,
1573
+ `is_char_device()`, `is_fifo()`, `is_socket()`; any `OSError`/`ValueError`
1574
+ from `stat()` reads as `False`. `info`, `is_junction()`, `is_mount()` and
1575
+ `is_dir_binding()` are described under `Path`.
1576
+
1577
+ ### `fs.Chmod`
1578
+
1579
+ ```python
1580
+ class Chmod(Chown, Utime, Protocol):
1581
+ def chmod(self, mode, *, follow_symlinks=True): ...
1582
+ def lchmod(self, mode): ...
1583
+ ```
1584
+
1585
+ `chmod()` (stub; every
1586
+ implementation normalizes through `providers.parse_mode()`, so `"0755"` works)
1587
+ and `lchmod()`. `supports(x, Chmod)` asks for `chmod()` alone. `Chmod`
1588
+ inherits `Chown` and `Utime` for compatibility, because `chown()`, `_chown()`
1589
+ and `_utime()` were documented with it: a class that lists `Chmod` has them,
1590
+ and a new class lists the protocol it means.
1591
+
1592
+ ### `fs.Owned`
1593
+
1594
+ ```python
1595
+ class Owned(Stat, Protocol):
1596
+ def owner(self, *, follow_symlinks=True) -> str: ...
1597
+ def group(self, *, follow_symlinks=True) -> str: ...
1598
+ def _user_name(self, uid) -> str: ...
1599
+ def _group_name(self, gid) -> str: ...
1600
+ ```
1601
+
1602
+ `owner()` and `group()` (the 3.13 signature) take `st_uid` and `st_gid` from
1603
+ `stat()` and map them through `_user_name(uid)` and `_group_name(gid)` (stubs);
1604
+ a stat without the field, or a class without the mapping, raises
1605
+ `NotImplementedError`. `LocalPath` keeps pathlib's.
1606
+
1607
+ ### `fs.Chown`
1608
+
1609
+ ```python
1610
+ class Chown(Protocol):
1611
+ def _chown(self, uid, gid, *, follow_symlinks=True) -> None: ...
1612
+ def chown(self, uid=None, gid=None, *, follow_symlinks=True) -> None: ...
1613
+ ```
1614
+
1615
+ `_chown()` (stub, receives a
1616
+ canonical pair) / `chown()` —
1617
+ `None` or `-1` leaves a field unchanged, `int` is an id, `str` a name; an
1618
+ all-unchanged call returns without touching the backend.
1619
+
1620
+ ### `fs.Utime`
1621
+
1622
+ ```python
1623
+ class Utime(Protocol):
1624
+ def _utime(self, *, atime=None, mtime=None) -> None: ...
1625
+ ```
1626
+
1627
+ `_utime(*, atime=None,
1628
+ mtime=None)` (stub) is the primitive for setting an existing entry's times, in
1629
+ seconds since the epoch (float): `None` leaves that time alone, a backend that
1630
+ keeps whole seconds or no access time sets what it can, and one that cannot set
1631
+ a time at all raises `NotImplementedError`. `touch()` of an existing file and
1632
+ `PathSyncer(preserve_mtime=True)` call it.
1633
+
1634
+ ### `fs.Create`
1635
+
1636
+ ```python
1637
+ class Create(Stat, Chmod, Utime, Protocol):
1638
+ def touch(self, mode=None, exist_ok=True): ...
1639
+ def _mkdir(self, mode): ...
1640
+ def mkdir(self, mode=0o777, parents=False, exist_ok=False): ...
1641
+ ```
1642
+
1643
+ `_mkdir(mode)` (stub) creates this one directory; `mkdir()` and `touch()` are
1644
+ described under `Path`.
1645
+
1646
+ ### `io.BinaryOpen`
1647
+
1648
+ ```python
1649
+ class BinaryOpen(Protocol):
1650
+ def open(
1651
+ self, mode="r", buffering=-1, encoding=None, errors=None,
1652
+ newline=None,
1653
+ ) -> _io.IOBase: ...
1654
+ def read_bytes(self) -> bytes: ...
1655
+ def read_text(self, encoding=None, errors=None, newline=None) -> str: ...
1656
+ def write_bytes(self, data): ...
1657
+ def write_text(self, data, encoding=None, errors=None, newline=None): ...
1658
+ def copy(self, target, *, progress=None, chunk_size=...): ...
1659
+ def _open(self, mode="r", buffering=-1) -> _io.IOBase: ...
1660
+ ```
1661
+
1662
+ `_open()` (stub).
1663
+ `open()`
1664
+ validates the mode like builtin `open()` (`ValueError`) and passes
1665
+ `_open()` a canonical `r`/`w`/`x`/`a` plus optional `+` (never `b`/`t`);
1666
+ text mode wraps in `TextIOWrapper` and closes the handle if wrapping fails.
1667
+ An `_open()` that cannot honor a mode raises `NotImplementedError` per
1668
+ call: one `_open(mode)` primitive serves reading and writing, so there is no
1669
+ separate read-only protocol. A stream
1670
+ that commits on close inherits `providers.DiscardOnError`, so a `with` block
1671
+ over the file that raised commits nothing (see "For path implementers");
1672
+ `write_bytes()`, `write_text()` and `copy()` whose data fails half way commit
1673
+ nothing there either. Binary mode returns the stream `_open()` gave it as it
1674
+ is. Derives
1675
+ `read_bytes()`, `read_text()`,
1676
+ `write_bytes(data)`, `write_text()`, `copy()` (`chunk_size` defaults to `shutil.COPY_BUFSIZE`; `progress(bytes_copied, total_size | None)`; an empty file reports once).
1677
+ This `copy()` streams into any target that has `open()`; `Copy.copy()` is the
1678
+ path copy, and a class that lists `Copy` before `BinaryOpen` has that one.
1679
+
1680
+ ### `tree.Listing`
1681
+
1682
+ ```python
1683
+ class Listing(Stat, Protocol):
1684
+ def iterdir(self) -> Iterator[Self]: ...
1685
+ def _scandir(self) -> Iterator[Tuple[str, Optional[FileStat]]]: ...
1686
+ def glob(
1687
+ self, pattern, *, case_sensitive=None, include_hidden=True,
1688
+ recursive=None, dironly=None, recurse_symlinks=False, native=True,
1689
+ on_error=None, bound_loops=False,
1690
+ ): ...
1691
+ def rglob(
1692
+ self, pattern, *, case_sensitive=None, include_hidden=True,
1693
+ recursive=True, dironly=None, recurse_symlinks=False, native=True,
1694
+ on_error=None, bound_loops=False,
1695
+ ): ...
1696
+ def walk(self, top_down=True, on_error=None, follow_symlinks=False): ...
1697
+ ```
1698
+
1699
+ `iterdir()` (stub). `_scandir()`, `glob()`, `rglob()`, `walk()` and
1700
+ `__iter__()` derive from it and from `stat()`; their contracts are under
1701
+ `Path`.
1702
+
1703
+ ### `tree.Remove`
1704
+
1705
+ ```python
1706
+ class Remove(Listing, Protocol):
1707
+ def unlink(self, missing_ok=False): ...
1708
+ def rmdir(self): ...
1709
+ def rm(
1710
+ self, recursive=False, missing_ok=False, ignore_error=False, *,
1711
+ follow_symlinks=False, follow_binds=False,
1712
+ ): ...
1713
+ ```
1714
+
1715
+ `unlink()` and `rmdir()` (stubs); `rm()` derives from them and from
1716
+ `_scandir()` (see `Path`).
1717
+
1718
+ ### `links.Readlink`
1719
+
1720
+ ```python
1721
+ class Readlink(Stat, Protocol):
1722
+ def readlink(self) -> Self: ...
1723
+ ```
1724
+
1725
+ `readlink()` (stub) returns a path of this class whose `str()` and
1726
+ `as_posix()` are the link text, unresolved (`OSError(EINVAL)` where the path
1727
+ is not a link). A read-only backend such as `ZipPath` and `TarPath` has this
1728
+ and not `Symlink`.
1729
+
1730
+ ### `links.Symlink`
1731
+
1732
+ ```python
1733
+ class Symlink(Remove, Protocol):
1734
+ def _symlink_to(self, target, target_is_directory=False) -> None: ...
1735
+ def _symlink_target(self, target) -> Self: ...
1736
+ def symlink_to(
1737
+ self, target, target_is_directory=False, *, force=False,
1738
+ ) -> None: ...
1739
+ ```
1740
+
1741
+ `_symlink_to()` (stub) creates a link, and `symlink_to()` is described under
1742
+ `Path`.
1743
+
1744
+ ### `links.Hardlink`
1745
+
1746
+ ```python
1747
+ class Hardlink(Stat, Protocol):
1748
+ def _hardlink_to(self, target) -> None: ...
1749
+ def hardlink_to(self, target) -> None: ...
1750
+ ```
1751
+
1752
+ `_hardlink_to(target)` (stub) makes this path a hard link to `target`, a
1753
+ path of this class; `hardlink_to()` turns a `str` target into a path of the
1754
+ same backend first. `LocalPath` has pathlib's `hardlink_to()` and, before
1755
+ 3.10, makes the link with `os.link`.
1756
+
1757
+ ### `resolve.Resolve`
1758
+
1759
+ ```python
1760
+ class Resolve(Readlink, Protocol):
1761
+ def _cwd(self) -> Self: ...
1762
+ def absolute(self) -> Self: ...
1763
+ def resolve(self, strict=False) -> Self: ...
1764
+ ```
1765
+
1766
+ `_cwd()` (stub) is the directory a relative path is relative to; `MemPath`
1767
+ and the tree paths answer with their root. `absolute()` is the path itself
1768
+ when it is absolute, else `_cwd()` joined with it. `resolve(strict=False)`
1769
+ makes it absolute, applies `..` and replaces every link by its `readlink()`
1770
+ text, at most 40 of them (a rooted link text restarts at the root). It is
1771
+ lexical where the class cannot read links. `strict=True` raises
1772
+ `FileNotFoundError` for a missing component. A link loop raises what `pathlib`
1773
+ raises on the running interpreter: `RuntimeError` before 3.13, and from 3.13
1774
+ `OSError(ELOOP)` when `strict` (otherwise the looping link is kept).
1775
+ `LocalPath` keeps pathlib's.
1776
+
1777
+ ### `transfer.Identity`
1778
+
1779
+ ```python
1780
+ class Identity(Stat, Protocol):
1781
+ def _same_filesystem(self, other) -> bool: ...
1782
+ def _node_key(self) -> tuple[object, tuple[str, ...]] | None: ...
1783
+ def samefile(self, other_path): ...
1784
+ ```
1785
+
1786
+ No primitives: the hooks are described under `Path`.
1787
+
1788
+ ### `transfer.Copy`
1789
+
1790
+ ```python
1791
+ class Copy(Identity, Create, Symlink, Readlink, BinaryOpen, Protocol):
1792
+ def copy(
1793
+ self, target, *, overwrite=False, follow_symlinks=True,
1794
+ preserve_metadata=True, recursive=False, ignore_error=None,
1795
+ progress=None, on_error=None,
1796
+ ): ...
1797
+ def copy_into(
1798
+ self, target_dir, *, overwrite=False, follow_symlinks=True,
1799
+ preserve_metadata=True, recursive=False, ignore_error=None,
1800
+ progress=None, on_error=None,
1801
+ ) -> Path: ...
1802
+ def _copy_from(
1803
+ self, source, follow_symlinks=True, preserve_metadata=False,
1804
+ ) -> None: ...
1805
+ def _coerce_target(self, target) -> Path: ...
1806
+ def _write_batch(self) -> ContextManager[object]: ...
1807
+ ```
1808
+
1809
+ No primitives: `copy()` is built from the protocols it lists. Contracts are
1810
+ under `Path`.
1811
+
1812
+ ### `transfer.Move`
1813
+
1814
+ ```python
1815
+ class Move(Copy, Remove, Protocol):
1816
+ def rename(self, target): ...
1817
+ def move(self, target, *, overwrite=False): ...
1818
+ def move_into(self, target_dir, *, overwrite=False) -> Path: ...
1819
+ def replace(self, target) -> Path: ...
1820
+ def _rename_compatible(self, target) -> bool: ...
1821
+ ```
1822
+
1823
+ `rename()` (stub). `move()` falls back to a copy and a removal where it is
1824
+ missing, so `supports(x, Move)` says whether the backend renames natively, not
1825
+ whether `move()` works.
1826
+
1827
+ ### `checksum.NativeChecksum`
1828
+
1829
+ ```python
1830
+ class NativeChecksum(Protocol):
1831
+ def checksum(self, algorithm="md5") -> str: ...
1832
+ def supported_checksums(self) -> FrozenSet[str]: ...
1833
+ ```
1834
+
1835
+ Opt-in (not on `Path`):
1836
+ `checksum()` MUST raise `NotImplementedError` whenever
1837
+ it cannot return a genuine content digest under exactly that algorithm
1838
+ (never a different algorithm, never a hash-shaped value such as a
1839
+ multipart ETag); callers catch only `NotImplementedError`. `supported_checksums()` (default
1840
+ empty) is advisory and never raises.
1841
+
1842
+ ## Syncing trees (`pathapi.sync`)
1843
+
1844
+ ```python
1845
+ class PathSyncer:
1846
+ EVENT_LOG_FORMAT = "[%s] Source:%s Target:%s DryRun:%s"
1847
+ def __init__(
1848
+ self, checksum=None, /, remove_missing=False, follow_symlinks=True,
1849
+ symlink_mode="preserve", hook=None, ignore_error=False,
1850
+ quick_check=True, preserve_mtime=True, follow_mounts=True,
1851
+ ) -> None: ...
1852
+ def sync(self, source, target, /, dry_run=False, ignore_error=None): ...
1853
+ def log(self, msg, *args): ...
1854
+ ```
1855
+
1856
+ One-way tree sync between any two
1857
+ `Path` implementations.
1858
+
1859
+ - `.sync()` — `None` uses
1860
+ the constructor policy; a bool or callable overrides it for this call.
1861
+ - `checksum=None`: a native digest when both sides share an algorithm
1862
+ (`NativeChecksum`), else a streamed md5 on both sides; a callable
1863
+ `checksum(entry: PathAndStat)` is compared with `==`.
1864
+ - `quick_check=True`: when either side is non-local (`is_local()`), equal
1865
+ `st_size` and `st_mtime` skip the checksum; `st_mtime == 0` never matches;
1866
+ a mismatch still checksums. Times match at the resolution of the coarser
1867
+ side: in whole seconds when either has no fractional part (SFTP, FTP and the
1868
+ object stores report seconds), else to a microsecond. So 100.0 and 100.9
1869
+ match, 100.0 and 101.0 do not, and neither do 100.25 and 100.5.
1870
+ - `preserve_mtime=True`: after it copies a file, the sync gives the target the
1871
+ source's `st_mtime` through `_utime()` (not for a directory or a link),
1872
+ so the next run's quick check can match. A source that reports no time and a
1873
+ target that cannot set one (`NotImplementedError`), or refuses
1874
+ (`OSError`), keep the time the copy gave them, and the sync does not
1875
+ report it. `False` leaves every copy with the time it was written at.
1876
+ - A `sync()` that is not a dry run runs inside the root target's
1877
+ `_write_batch()`: onto a zip archive the whole tree is one rewrite
1878
+ of the file, and a sync that raises leaves the archive as it was.
1879
+ - `hook(source: PathAndStat, target: PathAndStat, event: SyncEvent,
1880
+ dry_run: bool)` is called for every event (structural ones included) with
1881
+ the call's `dry_run`.
1882
+ - `ignore_error`: bool or `callable(error, source, target, event) -> bool`
1883
+ (`source` and `target` are `PathAndStat`), offered once per error, and a
1884
+ callable that raises ends the sync with its own exception, which is not
1885
+ offered back to it; a tolerated error is logged at WARNING on logger
1886
+ `pathapi.sync` and reported to `hook` as `SyncEvent.Error`.
1887
+ `.log(msg, *args)` (INFO on the same logger) is overridable by a subclass;
1888
+ `EVENT_LOG_FORMAT` (`"[%s] Source:%s Target:%s DryRun:%s"`) is the format
1889
+ it receives for each event.
1890
+ - Safety, all through `ignore_error`: a missing root `source` →
1891
+ `FileNotFoundError`; overlapping `source`/`target` (one inside the other,
1892
+ or two names of one file: decided like `copy()`/`move()` decide "same
1893
+ file", so two files of this machine whatever their classes, two spellings
1894
+ of one `MemPath` or archive node, and else one type that
1895
+ `_same_filesystem()` places together) → `ValueError`; a child
1896
+ name that would leave `target` (`..`, or on a Windows target `\`, `:`, a
1897
+ trailing dot or space, a device name) → `ValueError`, decided on the name
1898
+ as listed, in the source's listing and the target's alike; a symlink
1899
+ inside `target` is replaced, never followed, and so is a Windows
1900
+ junction below its root (event `TypeMismatch`; `rmdir()` removes the
1901
+ junction and nothing behind it). A mount point inside `target` is part
1902
+ of the tree and is synced into (`follow_mounts=True`, the default).
1903
+ With `follow_mounts=False` it is an error for that entry instead:
1904
+ `OSError(EXDEV)` naming it, offered to `ignore_error` with event
1905
+ `TypeMismatch`, and nothing behind it is listed, written or removed, in a
1906
+ dry run too. A junction is replaced as above under either value; the root
1907
+ `target` is used as given.
1908
+ Listing entries with unknown
1909
+ stats are re-stat'd. A name the source lists is never "missing": a listed
1910
+ link that cannot be resolved (dangling, a loop, an unmounted volume) is an
1911
+ error for that entry (`FileNotFoundError` or the stat's `OSError`, event
1912
+ `SyncStart`) and the same-named target entry is left alone, whatever
1913
+ `remove_missing` is; only a name the listing no longer holds is removed.
1914
+ - Cost: each root is stat'd once and each directory of each side is listed
1915
+ once; the listing's stat answers for every entry that is not a link, so a
1916
+ sync that changes nothing makes no other `stat()` call on a backend whose
1917
+ `_scandir()` returns stats. A link, an entry whose listing stat is `None`
1918
+ and a target name the listing did not report are stat'd, one call each. A
1919
+ backend with no `_scandir()` of its own pays the default's `stat()` per
1920
+ entry of every directory it lists.
1921
+ - `remove_missing=True` deletes target entries absent from the source. With
1922
+ `False`, a non-empty target directory whose source became a file or link
1923
+ is kept (`IsADirectoryError`, event `TypeMismatch`).
1924
+ - `follow_symlinks=False` + `symlink_mode="preserve"` recreates source links
1925
+ with the raw `readlink()` text (target must implement `symlink_to()`, else
1926
+ `NotImplementedError` through `ignore_error`, in a dry run too);
1927
+ `"reject"` raises `NotImplementedError`.
1928
+ - Changed files are written to a hidden temporary sibling
1929
+ (`.NAME.<12 hex>.pathapi-tmp`, NAME cut to keep the whole within 255
1930
+ bytes) and renamed over the target where the target supports `rename()`;
1931
+ where `rename()` refuses an existing target the old file is removed first,
1932
+ and if the second rename fails the new version is kept under its
1933
+ temporary name and the error (an `OSError`) names it. A name of exactly
1934
+ that form is the library's own: never a source, never removed as
1935
+ "missing"; a stale one (not being written by this process, modification
1936
+ time known and over a day old) is removed from a target directory when a
1937
+ changed file or link in it is next written, and by `remove_missing=True`
1938
+ (event `RemovedMissing`). FIFOs, sockets and devices are skipped. A dry run makes the same
1939
+ decisions without changes.
1940
+
1941
+ ### `SyncEvent`
1942
+
1943
+ ```python
1944
+ class SyncEvent(Enum):
1945
+ Copy = ...
1946
+ RemovedMissing = ...
1947
+ Synced = ...
1948
+ CreatedDirectory = ...
1949
+ SyncStart = ...
1950
+ TypeMismatch = ...
1951
+ CheckTargetChild = ...
1952
+ CheckTargetChildren = ...
1953
+ SyncChild = ...
1954
+ SyncChildren = ...
1955
+ Symlink = ...
1956
+ Compare = ...
1957
+ Skipped = ...
1958
+ Error = ...
1959
+ ```
1960
+
1961
+ Members: `Copy`, `RemovedMissing`, `Synced`,
1962
+ `CreatedDirectory`, `SyncStart`, `TypeMismatch`, `CheckTargetChild`,
1963
+ `CheckTargetChildren`, `SyncChild`, `SyncChildren`, `Symlink`, `Compare`
1964
+ (a comparison failed; only passed to `ignore_error`), `Skipped` (not a
1965
+ file, directory or link), `Error` (an error was tolerated).
1966
+
1967
+ ### `PathAndStat`
1968
+
1969
+ ```python
1970
+ class PathAndStat:
1971
+ def __init__(self, path, *, follow_symlink=True) -> None: ...
1972
+ @classmethod
1973
+ def from_stat(cls, path, stat) -> PathAndStat: ...
1974
+ def exists(self): ...
1975
+ def refresh(self, follow_symlink=True): ...
1976
+ ```
1977
+
1978
+ `path` plus cached
1979
+ `stat` (`FileStat | None`); `from_stat(path, stat)`, `exists()`,
1980
+ `refresh(follow_symlink=True)`; `is_*` attributes delegate to the stat
1981
+ (always-`False` callables when missing).
1982
+
1983
+ ## Globbing (`pathapi.glob`)
1984
+
1985
+ ```python
1986
+ def glob(
1987
+ path, *, dironly=False, root_dir=None, recursive=False,
1988
+ include_hidden=False, case_sensitive=None, native=True, on_error=None,
1989
+ bound_loops=False, recurse_symlinks=False,
1990
+ ) -> Iterator[_Globable]: ...
1991
+ def compile_pattern(pat, case_sensitive): ...
1992
+ WILCARD_PATTERN = WILDCARD_PATTERN
1993
+ def parse_pattern(pattern, *, native=True) -> Tuple[List[str], bool]: ...
1994
+ def select(
1995
+ base, parts, *, dironly=False, recursive=True, include_hidden=True,
1996
+ case_sensitive=None, native=True, on_error=None, bound_loops=False,
1997
+ recurse_symlinks=False,
1998
+ ) -> Iterator[_Globable]: ...
1999
+ def full_match(segments, pattern, case_sensitive) -> bool: ...
2000
+
2001
+ RECURSIVE = '**'
2002
+ ```
2003
+
2004
+ `glob.glob()`: the pattern
2005
+ is itself a path (`LocalPath("/x/**/*.py")`); like stdlib `glob`, hidden
2006
+ names need `include_hidden=True`. It is split at the first wildcard, never
2007
+ in the path's anchor (a drive, `\\?\C:\`); a bad pattern raises when it is
2008
+ called, the selection is lazy. `glob.parse_pattern()` (`ValueError`/`NonRelativePatternError`),
2009
+ `glob.select()` (the engine behind
2010
+ `Path.glob()`), `glob.full_match()`,
2011
+ `glob.RECURSIVE = "**"`, `glob.compile_pattern()` (one shell-style component
2012
+ as a compiled regular expression, cached), `glob.ANY_PATTERN` (a lone `*`) and
2013
+ `glob.WILDCARD_PATTERN` (a wildcard character), both compiled expressions, and
2014
+ `glob.WILCARD_PATTERN`, the old misspelling of `WILDCARD_PATTERN`.
2015
+
2016
+ ## Checksums (`pathapi.checksum`)
2017
+
2018
+ ```python
2019
+ def md5(path, chunk_size=65536) -> str: ...
2020
+ def sha256(path, chunk_size=65536) -> str: ...
2021
+ def stream(path, algorithm="md5", chunk_size=65536) -> str: ...
2022
+ def native(path, algorithm="md5") -> str | None: ...
2023
+ ```
2024
+
2025
+ `md5()`, `sha256()`, `stream()`
2026
+ (streamed through `open("rb")`, `usedforsecurity=False`),
2027
+ `native()` (`None` when the protocol is
2028
+ missing or raises `NotImplementedError`).
2029
+
2030
+ ## Providers (`pathapi.providers`)
2031
+
2032
+ ```python
2033
+ def is_safe_child_name(name, *, windows=False) -> bool: ...
2034
+ def is_windows_flavoured(path) -> bool: ...
2035
+ def name_stem(name) -> str: ...
2036
+ def name_suffix(name) -> str: ...
2037
+ def name_suffixes(name) -> list[str]: ...
2038
+ class TreePath(Path):
2039
+ @classmethod
2040
+ def _join(cls, segments) -> tuple[str, TreePath | None]: ...
2041
+ def _rebuild(self, text) -> Self: ...
2042
+ segments; parts; parent
2043
+ def with_segments(self, *segments) -> Self: ...
2044
+ def relative_to(self, other, *, walk_up=False) -> Self: ...
2045
+ def parse_mode(mode) -> int: ...
2046
+ def parse_owner(uid, gid) -> Tuple[Optional[int], Optional[int]]: ...
2047
+ def as_error_handler(
2048
+ ignore_error, *, default=False,
2049
+ ) -> Callable[..., bool]: ...
2050
+ def as_error_predicate(on_error) -> OfferedOnce: ...
2051
+ class OfferedOnce:
2052
+ def __call__(self, error, path) -> bool: ...
2053
+ def notimplemented(method): ...
2054
+ def derived_from(*primitives): ...
2055
+ def primitive(method): ...
2056
+
2057
+ class DiscardOnError:
2058
+ def discard(self) -> None: ...
2059
+
2060
+ class CommitOnClose(DiscardOnError, BytesIO):
2061
+ def __init__(self, commit, initial=None): ...
2062
+ def discard(self) -> None: ...
2063
+
2064
+ class LRU:
2065
+ def __init__(self, func, maxsize=128, on_evict=None): ...
2066
+ def discard(self, *args) -> bool: ...
2067
+ def invalidate(self, *args) -> V: ...
2068
+ maxsize
2069
+ UNCHANGED = None
2070
+ ```
2071
+
2072
+ - **`is_safe_child_name()`** — `False` for a
2073
+ non-`str`, `""`, `.`, `..`, or a name containing `/` or NUL; with
2074
+ `windows=True` also `\`, `:`, a trailing dot or space, and the reserved
2075
+ device names `CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9` (any
2076
+ case, with or without an extension: `nul.txt`).
2077
+ **`is_windows_flavoured()`**
2078
+ — `True` for a `PureWindowsPath` or a path whose `filepath` is one.
2079
+ - **`TreePath`** — the base of a path whose names are POSIX segments below the
2080
+ root of a tree a backend serves: a remote directory, the members of an
2081
+ archive. `FTPPath`, `ZipPath`, `TarPath` and `ArchivePath` are built on it.
2082
+ It holds the names only: joined and normalized like `PurePosixPath` (empty
2083
+ and `.` segments collapse, an absolute argument restarts the join), and a
2084
+ relative and a rooted spelling name the same node because the tree has no
2085
+ working directory (`_cwd()` answers the root). A backend where the two
2086
+ spellings are different nodes, because it has a working directory, does not
2087
+ use `TreePath`, or overrides `_cwd()`. A subclass supplies the constructor, the backend it
2088
+ carries and `_rebuild(text)` (a path of its class and backend for a joined
2089
+ text); `_join(segments)` returns the joined text and the first segment that
2090
+ is a path of the same family, whose backend a join keeps.
2091
+ - **`name_stem()`, `name_suffix()`, `name_suffixes()`** — one path component
2092
+ split as `pathlib` of the running interpreter splits it (`"a.tar.gz"` →
2093
+ `"a.tar"`, `".gz"`, `[".tar", ".gz"]`; `".bashrc"` has no suffix). From
2094
+ Python 3.14 a trailing dot is a suffix (`"a."` → `"."`), as it is there.
2095
+ A class that computes `stem` and `suffix` itself calls these to agree with
2096
+ `Pathname`.
2097
+ - **`LRU`** — thread-safe memoizing cache,
2098
+ called like `func`. `on_evict(key_tuple, value)` runs for every dropped
2099
+ value (overflow, `maxsize` shrink, `invalidate`/`discard`, a losing
2100
+ concurrent miss), outside the lock, exceptions suppressed. `discard(*args)
2101
+ -> bool`, `invalidate(*args)` (discard + recompute), settable `maxsize`.
2102
+ `maxsize=None` is unbounded; `maxsize=0` (or less) stores nothing, so every
2103
+ call runs `func` and its result never reaches `on_evict`.
2104
+ - **`parse_mode()`** — `int` passes through; `str` is octal (optional
2105
+ `0o`), any non-octal digit → `ValueError`. A `bool` → `TypeError`; a number
2106
+ below 0 or above `0o177777` (the 16 bits of a `st_mode`, type bits
2107
+ included) → `ValueError`.
2108
+ - **`parse_owner()`** — `-1` → `None`;
2109
+ `str` names pass through; a `bool` → `TypeError`, an id outside
2110
+ 0..2**32-1 → `ValueError`. **`UNCHANGED = None`**.
2111
+ - **`as_error_handler()`** — a
2112
+ callable passes through untouched (arity is the call site's: `rm` and
2113
+ `copy(on_error=)` `(error, path)`, `copy(ignore_error=)` `(error)`,
2114
+ `PathSyncer` `(error, source, target, event)`); a bool (or `None` →
2115
+ `default`) becomes a constant-returning callable.
2116
+ **`as_error_predicate()`** — `on_error` as an `OfferedOnce`: a bool becomes a
2117
+ constant answer, a callable is called as `(error, path)`, and an
2118
+ `OfferedOnce` is returned as it is. An `OfferedOnce` answers `False`, without
2119
+ calling the predicate, for an error it already answered `False` for and
2120
+ for an exception the predicate itself raised, so a recursive operation that
2121
+ passes it down never offers one failure twice.
2122
+ - **`notimplemented()`** — decorator that marks a method as the stub of a
2123
+ primitive, bare or with a reason: `@notimplemented` or `@notimplemented("why")`.
2124
+ Calling it raises `NotImplementedError` with the reason, or
2125
+ `"Method not implemented: <name>"`. `protocols.supports()` reads it as absent.
2126
+ - **`primitive()`** — decorator that marks a function as the default of a
2127
+ primitive that raises an error of its own (a `data:` URI's `_listdir()`
2128
+ raises `NotADirectoryError`): `supports()` reads it as absent, as it reads
2129
+ a `notimplemented` stub.
2130
+ - **`derived_from(*primitives)`** — decorator for a method that only wraps
2131
+ other primitives of its class, such as an `iterdir()` that lists through
2132
+ `_listdir()`: `supports()` counts the method as implemented only when one
2133
+ of the named primitives is, so a base class can define the wrapper without
2134
+ claiming a protocol its subclasses do not have.
2135
+ - **`CommitOnClose`** and **`DiscardOnError`** — the write buffer that commits
2136
+ when it is closed, and the mixin that makes a failed `with` block commit
2137
+ nothing; see "For path implementers".
2138
+
2139
+ ## Testing helpers (`pathapi.testing`, needs `pytest`)
2140
+
2141
+ ### `FIXTURE_TREE`
2142
+
2143
+ ```python
2144
+ FIXTURE_TREE = {
2145
+ "a.txt": "a",
2146
+ "b.py": "b",
2147
+ ".hidden.txt": "hidden",
2148
+ "sub": None,
2149
+ "sub/c.py": "c",
2150
+ "sub/nested": None,
2151
+ "sub/nested/d.py": "d",
2152
+ "empty_dir": None,
2153
+ }
2154
+ ```
2155
+
2156
+ Directories are the entries whose value is `None`.
2157
+
2158
+ ### `populate_fixture_tree`
2159
+
2160
+ ```python
2161
+ def populate_fixture_tree(root): ...
2162
+ ```
2163
+
2164
+ Builds `FIXTURE_TREE` under an
2165
+ existing empty directory through the path's own `mkdir()`/`write_text()`
2166
+ (any `Path`, or a stdlib `pathlib.Path`).
2167
+
2168
+ ### `PurePathContract`
2169
+
2170
+ ```python
2171
+ class PurePathContract: ...
2172
+ ```
2173
+
2174
+ Name/suffix/stem, parents, join, `match()`; needs a
2175
+ `root` fixture only.
2176
+
2177
+ ### `ReadPathContract`
2178
+
2179
+ ```python
2180
+ class ReadPathContract(PurePathContract):
2181
+ supports_listing = None
2182
+ supports_empty_directories = True
2183
+ distinguishes_file_types = True
2184
+ supports_pickle = True
2185
+ ```
2186
+
2187
+ Exists/types, reads and read
2188
+ modes, partial `read(n)`, `iterdir()`, `stat()` (the size of a longer file, a
2189
+ directory told from a file, a numeric `st_mtime`), `_scandir()` agreeing with
2190
+ `stat()` for every child, nothing existing below a file, `glob()`/`rglob()`,
2191
+ `walk()`, with pathlib's exception types, and a pickle round trip that keeps
2192
+ `==` and `hash()`. Capability attributes: `supports_listing`,
2193
+ `supports_empty_directories`, `distinguishes_file_types`, `supports_pickle`.
2194
+
2195
+ ### `PathContract`
2196
+
2197
+ ```python
2198
+ class PathContract(ReadPathContract):
2199
+ supports_rename = None
2200
+ supports_append = True
2201
+ supports_exclusive_create = True
2202
+ enforces_directory_hierarchy = True
2203
+ supports_mkdir = None
2204
+ supports_delete = None
2205
+ supports_move = True
2206
+ supports_unusual_names = True
2207
+ supports_utime = None
2208
+ supports_question_mark_names = False
2209
+ ```
2210
+
2211
+ `mkdir()`, writes and write/append/
2212
+ exclusive modes, a payload of about 290 KiB holding every byte value and
2213
+ runs of CR/LF written whole and in pieces and read back whole and in parts,
2214
+ names holding a space, `#`, `%`, `+`, `&`, `=` and mixed case written,
2215
+ listed, renamed and removed as written, `unlink()`, `rmdir()`, `rm()` (also
2216
+ with `follow_symlinks=`/`follow_binds=`: an implementation that overrides
2217
+ `rm()` must accept both), `copy()` (recursive), `move()`, `rename()` (also
2218
+ onto a non-empty directory, which must raise), `touch()`, copying a file
2219
+ onto a separately built spelling of itself (`OSError`, content kept) and
2220
+ `_same_filesystem()` within one root. Capability attributes:
2221
+ `supports_rename`, `supports_append`, `supports_exclusive_create`,
2222
+ `enforces_directory_hierarchy` (also covers rename onto a directory),
2223
+ `supports_mkdir` (every test that creates a directory), `supports_delete`
2224
+ (`unlink()`, `rmdir()`, `rm()`, and the overwriting half of `copy()`),
2225
+ `supports_move` (`move()`, by `rename()` or copy and delete),
2226
+ `supports_unusual_names`, `supports_utime` (`_utime()` sets an existing
2227
+ file's modification time and `touch()` moves it; set it `False` for a backend
2228
+ that cannot set a time and leaves `_utime()` raising `NotImplementedError`),
2229
+ and `supports_question_mark_names` (names holding `?`; **False by default**,
2230
+ because a Windows file system cannot store one: set it `True` for a URI
2231
+ scheme or a remote store).
2232
+
2233
+ - Five switches default to `None`, which means "ask `protocols.supports()` on
2234
+ `root`": `supports_listing` (`Listing`), `supports_mkdir` (`Create`),
2235
+ `supports_delete` (`Remove`), `supports_utime` (`Utime`) and `supports_rename`
2236
+ (`Move`). A class without the protocol skips those tests with no switch set,
2237
+ and the skip reason names it (`MyPath does not support Remove`). Set the
2238
+ attribute `True` or `False` on the test class to override the answer, for a
2239
+ class that implements the protocol but cannot honour the test.
2240
+
2241
+ - Both I/O contracts need `root` to be a **fresh, function-scoped** directory
2242
+ populated with `populate_fixture_tree()` (which uses `mkdir()` and
2243
+ `write_text()`: a backend without them seeds `root` another way); two
2244
+ contract classes must not share one. Capability attributes default to
2245
+ `True` except `supports_question_mark_names`; setting one `False` makes the
2246
+ tests it covers skip. `DIRECTORY_ERRORS = (IsADirectoryError,
2247
+ PermissionError)` and `NOT_EMPTY_ERRNOS = (ENOTEMPTY, EEXIST)` are the
2248
+ accepted platform variants.
2249
+
2250
+ ```python
2251
+ import pytest
2252
+ from pathapi import LocalPath as MyPath # your Path subclass
2253
+ from pathapi.testing import PathContract, populate_fixture_tree
2254
+
2255
+ class TestMyPath(PathContract):
2256
+ @pytest.fixture
2257
+ def root(self, tmp_path):
2258
+ return populate_fixture_tree(MyPath(tmp_path))
2259
+ ```
2260
+
2261
+ ### Testing support: the HTTP transport suite (`pathapi.http.testing`, needs `pytest`)
2262
+
2263
+ This is testing support, not part of the stable API: it is importable and
2264
+ documented here, but its contents, including test names, fixtures and helper
2265
+ signatures, may change in any release. `pathapi.http` never imports it and
2266
+ the root does not export it.
2267
+
2268
+ ```python
2269
+ # pathapi.http.testing
2270
+ def suite_directory() -> pathlib.Path: ...
2271
+ def run(arguments=None) -> int: ...
2272
+ ```
2273
+
2274
+ A distribution that provides a transport holds it to the promises of the
2275
+ built-in one by running the tests `pathapi` runs over its own, with the
2276
+ distribution and `pytest` installed:
2277
+
2278
+ ```
2279
+ python -m pathapi.http.testing --http-transports requests -q
2280
+ ```
2281
+
2282
+ Everything after `python -m pathapi.http.testing` goes to pytest.
2283
+ `--http-transports` takes names that `transport_names()` lists, separated by
2284
+ commas (default `builtin`): each test that depends on the transport runs once
2285
+ per name, with `PATHAPI_HTTP_TRANSPORT` set to it, so the clients the
2286
+ test builds use `get_transport(name)()`. The tests are `HTTPClient` over
2287
+ loopback servers (redirects and the rule for `Authorization`, replies cut short
2288
+ or too large, errors, headers) and the transport contract against servers
2289
+ scripted at the byte level (what the server received is what is asserted). The
2290
+ modules also hold tests that do not depend on the transport; they pass whatever
2291
+ the name. Every connection stays on the loopback interface, a test that tries
2292
+ another host fails, and the proxy and certificate settings of the environment
2293
+ are removed for the run. The TLS tests need `cryptography` and skip without it.
2294
+ Importing `pathapi.http.testing` loads `pathapi.http` but not `pytest`.
2295
+
2296
+ - `run(arguments=None)` runs the suite with pytest and returns the exit status;
2297
+ it raises `ImportError` without pytest.
2298
+ - `suite_directory()` is the directory that holds the test modules,
2299
+ `MODULES` (their file names) and `HELPERS` (the files they import):
2300
+ `pathapi/http/testing/suite` in an installed package, `tests/transport`
2301
+ in a source checkout; `FileNotFoundError` when neither is there.
2302
+
2303
+ ## Exceptions
2304
+
2305
+ A failure is the exception `pathlib` raises for the same condition
2306
+ (`FileNotFoundError`, `FileExistsError`, `IsADirectoryError`,
2307
+ `NotADirectoryError`, `PermissionError`, or an `OSError` with an `errno`),
2308
+ `NotImplementedError` for an operation a class does not support, and
2309
+ `ValueError`/`TypeError` for a bad argument. Those the package defines are
2310
+ listed here; the conditions that raise them are in the entries.
2311
+
2312
+ ### `pathapi.exceptions` (also exported from `pathapi`)
2313
+
2314
+ ```python
2315
+ class PathError(Exception): ...
2316
+ class NonRelativePatternError(PathError, NotImplementedError, ValueError): ...
2317
+ ```
2318
+
2319
+ `PathError` is the base of every exception the core defines for a failure at
2320
+ run time that no builtin describes; an I/O failure is never a `PathError`.
2321
+ `glob()` raises `NonRelativePatternError` for an absolute pattern, and it is
2322
+ caught as `PathError`, `NotImplementedError` or `ValueError`.
2323
+
2324
+ ## Environment variables
2325
+
2326
+ The package reads one.
2327
+
2328
+ ### `PATHAPI_HTTP_TRANSPORT` (`pathapi.http`)
2329
+
2330
+ `builtin` (the default when unset), or the name of a
2331
+ transport an installed distribution declares in the group
2332
+ `pathapi.http_transports` (`requests`, from `pathapi-requests`): the
2333
+ transport an `HTTPClient` builds when it was given none. `requests` without
2334
+ that distribution raises `ImportError` naming it; the value is case-sensitive,
2335
+ and an empty or unrecognised one raises `ValueError` naming the choices
2336
+ (`transport_names()`). It is read when a client first needs its transport,
2337
+ once per client, so a change reaches clients that need theirs afterwards.
2338
+
2339
+ ## Gotchas
2340
+
2341
+ - A `str` argument to `is_relative_to()` is parsed standalone via
2342
+ `self.with_segments(other)`, which keeps per-instance state (a `MemPath`
2343
+ backend). Normalize strings the same way in subclasses: `type(self)(x)`
2344
+ drops that state.
2345
+
2346
+ - **A binding is not a symlink.** `is_junction()` (pathlib 3.12 parity:
2347
+ a Windows junction) and `is_mount()` (a mount point, bind mounts
2348
+ included) report a directory that is another tree's second NAME.
2349
+ Neither is a symlink: `is_symlink()` is False, `readlink()` says
2350
+ nothing, and a non-following stat calls it an ordinary directory — which
2351
+ is why a symlink check cannot protect a walk from one. `is_dir_binding()`
2352
+ is the pair, and is what `rm(recursive=True)` consults: it removes the
2353
+ binding itself rather than the contents behind it (`rmdir()` on a live
2354
+ mount fails instead of emptying the mounted filesystem).
2355
+ Default False everywhere; `LocalPath` answers for real.