pathapi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. pathapi/AGENTS.md +2355 -0
  2. pathapi/README.md +205 -0
  3. pathapi/__init__.py +62 -0
  4. pathapi/_commit.py +116 -0
  5. pathapi/_discard.py +92 -0
  6. pathapi/_errorpolicy.py +106 -0
  7. pathapi/_filestat.py +194 -0
  8. pathapi/_lru.py +138 -0
  9. pathapi/_names.py +70 -0
  10. pathapi/_permissions.py +102 -0
  11. pathapi/_stubs.py +76 -0
  12. pathapi/_tree.py +136 -0
  13. pathapi/archive/__init__.py +44 -0
  14. pathapi/archive/_backend.py +393 -0
  15. pathapi/archive/_names.py +63 -0
  16. pathapi/archive/_pack.py +394 -0
  17. pathapi/archive/_path.py +352 -0
  18. pathapi/archive/_snapshot.py +269 -0
  19. pathapi/archive/_tar.py +126 -0
  20. pathapi/archive/_write.py +359 -0
  21. pathapi/archive/_zip.py +563 -0
  22. pathapi/archive/_zipentries.py +272 -0
  23. pathapi/checksum.py +61 -0
  24. pathapi/exceptions.py +22 -0
  25. pathapi/fspath.py +513 -0
  26. pathapi/ftp/__init__.py +27 -0
  27. pathapi/ftp/_backend.py +418 -0
  28. pathapi/ftp/_facts.py +117 -0
  29. pathapi/ftp/_path.py +582 -0
  30. pathapi/glob/__init__.py +27 -0
  31. pathapi/glob/_entries.py +103 -0
  32. pathapi/glob/_patterns.py +164 -0
  33. pathapi/glob/_recursion.py +180 -0
  34. pathapi/glob/_selection.py +303 -0
  35. pathapi/http/__init__.py +84 -0
  36. pathapi/http/_body.py +156 -0
  37. pathapi/http/_builtin.py +553 -0
  38. pathapi/http/_client.py +211 -0
  39. pathapi/http/_errors.py +79 -0
  40. pathapi/http/_follow.py +230 -0
  41. pathapi/http/_headers.py +233 -0
  42. pathapi/http/_pool.py +169 -0
  43. pathapi/http/_proxies.py +142 -0
  44. pathapi/http/_response.py +255 -0
  45. pathapi/http/_settings.py +12 -0
  46. pathapi/http/_status.py +68 -0
  47. pathapi/http/_transports.py +173 -0
  48. pathapi/http/testing/__init__.py +80 -0
  49. pathapi/http/testing/__main__.py +10 -0
  50. pathapi/http/testing/suite/conftest.py +98 -0
  51. pathapi/http/testing/suite/test_client_default.py +285 -0
  52. pathapi/http/testing/suite/test_client_errors.py +181 -0
  53. pathapi/http/testing/suite/test_client_peer_text.py +86 -0
  54. pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
  55. pathapi/http/testing/suite/test_client_redirects.py +231 -0
  56. pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
  57. pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
  58. pathapi/http/testing/suite/test_transport_body.py +302 -0
  59. pathapi/http/testing/suite/test_transport_send.py +662 -0
  60. pathapi/http/testing/suite/transport_fakes.py +133 -0
  61. pathapi/http/testing/suite/transport_guard.py +174 -0
  62. pathapi/http/testing/suite/transport_wire.py +511 -0
  63. pathapi/mempath.py +438 -0
  64. pathapi/path/__init__.py +19 -0
  65. pathapi/path/_info.py +10 -0
  66. pathapi/path/_path.py +198 -0
  67. pathapi/path/_pure.py +522 -0
  68. pathapi/path/_relation.py +10 -0
  69. pathapi/path/_transfer.py +10 -0
  70. pathapi/protocols/__init__.py +35 -0
  71. pathapi/protocols/_bundles.py +22 -0
  72. pathapi/protocols/_errors.py +12 -0
  73. pathapi/protocols/_info.py +65 -0
  74. pathapi/protocols/_relation.py +191 -0
  75. pathapi/protocols/_removal.py +69 -0
  76. pathapi/protocols/_support.py +122 -0
  77. pathapi/protocols/_transfer.py +100 -0
  78. pathapi/protocols/checksum.py +80 -0
  79. pathapi/protocols/fs.py +429 -0
  80. pathapi/protocols/io.py +224 -0
  81. pathapi/protocols/links.py +135 -0
  82. pathapi/protocols/pure.py +31 -0
  83. pathapi/protocols/resolve.py +97 -0
  84. pathapi/protocols/transfer.py +656 -0
  85. pathapi/protocols/tree.py +492 -0
  86. pathapi/providers.py +48 -0
  87. pathapi/py.typed +1 -0
  88. pathapi/sync/__init__.py +8 -0
  89. pathapi/sync/_compare.py +157 -0
  90. pathapi/sync/_events.py +97 -0
  91. pathapi/sync/_links.py +93 -0
  92. pathapi/sync/_offer.py +33 -0
  93. pathapi/sync/_paths.py +50 -0
  94. pathapi/sync/_replace.py +98 -0
  95. pathapi/sync/_syncer.py +989 -0
  96. pathapi/testing/__init__.py +96 -0
  97. pathapi/testing/_contract.py +522 -0
  98. pathapi/testing/_fixtures.py +83 -0
  99. pathapi/testing/_read.py +279 -0
  100. pathapi-0.1.0.dist-info/METADATA +242 -0
  101. pathapi-0.1.0.dist-info/RECORD +104 -0
  102. pathapi-0.1.0.dist-info/WHEEL +4 -0
  103. pathapi-0.1.0.dist-info/entry_points.txt +2 -0
  104. pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,352 @@
1
+ from __future__ import annotations
2
+
3
+ import errno as _errno
4
+ import os as _os
5
+ import urllib.parse as _urlparse
6
+
7
+ from .._tree import TreePath
8
+ from ..fspath import LocalPath
9
+ from ..path import Path
10
+ from .._names import is_safe_child_name
11
+ from ._backend import (
12
+ _ArchiveBackend,
13
+ _UndecidedBackend,
14
+ _get_backend,
15
+ )
16
+ from ._names import _normalize_member_name
17
+ from ._snapshot import LinkTarget, _Snapshot, _Unresolved
18
+ from ._tar import _TarBackend
19
+ from ._write import _WritableMembers
20
+ from ._zip import _ZipBackend
21
+
22
+ _SEP = "!/"
23
+
24
+
25
+ def _outer_file(archive: object) -> Path:
26
+ """The archive file as a `Path`: a path of this library as it is, a `str`
27
+ or any other `os.PathLike` as a `LocalPath`."""
28
+ if isinstance(archive, Path):
29
+ return archive
30
+ if isinstance(archive, (str, _os.PathLike)):
31
+ return LocalPath(archive)
32
+ raise TypeError(
33
+ "archive should be a Path, a str or an os.PathLike, "
34
+ f"not {type(archive).__name__!r}"
35
+ )
36
+
37
+
38
+ def _rebuild_member(cls, text, archive):
39
+ return cls(text, archive=archive)
40
+
41
+
42
+ class ArchivePath(_WritableMembers, TreePath):
43
+ """A member path of an archive file, which is any `Path` (a `LocalPath`, a
44
+ `MemPath`, a path on a remote store). The segments are the member path; the
45
+ archive's format, zip or tar, is read from its extension or its first bytes
46
+ on first use, and `ZipPath` and `TarPath` fix it. Building a path, printing
47
+ it and joining names onto it read nothing.
48
+
49
+ A zip archive that is a local file can be written (new members,
50
+ `unlink`/`rmdir`/`rename`, `batch()`); every other archive is read-only.
51
+ One backend serves each archive file and format, so paths built separately
52
+ for one file see each other's writes."""
53
+
54
+ __slots__ = ("_backend",)
55
+ _SCHEME = "archive"
56
+ _backend_cls: "type | None" = None
57
+
58
+ def __init__(self, *segments: "str | ArchivePath", archive: object = None):
59
+ text, donor = self._join(segments)
60
+ if archive is not None:
61
+ backend = self.backend_for(archive)
62
+ elif donor is not None:
63
+ backend = donor._backend
64
+ else:
65
+ raise TypeError("an archive path needs archive=")
66
+ self._segments = self._parse(text)
67
+ self._backend = backend
68
+
69
+ @classmethod
70
+ def backend_for(cls, archive: object, *, shared: bool = True, identity=None):
71
+ """The backend that serves `archive`, to pass as `archive=` of any path
72
+ of the class: the backend of another path of the same archive is
73
+ returned as it is. With `shared` the one backend of that archive file
74
+ and format (found by its real path for a local file, else by `identity`
75
+ or the node or URI of the path); with `shared=False` a backend of its
76
+ own. Reads nothing: the format of `ArchivePath` is decided on first
77
+ use."""
78
+ if isinstance(archive, (_ArchiveBackend, _UndecidedBackend)):
79
+ return archive
80
+ outer = _outer_file(archive)
81
+ if cls._backend_cls is None:
82
+ return _UndecidedBackend(outer, shared, identity)
83
+ if not shared:
84
+ return cls._backend_cls(outer)
85
+ return _get_backend(cls._backend_cls, outer, identity)
86
+
87
+ def _rebuild(self, text: str) -> "ArchivePath":
88
+ return type(self)(text, archive=self._backend)
89
+
90
+ def __reduce__(self):
91
+ # The text and the archive file; the handle is found again by the
92
+ # registry, and a pickle holds no open file.
93
+ return _rebuild_member, (type(self), self.as_posix(), self.archive)
94
+
95
+ def __repr__(self) -> str:
96
+ return f"{type(self).__name__}({self.as_posix()!r}, archive={self.archive!r})"
97
+
98
+ @property
99
+ def archive(self) -> Path:
100
+ """The archive file. Never decides the format."""
101
+ return self._backend.outer
102
+
103
+ @property
104
+ def backend(self) -> _ArchiveBackend:
105
+ """The handle on the archive, shared by every path of it: settles the
106
+ format of an `ArchivePath` on the first call, which reads the archive
107
+ when its extension does not say."""
108
+ backend = self._backend
109
+ if isinstance(backend, _UndecidedBackend):
110
+ backend = backend.resolve()
111
+ return backend
112
+
113
+ def refresh(self) -> None:
114
+ """Forgets what is held of the archive file, so the next use reads it
115
+ again. A non-local archive is read once and kept while any path to it
116
+ lives, so this is how a later change at its source is seen; a local one
117
+ is checked against the file on every use. Every path to the same
118
+ archive shares the refresh."""
119
+ backend = self._backend
120
+ if isinstance(backend, _UndecidedBackend):
121
+ backend = backend._resolved
122
+ if backend is None:
123
+ return # nothing has been read yet
124
+ backend.refresh()
125
+
126
+ def as_uri(self) -> str:
127
+ # Encoded so the string parses back to the same member: the member
128
+ # path's `%`, `?` and `#`, and a literal "!/" inside the archive URI.
129
+ outer = self.archive
130
+ outer_uri = outer.as_uri()
131
+ if not isinstance(outer, ArchivePath):
132
+ # A nested archive's outer keeps its own separator (it already
133
+ # encodes any other "!/").
134
+ outer_uri = outer_uri.replace(_SEP, "%21/")
135
+ member = _urlparse.quote(self.as_posix().lstrip("/"), safe="/")
136
+ # A "!/" inside a member name must never read as a separator once
137
+ # this URI is itself the outer of a nested archive.
138
+ member = member.replace(_SEP, "%21/")
139
+ return f"{self._SCHEME}:{outer_uri}{_SEP}{member}"
140
+
141
+ def _snapshot(self) -> _Snapshot:
142
+ """The archive's members as of one validation of its handle; see
143
+ `_Snapshot`."""
144
+ return self.backend.snapshot()
145
+
146
+ def _raw_name(self, name: str) -> str:
147
+ """The backend's own key for a normalized name (the name itself when
148
+ the archive spells it canonically, or holds no such member yet).
149
+
150
+ A write may be the call that CREATES the archive, so a missing outer
151
+ is not an error here -- there is simply nothing to map yet.
152
+ """
153
+ try:
154
+ return self._snapshot().index.get(name, name)
155
+ except OSError:
156
+ return name
157
+
158
+ @property
159
+ def _member(self) -> "str | None":
160
+ """This path as a normalized member name, or None if it escapes."""
161
+ return _normalize_member_name(self.as_posix().removeprefix("/"))
162
+
163
+ def _resolve(self, snapshot: _Snapshot, member: str, follow: bool = True) -> str:
164
+ """`member` with the link members on its way followed (the last
165
+ component only when `follow`), or the error a lookup of it raises:
166
+ `FileNotFoundError` naming the link that leaves the archive, is
167
+ absolute or has no text, `OSError(ELOOP)` after `MAX_LINKS` links."""
168
+ try:
169
+ return snapshot.resolve(member, follow=follow)
170
+ except _Unresolved as error:
171
+ link = self._rebuild(error.link)
172
+ if error.loop:
173
+ raise OSError(
174
+ _errno.ELOOP, "Too many levels of symbolic links", str(link)
175
+ ) from None
176
+ raise FileNotFoundError(
177
+ _errno.ENOENT,
178
+ "A link does not resolve inside the archive",
179
+ str(link),
180
+ ) from None
181
+
182
+ def readlink(self) -> LinkTarget:
183
+ """The text a link member stores, as a `LinkTarget` (`OSError(EINVAL)`
184
+ when this member is not a link). Links on the way to it are followed."""
185
+ member = self._member
186
+ if member is None:
187
+ raise FileNotFoundError(str(self))
188
+ snapshot = self._snapshot()
189
+ member = self._resolve(snapshot, member, follow=False)
190
+ if snapshot.stat(member) is None:
191
+ raise FileNotFoundError(str(self))
192
+ if member not in snapshot.links:
193
+ raise OSError(_errno.EINVAL, "Invalid argument", str(self))
194
+ text = snapshot.links[member]
195
+ if text is None:
196
+ raise OSError(_errno.EIO, "The link's text cannot be read", str(self))
197
+ target = LinkTarget(text)
198
+ target._text = text
199
+ return target
200
+
201
+ def _directory_children(self) -> "tuple[_Snapshot, str, list[str]]":
202
+ member = self._member
203
+ if member is None:
204
+ raise FileNotFoundError(str(self))
205
+ snapshot = self._snapshot()
206
+ member = self._resolve(snapshot, member)
207
+ found = snapshot.stat(member)
208
+ if found is None:
209
+ raise FileNotFoundError(str(self))
210
+ if not found.is_dir():
211
+ raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
212
+ return snapshot, member, list(snapshot.children.get(member, ()))
213
+
214
+ def _scandir(self):
215
+ # The stat data is in the snapshot already: a listing costs one
216
+ # validation of the handle, however many entries it has.
217
+ snapshot, member, names = self._directory_children()
218
+ prefix = f"{member}/" if member else ""
219
+ for name in names:
220
+ if not is_safe_child_name(name):
221
+ continue
222
+ found = snapshot.stat(prefix + name)
223
+ if found is not None:
224
+ yield name, found
225
+
226
+ def iterdir(self):
227
+ for name, _stat in self._scandir():
228
+ yield self.with_segments(*self.segments, name)
229
+
230
+ def stat(self, *, follow_symlinks=True):
231
+ member = self._member
232
+ if member is None:
233
+ raise FileNotFoundError(str(self))
234
+ # The root too: a missing or unreadable archive is not a directory
235
+ # that exists, and a listing of it fails the same way.
236
+ snapshot = self._snapshot()
237
+ resolved = self._resolve(snapshot, member, follow_symlinks)
238
+ if follow_symlinks and snapshot.links:
239
+ self._check_enterable(snapshot, member, resolved)
240
+ found = snapshot.stat(resolved)
241
+ if found is None:
242
+ raise FileNotFoundError(str(self))
243
+ return found
244
+
245
+ def _check_enterable(self, snapshot: _Snapshot, member: str, resolved: str) -> None:
246
+ """A walk that follows links asks each listed child for its following
247
+ stat, so that answer decides whether it descends. A link to a
248
+ directory that lies beyond another link, or that points at the
249
+ directory holding it or one of that directory's ancestors, answers
250
+ `OSError(ELOOP)`: it is a link and not a directory to every walker,
251
+ and the entries a recursive operation visits stay bounded by the
252
+ members, the links and the directory links, however the links point.
253
+ A path spelled through further links is still read, listed and
254
+ opened."""
255
+ entry = snapshot.resolve(member, follow=False)
256
+ if entry not in snapshot.links:
257
+ return
258
+ found = snapshot.stat(resolved)
259
+ if found is None or not found.is_dir():
260
+ return
261
+ names = member.split("/")
262
+ beyond = any(
263
+ "/".join(names[:count]) in snapshot.links for count in range(1, len(names))
264
+ )
265
+ parent = entry.rpartition("/")[0]
266
+ around = (
267
+ resolved == "" or resolved == parent or parent.startswith(resolved + "/")
268
+ )
269
+ if beyond or around:
270
+ raise OSError(
271
+ _errno.ELOOP,
272
+ "A link to a directory is not entered "
273
+ + (
274
+ "beyond another link"
275
+ if beyond
276
+ else "when it points at the directory that holds it or above"
277
+ ),
278
+ str(self),
279
+ )
280
+
281
+ def _is_dir_member(self) -> bool:
282
+ try:
283
+ return self.stat().is_dir()
284
+ except FileNotFoundError:
285
+ return False
286
+
287
+ def _read_member(self):
288
+ member = self._member
289
+ if member is None:
290
+ raise FileNotFoundError(str(self))
291
+ try:
292
+ snapshot = self._snapshot()
293
+ spelled, member = member, self._resolve(snapshot, member)
294
+ if snapshot.links:
295
+ self._check_enterable(snapshot, spelled, member)
296
+ if snapshot.hidden(member):
297
+ raise KeyError(member)
298
+ return self.backend.read_member(self._raw_name(member))
299
+ except KeyError as error:
300
+ if self._is_dir_member():
301
+ raise IsADirectoryError(
302
+ _errno.EISDIR, "Is a directory", str(self)
303
+ ) from error
304
+ raise FileNotFoundError(str(self)) from error
305
+
306
+ def _open(self, mode="r", buffering=-1):
307
+ if "r" in mode and "+" not in mode:
308
+ return self._read_member()
309
+ return self._open_for_write(mode)
310
+
311
+ def _node_key(self):
312
+ # One member per archive handle, whichever spelling names it.
313
+ member = self._member
314
+ if member is None:
315
+ return None
316
+ return self.backend, tuple(member.split("/")) if member else ()
317
+
318
+ def _same_filesystem(self, other: "ArchivePath") -> bool:
319
+ # The archive handle is the namespace. A path of an undecided archive
320
+ # holds a stand-in until its format is settled, and two of them naming
321
+ # one archive settle on one handle.
322
+ try:
323
+ return other.backend is self.backend
324
+ except Exception:
325
+ # The archive cannot be read: nothing settles its handle.
326
+ return False
327
+
328
+
329
+ class ZipPath(ArchivePath):
330
+ """A member path of a zip archive, whatever its extension. Read/write when
331
+ the archive file is a local `Path`; every other archive is read-only (it
332
+ is fetched whole into memory first). Every change replaces the archive
333
+ atomically (a temporary file next to it, then `os.replace`), so the result
334
+ is a new file: other hard links keep the old content, and an archive the
335
+ caller may not write is refused first. A new member is appended, deflated,
336
+ to a byte copy of the archive (nothing recompressed); overwriting,
337
+ deleting or renaming a member rewrites the archive, keeping every other
338
+ member's metadata and compression method."""
339
+
340
+ __slots__ = ()
341
+ _SCHEME = "zip"
342
+ _backend_cls = _ZipBackend
343
+
344
+
345
+ class TarPath(ArchivePath):
346
+ """A member path of a tar archive, plain or compressed (`tarfile`'s
347
+ detected `r:*` mode), whatever its extension. Read-only. Members stored
348
+ with a `./` prefix are addressed without it."""
349
+
350
+ __slots__ = ()
351
+ _SCHEME = "tar"
352
+ _backend_cls = _TarBackend
@@ -0,0 +1,269 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib as _pathlib
4
+
5
+ from .._filestat import FileStat
6
+ from ._names import _normalize_member_name
7
+
8
+ MAX_LINKS = 40
9
+ """The most link members one lookup follows before it is a loop
10
+ (`OSError(ELOOP)`)."""
11
+
12
+
13
+ class LinkTarget(_pathlib.PurePosixPath):
14
+ """What `readlink()` of a link member returns: the stored text as a
15
+ `PurePosixPath`, which spells it normalized (`./a//b` is `a/b`), and as
16
+ `path`, which is the text exactly as stored. `PathSyncer` and `copy()`
17
+ recreate a link from `path`."""
18
+
19
+ __slots__ = ("_text",)
20
+
21
+ @property
22
+ def path(self) -> str:
23
+ try:
24
+ return self._text
25
+ except AttributeError:
26
+ return self.as_posix()
27
+
28
+
29
+ NO_LINK = object()
30
+ """What a snapshot is given for an entry that is not a link."""
31
+
32
+
33
+ class _Unresolved(Exception):
34
+ """A lookup that cannot get through the link members on its way: the one
35
+ named `link` leaves the archive, is absolute or has no readable text
36
+ (`loop` False), or the lookup followed `MAX_LINKS` links (`loop` True)."""
37
+
38
+ def __init__(self, link: str, loop: bool):
39
+ super().__init__(link)
40
+ self.link = link
41
+ self.loop = loop
42
+
43
+
44
+ class _Snapshot:
45
+ """What one open handle holds, derived once from its entries and kept
46
+ until the handle is dropped: the normalized-name index, every raw
47
+ spelling of a name, the directories (implicit ones included) with their
48
+ children, and the stat data of each entry. Nothing here touches the
49
+ handle again, so a lookup or a listing sees one consistent archive
50
+ however the file changes meanwhile.
51
+
52
+ A member whose name escapes the root is left out entirely, so it can be
53
+ neither listed nor looked up. When two spellings normalize to one name
54
+ the later entry wins, as `zipfile`/`tarfile` resolve duplicates;
55
+ `spellings` lists all of them."""
56
+
57
+ __slots__ = (
58
+ "index",
59
+ "spellings",
60
+ "infos",
61
+ "files",
62
+ "implicit",
63
+ "children",
64
+ "links",
65
+ "_format",
66
+ )
67
+
68
+ def __init__(self, format: "type[_ArchiveBackend]", entries, links=None):
69
+ # The backend class, not the backend: a snapshot that held its backend
70
+ # would keep it alive in a reference cycle.
71
+ self._format = format
72
+ index: "dict[str, str]" = {}
73
+ spellings: "dict[str, list[str]]" = {}
74
+ infos: "dict[str, object]" = {}
75
+ for raw, info in entries:
76
+ infos[raw] = info
77
+ normalized = _normalize_member_name(raw)
78
+ if not normalized:
79
+ continue
80
+ previous = index.get(normalized)
81
+ if previous is not None and previous != raw:
82
+ others = spellings.setdefault(normalized, [previous])
83
+ if raw not in others:
84
+ others.append(raw)
85
+ index[normalized] = raw
86
+ self.index = index
87
+ self.spellings = spellings
88
+ self.infos = infos
89
+ # The link members, by normalized name: the text they store, or None
90
+ # when it cannot be read. `links` maps raw names.
91
+ self.links = (
92
+ {name: links[raw] for name, raw in index.items() if raw in links}
93
+ if links
94
+ else {}
95
+ )
96
+ # Members that are files: nothing lies below one. A link is not one:
97
+ # what lies below it lies below what it points at.
98
+ self.files = {
99
+ name
100
+ for name, raw in index.items()
101
+ if not name.endswith("/")
102
+ and not format._is_directory(infos[raw])
103
+ and name not in self.links
104
+ }
105
+ # Every proper prefix of a name is a directory, and `children` holds
106
+ # the first component below each directory, in archive order.
107
+ implicit: "set[str]" = set()
108
+ children: "dict[str, dict[str, None]]" = {"": {}}
109
+ for name, raw in index.items():
110
+ parts = name.rstrip("/").split("/")
111
+ directory = ""
112
+ for position, part in enumerate(parts):
113
+ children.setdefault(directory, {})[part] = None
114
+ path = f"{directory}/{part}" if directory else part
115
+ if position < len(parts) - 1:
116
+ implicit.add(path)
117
+ directory = path
118
+ if name.endswith("/") or format._is_directory(infos[raw]):
119
+ children.setdefault(directory, {})
120
+ self.implicit = implicit
121
+ self.children = children
122
+
123
+ def add(self, raw: str, info, link=NO_LINK) -> bool:
124
+ """Adds the entry `raw`, or replaces its info, in place, giving the
125
+ snapshot the entries it would have been built from with this one
126
+ last. `link` is the text of a link entry (None when unreadable). False,
127
+ and nothing changed, when `raw` names a member the snapshot already
128
+ holds under another spelling. For a snapshot that no other thread
129
+ reads."""
130
+ normalized = _normalize_member_name(raw)
131
+ current = self.index.get(normalized) if normalized else None
132
+ if current is not None and current != raw:
133
+ return False
134
+ self.infos[raw] = info
135
+ if not normalized:
136
+ return True
137
+ self.index[normalized] = raw
138
+ if link is NO_LINK:
139
+ self.links.pop(normalized, None)
140
+ else:
141
+ self.links[normalized] = link
142
+ directory = normalized.endswith("/") or self._format._is_directory(info)
143
+ if directory or link is not NO_LINK:
144
+ self.files.discard(normalized)
145
+ else:
146
+ self.files.add(normalized)
147
+ if current is not None:
148
+ return True
149
+ directory_path = ""
150
+ parts = normalized.rstrip("/").split("/")
151
+ for position, part in enumerate(parts):
152
+ self.children.setdefault(directory_path, {})[part] = None
153
+ path = f"{directory_path}/{part}" if directory_path else part
154
+ if position < len(parts) - 1:
155
+ self.implicit.add(path)
156
+ directory_path = path
157
+ if directory:
158
+ self.children.setdefault(directory_path, {})
159
+ return True
160
+
161
+ def drop(self, raw: str) -> bool:
162
+ """Removes the entry `raw` in place, giving the snapshot the entries it
163
+ would have been built from without it: a directory that existed only
164
+ through it goes too, and so does each directory above that was left
165
+ with nothing. A directory keeps its place in the listing of its parent.
166
+ False, and nothing changed, when its name has several spellings here.
167
+ For a snapshot that no other thread reads."""
168
+ normalized = _normalize_member_name(raw)
169
+ if normalized and (
170
+ self.index.get(normalized) != raw or normalized in self.spellings
171
+ ):
172
+ return False
173
+ self.infos.pop(raw, None)
174
+ if not normalized:
175
+ return True
176
+ del self.index[normalized]
177
+ self.files.discard(normalized)
178
+ self.links.pop(normalized, None)
179
+ path = normalized.rstrip("/")
180
+ while path:
181
+ below = self.children.get(path)
182
+ if not below:
183
+ self.implicit.discard(path)
184
+ if path in self.index or f"{path}/" in self.index or below:
185
+ break
186
+ parent, _, leaf = path.rpartition("/")
187
+ self.children.pop(path, None)
188
+ siblings = self.children.get(parent)
189
+ if siblings is not None:
190
+ siblings.pop(leaf, None)
191
+ path = parent
192
+ return True
193
+
194
+ def resolve(self, member: str, *, follow: bool = True) -> str:
195
+ """`member` with every link member on its way replaced by what it
196
+ stores, as a normalized name that is not a link: a relative text is
197
+ read from the directory that holds the link, an absolute one or one
198
+ that leaves the root names nothing. The last component is left alone
199
+ unless `follow`. Raises `_Unresolved`. Costs nothing when the archive
200
+ holds no link."""
201
+ links = self.links
202
+ if not links:
203
+ return member
204
+ done: "list[str]" = []
205
+ todo = member.split("/")[::-1] if member else []
206
+ followed = 0
207
+ last = member
208
+ while todo:
209
+ part = todo.pop()
210
+ if part in ("", "."):
211
+ continue
212
+ if part == "..":
213
+ if not done:
214
+ raise _Unresolved(last, False)
215
+ done.pop()
216
+ continue
217
+ name = "/".join((*done, part))
218
+ if name in links and (todo or follow):
219
+ followed += 1
220
+ last = name
221
+ if followed > MAX_LINKS:
222
+ raise _Unresolved(name, True)
223
+ text = links[name]
224
+ if not text or text.startswith("/"):
225
+ raise _Unresolved(name, False)
226
+ todo.extend(text.split("/")[::-1])
227
+ continue
228
+ done.append(part)
229
+ return "/".join(done)
230
+
231
+ def hidden(self, member: str) -> bool:
232
+ """Whether a file member sits above `member`. A file has no children:
233
+ `y/z` next to a file `y` does not exist, as for a name that escapes
234
+ the root, so a lookup agrees with the listing that cannot show it."""
235
+ files = self.files
236
+ start = member.find("/")
237
+ while start > 0:
238
+ if member[:start] in files:
239
+ return True
240
+ start = member.find("/", start + 1)
241
+ return False
242
+
243
+ def stat(self, member: str) -> "FileStat | None":
244
+ """The stat of the member `member` (normalized, no trailing slash
245
+ unless it names a directory entry), or None when there is none. The
246
+ root is a directory; a directory that exists only through its members
247
+ is one too."""
248
+ if member == "":
249
+ return FileStat(is_dir=True)
250
+ if self.hidden(member):
251
+ return None
252
+ raw = self.index.get(member)
253
+ if raw is None:
254
+ raw = self.index.get(f"{member}/")
255
+ if raw is not None:
256
+ return self._format._stat_of(self.infos[raw], self)
257
+ if member in self.implicit:
258
+ return FileStat(is_dir=True)
259
+ return None
260
+
261
+ def raw_names(self, normalized: str) -> "tuple[str, ...]":
262
+ """Every key the archive holds the member `normalized` under, in
263
+ archive order (empty when there is none): an archive can spell one
264
+ member `norm` and `./norm`, and removing or renaming it has to act on
265
+ all of them or the shadowed one comes back."""
266
+ raw = self.index.get(normalized)
267
+ if raw is None:
268
+ return ()
269
+ return tuple(self.spellings.get(normalized) or (raw,))