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,126 @@
1
+ from __future__ import annotations
2
+
3
+ import io as _io
4
+ import stat as _stat
5
+ import tarfile as _tarfile
6
+ import zlib as _zlib
7
+
8
+ try:
9
+ from lzma import LZMAError as _LZMAError
10
+ except ImportError: # pragma: no cover - a Python built without lzma
11
+ _LZMAError = _zlib.error
12
+
13
+ from .._filestat import FileStat
14
+ from ._backend import _ArchiveBackend, _LazyReadFile, _local_outer_path, _spool
15
+ from ._names import _normalize_member_name
16
+
17
+
18
+ class _TarBackend(_ArchiveBackend):
19
+ """A tar archive, plain or compressed. A local one is read from the file
20
+ as it is needed (a compressed one is decompressed from its start for
21
+ each member it reaches), a non-local one is held in memory whole."""
22
+
23
+ __slots__ = ("_members", "_source")
24
+
25
+ _FORMAT_ERROR = _tarfile.ReadError
26
+
27
+ def __init__(self, outer):
28
+ super().__init__(outer)
29
+ self._members = {}
30
+ self._source = None
31
+
32
+ def _is_format_error(self, error):
33
+ # What the decompressors raise over an archive that is cut short or
34
+ # damaged. Their `OSError`s (a bad gzip header or CRC, bzip2's
35
+ # "Invalid data stream") carry no errno, which an I/O failure does.
36
+ return isinstance(
37
+ error, (_tarfile.TarError, EOFError, _zlib.error, _LZMAError)
38
+ ) or (isinstance(error, OSError) and error.errno is None)
39
+
40
+ def _open(self):
41
+ local = _local_outer_path(self.outer)
42
+ if local is not None:
43
+ source = _LazyReadFile(local)
44
+ else:
45
+ source = _io.BytesIO(self._outer_bytes())
46
+ try:
47
+ with self._damage_as_format_error():
48
+ handle = _tarfile.open(fileobj=source, mode="r:*")
49
+ members = {}
50
+ for info in handle.getmembers():
51
+ # A later entry of the same name wins, as in tarfile itself.
52
+ members[info.name] = info
53
+ except BaseException:
54
+ source.close()
55
+ raise
56
+ self._members = members
57
+ self._source = source
58
+ self._release()
59
+ return handle
60
+
61
+ def _release(self):
62
+ # No OS handle is kept between operations: on Windows one blocks
63
+ # deleting or replacing the archive.
64
+ if isinstance(self._source, _LazyReadFile):
65
+ self._source.release()
66
+
67
+ def _close_handle(self):
68
+ source, self._source = self._source, None
69
+ super()._close_handle()
70
+ if source is not None:
71
+ source.close()
72
+
73
+ def _entries(self):
74
+ return list(self._members.items())
75
+
76
+ def _link_texts(self, entries):
77
+ return {raw: info.linkname for raw, info in entries if info.issym()}
78
+
79
+ @staticmethod
80
+ def _is_directory(info):
81
+ return info.isdir()
82
+
83
+ @staticmethod
84
+ def _stat_of(info, snapshot):
85
+ size = info.size
86
+ if info.isdir():
87
+ kind = _stat.S_IFDIR
88
+ elif info.isfile() or info.islnk():
89
+ kind = _stat.S_IFREG
90
+ if info.islnk():
91
+ # A hard link carries no data of its own.
92
+ target = _normalize_member_name(info.linkname)
93
+ raw = snapshot.index.get(target) if target else None
94
+ size = snapshot.infos[raw].size if raw is not None else 0
95
+ elif info.issym():
96
+ kind = _stat.S_IFLNK
97
+ size = len(info.linkname.encode("utf-8", "surrogateescape"))
98
+ else:
99
+ # Special files are not modelled: no mode.
100
+ kind = 0
101
+ perms = _stat.S_IMODE(info.mode)
102
+ if kind == _stat.S_IFLNK:
103
+ perms = perms or 0o777
104
+ return FileStat(
105
+ st_mode=kind | perms if kind and perms else None,
106
+ st_size=size,
107
+ st_mtime=int(info.mtime),
108
+ is_dir=info.isdir(),
109
+ )
110
+
111
+ def read_member(self, path):
112
+ with self._lock:
113
+ handle = self.handle
114
+ member = self._members[path] # raises KeyError if missing
115
+ try:
116
+ with self._damage_as_format_error():
117
+ f = handle.extractfile(member)
118
+ if f is None:
119
+ raise IsADirectoryError(path)
120
+ # Copied out under the lock: a live stream shares the
121
+ # handle's file object (and decompressor) with every other
122
+ # reader. A large member goes to a temporary file.
123
+ with self._damage_as_format_error(), f:
124
+ return _spool(f)
125
+ finally:
126
+ self._release()
@@ -0,0 +1,359 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib as _contextlib
4
+ import errno as _errno
5
+ import typing as _ty
6
+
7
+ from .._errorpolicy import as_error_handler, check_follow
8
+ from .._commit import CommitOnClose
9
+ from ._names import _normalize_member_name
10
+ from ._snapshot import _Snapshot
11
+
12
+
13
+ class _WritableMembers:
14
+ """What an archive path does to change its archive: gated on
15
+ `self.backend.writable`, which only the zip backend, and only for a local
16
+ archive file, ever reports `True` -- so a tar-backed path, whether reached
17
+ as `TarPath` or auto-detected, correctly raises `NotImplementedError` on
18
+ any write attempt rather than silently misbehaving."""
19
+
20
+ __slots__ = ()
21
+
22
+ def _write_stream(self, name: str, initial: "bytes | None" = None):
23
+ """A write buffer whose content becomes the member `name` (a key of
24
+ the backend) when it is closed."""
25
+ backend = self.backend
26
+ return CommitOnClose(
27
+ lambda buffer: backend.write_member(name, buffer.getvalue()), initial
28
+ )
29
+
30
+ def _member_for_write(self) -> str:
31
+ member = self._member
32
+ if member is None:
33
+ raise ValueError(
34
+ f"member name escapes the archive root: {self.as_posix()!r}"
35
+ )
36
+ return member
37
+
38
+ def _check_no_link(self, snapshot: _Snapshot, member: str, *, last: bool) -> None:
39
+ """Writes go to the member named, never through a link: the member
40
+ would land beside the link and no listing would show it."""
41
+ if not snapshot.links:
42
+ return
43
+ parts = member.split("/")
44
+ for count in range(1, len(parts) + (1 if last else 0)):
45
+ if "/".join(parts[:count]) in snapshot.links:
46
+ raise NotImplementedError(
47
+ f"{self._SCHEME}: writing through a link member is not "
48
+ f"supported: {self}"
49
+ )
50
+
51
+ def _snapshot_or_empty(self) -> _Snapshot:
52
+ """The snapshot a write checks the member against; an archive that is
53
+ not there yet holds nothing."""
54
+ try:
55
+ return self._snapshot()
56
+ except FileNotFoundError:
57
+ return _Snapshot(type(self.backend), [])
58
+
59
+ @staticmethod
60
+ def _emptied_parent(
61
+ member: str, index: "dict[str, str]", removed, added: "str | None" = None
62
+ ) -> "str | None":
63
+ """The directory marker (`"a/b/"`) to write when taking `member` out
64
+ of its directory would leave that directory with nothing in it. A zip
65
+ directory may exist only through its members, and `removed` (the
66
+ normalized names leaving) plus `added` (a name arriving) decide
67
+ whether any remain; removing a file never removes its parent."""
68
+ parent = member.rstrip("/").rpartition("/")[0]
69
+ if not parent:
70
+ return None
71
+ marker = f"{parent}/"
72
+ if added is not None and added.startswith(marker):
73
+ return None
74
+ for name in index:
75
+ if name.startswith(marker) and name not in removed:
76
+ return None
77
+ return marker
78
+
79
+ def _check_parent(self):
80
+ """pathlib's check before creating `self`: the parent must exist and
81
+ be a directory (a zip directory may be implicit, see `stat`)."""
82
+ parent = self.parent
83
+ if not parent.as_posix().strip("/"):
84
+ return
85
+ try:
86
+ is_dir = parent.stat().is_dir()
87
+ except FileNotFoundError:
88
+ raise FileNotFoundError(
89
+ _errno.ENOENT, "No such file or directory", str(self)
90
+ ) from None
91
+ if not is_dir:
92
+ raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
93
+
94
+ def _require_writable(self):
95
+ backend = self.backend
96
+ if getattr(backend, "writable", False):
97
+ return
98
+ if hasattr(backend, "write_member"):
99
+ # A write-capable backend type (currently only the zip one),
100
+ # just not writable for *this* archive file (a non-local one).
101
+ raise NotImplementedError(
102
+ f"{self._SCHEME}: write support requires a local archive file"
103
+ )
104
+ raise NotImplementedError(
105
+ f"{self._SCHEME}: write support is only available for "
106
+ "zip-format archives"
107
+ )
108
+
109
+ def batch(self) -> "_ty.ContextManager[None]":
110
+ """A context manager that holds every change made to this archive
111
+ while it is open and writes them in one replacement of the archive
112
+ file when the outermost block ends without an exception; an exception
113
+ leaves the archive as it was. Inside it every path of the archive
114
+ reads the held changes, and the file is not touched until the block
115
+ ends. A block inside another joins it. `NotImplementedError` when the
116
+ archive cannot be written (see `_require_writable`)."""
117
+ self._require_writable()
118
+ return self.backend.batch()
119
+
120
+ def _write_batch(self) -> "_ty.ContextManager[object]":
121
+ # Entered by `copy(recursive=True)` and `PathSyncer` around a tree of
122
+ # writes. It must read nothing and fail never: the write that follows
123
+ # reports a missing or unwritable archive itself.
124
+ try:
125
+ backend = self.backend
126
+ writable = getattr(backend, "writable", False)
127
+ except Exception:
128
+ writable = False
129
+ if not (writable and hasattr(backend, "batch")):
130
+ return _contextlib.nullcontext()
131
+ return backend.batch()
132
+
133
+ def _open_for_write(self, mode: str):
134
+ self._require_writable()
135
+ self._check_no_link(
136
+ self._snapshot_or_empty(), self._member_for_write(), last=True
137
+ )
138
+ if "r" in mode:
139
+ # Read-modify-write: what is written reaches the archive on close.
140
+ data = self._read_member().read()
141
+ return self._write_stream(
142
+ self._raw_name(self._member_for_write()), initial=data
143
+ )
144
+ if mode not in ("w", "x"):
145
+ raise NotImplementedError(f"open(mode={mode!r})")
146
+ if self._is_dir_member():
147
+ raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
148
+ if mode == "x" and self.exists():
149
+ raise FileExistsError(str(self))
150
+ self._check_parent()
151
+ # The raw name: overwriting a member the archive spells "./f.txt" rewrites THAT
152
+ # entry instead of appending a second one that shadows it.
153
+ return self._write_stream(self._raw_name(self._member_for_write()))
154
+
155
+ def _mkdir(self, mode):
156
+ self._require_writable()
157
+ if not self._member_for_write():
158
+ # The archive root is the archive itself: it exists, or there is
159
+ # nothing to create it in.
160
+ self.stat()
161
+ raise FileExistsError(str(self))
162
+ snapshot = self._snapshot_or_empty()
163
+ if self.exists() or self._member_for_write() in snapshot.links:
164
+ raise FileExistsError(str(self))
165
+ self._check_no_link(snapshot, self._member_for_write(), last=False)
166
+ self._check_parent()
167
+ marker = f"{self._member_for_write()}/"
168
+ self.backend.write_member(self._raw_name(marker), b"")
169
+
170
+ def unlink(self, missing_ok=False):
171
+ self._require_writable()
172
+ path = self._member_for_write()
173
+ snapshot = self._snapshot()
174
+ self._check_no_link(snapshot, path, last=False)
175
+ index = snapshot.index
176
+ if path not in index or snapshot.hidden(path):
177
+ if self._is_dir_member():
178
+ raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
179
+ if missing_ok:
180
+ return
181
+ raise FileNotFoundError(str(self))
182
+ self.backend.delete_members(
183
+ snapshot.raw_names(path),
184
+ keep_dir=self._emptied_parent(path, index, {path}),
185
+ )
186
+
187
+ def rmdir(self):
188
+ self._require_writable()
189
+ path = self._member_for_write()
190
+ snapshot = self._snapshot()
191
+ self._check_no_link(snapshot, path, last=False)
192
+ index = snapshot.index
193
+ if not path:
194
+ # The archive root cannot be removed: an empty one is a no-op.
195
+ if index:
196
+ raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(self))
197
+ return
198
+ marker = f"{path}/"
199
+ if snapshot.hidden(path):
200
+ raise FileNotFoundError(str(self))
201
+ if any(n != marker and n.startswith(marker) for n in index):
202
+ raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(self))
203
+ if marker not in index:
204
+ if path in index:
205
+ raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
206
+ raise FileNotFoundError(str(self))
207
+ self.backend.delete_members(
208
+ snapshot.raw_names(marker),
209
+ keep_dir=self._emptied_parent(marker, index, {marker}),
210
+ )
211
+
212
+ def rm(
213
+ self,
214
+ /,
215
+ recursive=False,
216
+ missing_ok=False,
217
+ ignore_error: "bool | _ty.Callable[[Exception, _ty.Self], bool]" = False,
218
+ *,
219
+ follow_symlinks=False,
220
+ follow_binds=False,
221
+ ):
222
+ # An archive holds no bindings, and a link member is an entry like
223
+ # any other for a removal that does not follow it.
224
+ check_follow("follow_symlinks", follow_symlinks)
225
+ check_follow("follow_binds", follow_binds)
226
+ if not (
227
+ recursive
228
+ and self._rm_tree(
229
+ as_error_handler(ignore_error), follows=follow_symlinks is not False
230
+ )
231
+ ):
232
+ return super().rm(
233
+ recursive=recursive,
234
+ missing_ok=missing_ok,
235
+ ignore_error=ignore_error,
236
+ follow_symlinks=follow_symlinks,
237
+ follow_binds=follow_binds,
238
+ )
239
+
240
+ def _rm_tree(self, on_error, follows: bool = False) -> bool:
241
+ """`rm(recursive=True)` of a directory of a writable archive: every
242
+ member under it leaves in ONE rewrite of the archive. False when this
243
+ is not that case, and the generic removal handles (and reports) it:
244
+ a policy that follows links, or leaves them, has to meet each one.
245
+ `on_error(error, self)` is asked about a failure and returns whether to
246
+ carry on."""
247
+ member = self._member
248
+ if member is None or not getattr(self.backend, "writable", False):
249
+ return False
250
+ try:
251
+ snapshot = self._snapshot()
252
+ found = snapshot.stat(member)
253
+ if found is None or not found.is_dir():
254
+ return False
255
+ index = snapshot.index
256
+ except Exception:
257
+ return False
258
+ marker = f"{member}/" if member else ""
259
+ under = [name for name in index if name.startswith(marker)]
260
+ if follows and any(name in snapshot.links for name in under):
261
+ return False
262
+ try:
263
+ # A member under a file is not listed, so a removal that walked
264
+ # the listing would leave it behind and the directory would
265
+ # come back.
266
+ hidden = [name for name in under if snapshot.hidden(name)]
267
+ if hidden:
268
+ raise OSError(
269
+ _errno.ENOTEMPTY,
270
+ f"Directory not empty: {hidden[0]!r} lies under a file",
271
+ str(self),
272
+ )
273
+ if under:
274
+ self.backend.delete_members(
275
+ [raw for name in under for raw in snapshot.raw_names(name)],
276
+ keep_dir=(
277
+ self._emptied_parent(member, index, set(under))
278
+ if member
279
+ else None
280
+ ),
281
+ )
282
+ except Exception as error:
283
+ if not on_error(error, self):
284
+ raise
285
+ return True
286
+
287
+ def rename(self, target):
288
+ # A plain str target is a sibling rename (relative to self's parent), as in sftp
289
+ # and ftp. An existing target is replaced as POSIX rename(2) does (a file over a
290
+ # file, a directory over an empty one), never a duplicate member. Returns the new path.
291
+ self._require_writable()
292
+ if isinstance(target, str):
293
+ target = self.parent / target
294
+ elif not isinstance(target, type(self)) or not self._same_filesystem(target):
295
+ raise NotImplementedError("rename() onto another archive")
296
+ old_path = self._member_for_write()
297
+ new_path = _normalize_member_name(target.as_posix().lstrip("/"))
298
+ if not new_path:
299
+ raise ValueError(
300
+ f"member name escapes the archive root: {target.as_posix()!r}"
301
+ )
302
+ snapshot = self._snapshot()
303
+ self._check_no_link(snapshot, old_path, last=False)
304
+ self._check_no_link(snapshot, new_path, last=False)
305
+ index = snapshot.index
306
+ names = list(index)
307
+ marker = f"{old_path}/"
308
+ new_marker = f"{new_path}/"
309
+ renamed = self._rebuild(new_path)
310
+ backend = self.backend
311
+ hidden = snapshot.hidden(old_path)
312
+ is_file = old_path in index and not hidden
313
+ is_dir = not is_file and not hidden and any(n.startswith(marker) for n in names)
314
+ if not (is_file or is_dir):
315
+ raise FileNotFoundError(str(self))
316
+ if is_dir and new_path.startswith(marker):
317
+ raise OSError(
318
+ _errno.EINVAL, "Cannot move a directory into itself", str(target)
319
+ )
320
+ renamed._check_parent()
321
+ if is_file:
322
+ if new_path == old_path:
323
+ return renamed
324
+ if any(n.startswith(new_marker) for n in names):
325
+ raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(target))
326
+ # Every spelling of the source moves, and every spelling of an
327
+ # existing target goes: one left behind would shadow, or come
328
+ # back as, the member this call just placed.
329
+ backend.rename_member(
330
+ index[old_path],
331
+ new_path,
332
+ members={raw: new_path for raw in snapshot.raw_names(old_path)},
333
+ replace=snapshot.raw_names(new_path),
334
+ keep_dir=self._emptied_parent(old_path, index, {old_path}, new_path),
335
+ )
336
+ else:
337
+ if new_marker == marker:
338
+ return renamed
339
+ if new_path in index:
340
+ raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(target))
341
+ if any(n != new_marker and n.startswith(new_marker) for n in names):
342
+ raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(target))
343
+ # The backend renames by its own keys, and a prefix match on the normalized marker
344
+ # misses members spelled "./dir/x" (renaming nothing, or splitting the directory):
345
+ # hand it the exact raw-name mapping.
346
+ moving = [name for name in names if name.startswith(marker)]
347
+ members = {
348
+ raw: new_marker + name[len(marker) :]
349
+ for name in moving
350
+ for raw in snapshot.raw_names(name)
351
+ }
352
+ backend.rename_member(
353
+ index.get(marker, marker),
354
+ new_marker,
355
+ members=members,
356
+ replace=snapshot.raw_names(new_marker),
357
+ keep_dir=self._emptied_parent(old_path, index, set(moving), new_path),
358
+ )
359
+ return renamed