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/ftp/_path.py ADDED
@@ -0,0 +1,582 @@
1
+ from __future__ import annotations
2
+
3
+ import errno as _errno
4
+ import ftplib as _ftplib
5
+ import io as _io
6
+ import posixpath as _posix
7
+ import threading as _thread
8
+ import time as _time
9
+ import types as _types
10
+ import typing as _ty
11
+ import urllib.parse as _urlparse
12
+ import weakref as _weakref
13
+
14
+ from .._tree import TreePath
15
+ from .._permissions import parse_mode as _parse_mode
16
+ from .._names import is_safe_child_name as _is_safe_child_name
17
+ from .._commit import CommitOnClose
18
+ from .._filestat import FileStat
19
+ from ._backend import (
20
+ DEFAULT_PORT,
21
+ DEFAULT_TIMEOUT,
22
+ BaseFTPBackend,
23
+ FTPBackend,
24
+ cached_session,
25
+ live_client,
26
+ session_of,
27
+ wired,
28
+ )
29
+ from ._facts import (
30
+ PERMISSION_WORDS,
31
+ UNSUPPORTED_REPLIES,
32
+ facts_to_filestat,
33
+ is_child_entry,
34
+ lists_feature,
35
+ mlst_facts,
36
+ reply_code,
37
+ )
38
+
39
+
40
+ class _Namespace:
41
+ """What two paths share when they resolve their names on one server: the
42
+ object `_node_key()` hands out for an endpoint."""
43
+
44
+ __slots__ = ("endpoint", "__weakref__")
45
+
46
+ def __init__(self, endpoint):
47
+ self.endpoint = endpoint
48
+
49
+
50
+ _NAMESPACES: "_weakref.WeakValueDictionary[object, _Namespace]" = (
51
+ _weakref.WeakValueDictionary()
52
+ )
53
+ _NAMESPACES_LOCK = _thread.Lock()
54
+
55
+
56
+ def _namespace_of(backend: BaseFTPBackend) -> object:
57
+ endpoint = backend.endpoint
58
+ if endpoint is None:
59
+ return backend
60
+ with _NAMESPACES_LOCK:
61
+ found = _NAMESPACES.get(endpoint)
62
+ if found is None:
63
+ found = _NAMESPACES[endpoint] = _Namespace(endpoint)
64
+ return found
65
+
66
+
67
+ def _rebuild_path(cls, text, backend):
68
+ return cls(text, backend=backend)
69
+
70
+
71
+ class FTPPath(TreePath):
72
+ """A path on an FTP server, read and written through stdlib `ftplib`,
73
+ with a thread-keyed connection cache (`FTPBackend`; a thread's connections
74
+ close when it ends). A listing prefers MLSD (RFC 3659 -- type/size/modify
75
+ facts in one round trip) and a stat MLST of the entry itself where FEAT
76
+ lists it, else its parent's MLSD; servers that don't support them fall
77
+ back to NLST for listing (names only) and SIZE/CWD for stat (no portable
78
+ "not found vs. is a directory" distinction in that path).
79
+
80
+ `FTPPath("/pub/x", host="h", user="u", tls=True)` builds the path's own
81
+ `FTPBackend`; `backend=` shares one connection cache between paths built
82
+ separately, or plugs a `BaseFTPBackend` of your own. A path joined from
83
+ another keeps its backend. Names are read from the server root: `pub/x` and
84
+ `/pub/x` are one file."""
85
+
86
+ __slots__ = ("_backend",)
87
+
88
+ def __init__(
89
+ self,
90
+ *segments: "str | FTPPath",
91
+ backend: "BaseFTPBackend | None" = None,
92
+ host: "str | None" = None,
93
+ port: int = DEFAULT_PORT,
94
+ user: "str | None" = None,
95
+ password: "str | None" = None,
96
+ tls: bool = False,
97
+ timeout: "float | None" = DEFAULT_TIMEOUT,
98
+ ssl_context: "object | None" = None,
99
+ verify: bool = True,
100
+ ):
101
+ text, donor = self._join(segments)
102
+ endpoint = (
103
+ host is not None
104
+ or user is not None
105
+ or password is not None
106
+ or ssl_context is not None
107
+ or tls
108
+ or port != DEFAULT_PORT
109
+ or timeout != DEFAULT_TIMEOUT
110
+ or verify is not True
111
+ )
112
+ if backend is not None and endpoint:
113
+ raise TypeError(
114
+ "pass either backend= or the endpoint keywords "
115
+ "(host=, port=, user=, ...), not both"
116
+ )
117
+ if backend is None:
118
+ if endpoint:
119
+ if host is None:
120
+ raise TypeError(
121
+ "host= is required with the other endpoint keywords"
122
+ )
123
+ backend = FTPBackend(
124
+ host,
125
+ port,
126
+ user=user,
127
+ password=password,
128
+ tls=tls,
129
+ timeout=timeout,
130
+ ssl_context=ssl_context,
131
+ verify=verify,
132
+ )
133
+ elif donor is not None:
134
+ backend = donor._backend
135
+ else:
136
+ raise TypeError("an FTPPath needs host= or backend=")
137
+ self._segments = self._parse(text)
138
+ self._backend = backend
139
+
140
+ def _rebuild(self, text: str) -> "FTPPath":
141
+ return type(self)(text, backend=self._backend)
142
+
143
+ def __reduce__(self):
144
+ # The text and the backend; a backend pickles without its
145
+ # connections, and with the password only if it was given one.
146
+ return _rebuild_path, (type(self), self.as_posix(), self._backend)
147
+
148
+ def __repr__(self) -> str:
149
+ backend = self._backend
150
+ shown = [repr(self.as_posix())]
151
+ if isinstance(backend, FTPBackend):
152
+ shown += [f"host={backend.host!r}", f"port={backend.port}"]
153
+ if backend.user is not None:
154
+ shown.append(f"user={backend.user!r}")
155
+ if backend.tls:
156
+ shown.append("tls=True")
157
+ else:
158
+ shown.append(f"backend={backend!r}")
159
+ return f"{type(self).__name__}({', '.join(shown)})"
160
+
161
+ @property
162
+ def backend(self) -> BaseFTPBackend:
163
+ """The `FTPBackend` (or custom `BaseFTPBackend`) holding the
164
+ connections of this path."""
165
+ return self._backend
166
+
167
+ def as_uri(self) -> str:
168
+ backend = self._backend
169
+ host = getattr(backend, "host", None)
170
+ if host is None:
171
+ raise NotImplementedError("as_uri() needs an FTPBackend")
172
+ netloc = f"[{host}]" if ":" in host else host
173
+ if backend.user is not None:
174
+ netloc = f"{_urlparse.quote(backend.user, safe='')}@{netloc}"
175
+ if backend.port != DEFAULT_PORT:
176
+ netloc = f"{netloc}:{backend.port}"
177
+ scheme = "ftps" if backend.tls else "ftp"
178
+ return f"{scheme}://{netloc}{_urlparse.quote(self._wirepath, safe='/')}"
179
+
180
+ def _same_filesystem(self, other: "FTPPath") -> bool:
181
+ # Equality ignores the server: two paths are one tree when they share a
182
+ # backend or name the same host, port, account and security.
183
+ mine, theirs = self._backend, other._backend
184
+ if mine is theirs:
185
+ return True
186
+ endpoint = mine.endpoint
187
+ return endpoint is not None and endpoint == theirs.endpoint
188
+
189
+ def _node_key(self):
190
+ # "x", "/x" and "/d/../x" are one node. This reads no server, so `..`
191
+ # is applied to the text.
192
+ posix = "/".join(self.segments).lstrip("/")
193
+ names = _posix.normpath("/" + posix).removeprefix("/").split("/")
194
+ return _namespace_of(self._backend), tuple(name for name in names if name)
195
+
196
+ @property
197
+ def _wirepath(self) -> str:
198
+ """This path as a command argument: rooted, and `/` for the root. An
199
+ empty argument would name the session's working directory instead."""
200
+ return "/" + "/".join(name for name in self.segments if name)
201
+
202
+ @property
203
+ def _ftpclient(self) -> "_ftplib.FTP":
204
+ return live_client(self._backend)
205
+
206
+ def _wire(self):
207
+ """The calling thread's live client, for one command or transfer (see
208
+ `_backend.wired`)."""
209
+ return wired(self._backend, str(self))
210
+
211
+ def _call(self, method: str, *args):
212
+ """`client.<method>(*args)` through `_wire()`. Only `error_perm` is
213
+ left for the caller; a transient `error_temp` becomes OSError.
214
+
215
+ `FTP.mlsd()` is a generator that runs its transfer while it is
216
+ iterated, so a listing is read whole here, inside the guard: a
217
+ failure half way through it evicts the client like any other."""
218
+ try:
219
+ with self._wire() as client:
220
+ result = getattr(client, method)(*args)
221
+ if isinstance(result, _types.GeneratorType):
222
+ result = list(result)
223
+ return result
224
+ except _ftplib.error_temp as error:
225
+ raise OSError(_errno.EAGAIN, str(error), str(self)) from error
226
+
227
+ def _translate(self, error: "_ftplib.error_perm", wrong_type=None) -> OSError:
228
+ """OSError for a refused command on this path. A reply alone cannot
229
+ say why: a read-only login gets "550 Not enough privileges" for a
230
+ missing file too. So the path is stat'ed: missing is
231
+ FileNotFoundError; `wrong_type(stat)` may name a type mismatch
232
+ (IsADirectoryError, ...); anything else is PermissionError."""
233
+ try:
234
+ st = self._fresh_stat()
235
+ except FileNotFoundError:
236
+ return FileNotFoundError(
237
+ _errno.ENOENT, f"No such file or directory ({error})", str(self)
238
+ )
239
+ except OSError:
240
+ st = None
241
+ if st is not None and wrong_type is not None:
242
+ mismatch = wrong_type(st)
243
+ if mismatch is not None:
244
+ return mismatch
245
+ return PermissionError(_errno.EACCES, str(error), str(self))
246
+
247
+ def _mlsd_entry(self):
248
+ """This entry's MLSD facts from its parent's listing, or None if
249
+ not found, the parent can't be listed (a name in it the client cannot
250
+ decode included), or the server doesn't support MLSD."""
251
+ try:
252
+ for name, facts in self._call("mlsd", self.parent._wirepath):
253
+ if name == self.name and is_child_entry(name, facts):
254
+ return facts
255
+ except _ftplib.error_perm:
256
+ return None
257
+ except OSError as error:
258
+ if error.errno == _errno.EILSEQ:
259
+ return None
260
+ raise
261
+ return None
262
+
263
+ def _offers(self, feature: str) -> bool:
264
+ """Whether the server of this path's connection lists `feature` in its
265
+ `FEAT` reply, asked once per connection and remembered on its
266
+ session."""
267
+ slot = feature.lower()
268
+ try:
269
+ with self._wire() as client:
270
+ session = session_of(client)
271
+ if session is not None and getattr(session, slot) is not None:
272
+ return getattr(session, slot)
273
+ send = getattr(client, "sendcmd", None)
274
+ offered = False
275
+ if send is not None:
276
+ try:
277
+ offered = lists_feature(send("FEAT"), feature)
278
+ except _ftplib.error_perm:
279
+ pass # 500/502: the server has no FEAT
280
+ if session is not None:
281
+ setattr(session, slot, offered)
282
+ return offered
283
+ except _ftplib.error_temp:
284
+ return False # not remembered: the next call asks again
285
+
286
+ def _stat_entry(self):
287
+ """This entry's facts, asked of the server in the fewest replies it
288
+ allows: `MLST` of the entry itself where `FEAT` offers it (RFC 3659
289
+ 7.2), else its parent's `MLSD` listing. None when the server has no
290
+ such entry, or will not say."""
291
+ if self._offers("MLST"):
292
+ try:
293
+ reply = self._control(f"MLST {self._wirepath}")
294
+ except _ftplib.error_perm:
295
+ return None
296
+ facts = None if reply is None else mlst_facts(reply)
297
+ if facts is not None:
298
+ return facts
299
+ # An MLST that answers without an entry is no use on this
300
+ # connection: use the listing, and do not ask again.
301
+ session = cached_session(self._backend)
302
+ if session is not None:
303
+ session.mlst = False
304
+ return self._mlsd_entry()
305
+
306
+ def _control(self, command: str) -> "str | None":
307
+ """The reply to a raw `command` on the control connection, or None for
308
+ a client that cannot send one. `error_perm` is left for the caller; a
309
+ transient `error_temp` becomes OSError."""
310
+ try:
311
+ with self._wire() as client:
312
+ send = getattr(client, "sendcmd", None)
313
+ return None if send is None else send(command)
314
+ except _ftplib.error_temp as error:
315
+ raise OSError(_errno.EAGAIN, str(error), str(self)) from error
316
+
317
+ def _not_a_directory(self, st) -> "OSError | None":
318
+ if st.is_dir():
319
+ return None
320
+ return NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
321
+
322
+ def _is_a_directory(self, st) -> "OSError | None":
323
+ if not st.is_dir():
324
+ return None
325
+ return IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
326
+
327
+ def _scandir(self):
328
+ # MLSD's facts carry type, size and modify for every child in one round trip; NLST
329
+ # (names only) is the fallback. The listing is read whole before anything is
330
+ # yielded, so no transfer is left half-read.
331
+ try:
332
+ listing = [
333
+ (name, facts_to_filestat(facts))
334
+ for name, facts in self._call("mlsd", self._wirepath)
335
+ if is_child_entry(name, facts)
336
+ ]
337
+ if (
338
+ len(listing) == 1
339
+ and listing[0][0] == self.name
340
+ and not listing[0][1].is_dir()
341
+ ):
342
+ # RFC 3659 has MLSD of a file refused (501), but a server
343
+ # that lists the file itself instead looks the same as a
344
+ # directory holding one same-named file: only a stat tells.
345
+ mismatch = self._not_a_directory(self._fresh_stat())
346
+ if mismatch is not None:
347
+ raise mismatch
348
+ except _ftplib.error_perm as error:
349
+ if reply_code(error) not in UNSUPPORTED_REPLIES:
350
+ # A missing path, a file, or a refused listing.
351
+ raise self._translate(error, self._not_a_directory) from error
352
+ try:
353
+ names = self._call("nlst", self._wirepath)
354
+ except _ftplib.error_perm as error:
355
+ raise self._translate(error, self._not_a_directory) from error
356
+ listing = [
357
+ (base, None)
358
+ for base in (name.rsplit("/", 1)[-1] for name in names)
359
+ if _is_safe_child_name(base)
360
+ ]
361
+ if len(listing) <= 1:
362
+ # Servers answer NLST of a missing path with an empty list,
363
+ # and of a file with the file's own name.
364
+ mismatch = self._not_a_directory(self._fresh_stat())
365
+ if mismatch is not None:
366
+ raise mismatch
367
+ yield from listing
368
+
369
+ def iterdir(self):
370
+ for name, _stat in self._scandir():
371
+ yield self.with_segments(*self.segments, name)
372
+
373
+ def stat(self, *, follow_symlinks=True):
374
+ return self._fresh_stat()
375
+
376
+ def _fresh_stat(self) -> FileStat:
377
+ # Special case: the FTP root '/' has no parent to MLSD and SIZE won't
378
+ # work on a directory. Confirm it exists via CWD.
379
+ if self._wirepath == "/":
380
+ try:
381
+ self._call("cwd", "/")
382
+ return FileStat(is_dir=True)
383
+ except _ftplib.error_perm as error:
384
+ raise FileNotFoundError(str(self)) from error
385
+ facts = self._stat_entry()
386
+ if facts is not None:
387
+ return facts_to_filestat(facts)
388
+ # MLST/MLSD unsupported, the parent unlistable, or the entry not there. SIZE answers
389
+ # for a file (in binary mode: servers refuse it in ASCII), CWD for a directory (every
390
+ # path here is absolute, so the changed working directory affects nothing).
391
+ try:
392
+ self._call("voidcmd", "TYPE I")
393
+ size = self._call("size", self._wirepath)
394
+ except _ftplib.error_perm:
395
+ size = None
396
+ if size is not None:
397
+ return FileStat(st_size=size, is_dir=False)
398
+ try:
399
+ self._call("cwd", self._wirepath)
400
+ except _ftplib.error_perm as error:
401
+ raise FileNotFoundError(str(self)) from error
402
+ return FileStat(is_dir=True)
403
+
404
+ def _open(self, mode="r", buffering=-1):
405
+ if mode in ("r", "r+"):
406
+ buf = _io.BytesIO()
407
+ try:
408
+ self._call("retrbinary", f"RETR {self._wirepath}", buf.write)
409
+ except _ftplib.error_perm as error:
410
+ raise self._translate(error, self._is_a_directory) from error
411
+ if mode == "r+":
412
+ # Read-modify-write: what is written is uploaded on close.
413
+ return self._upload_on_close("STOR", initial=buf.getvalue())
414
+ buf.seek(0)
415
+ # Read-only, like a local file opened "rb": a write raises
416
+ # instead of landing in a buffer nobody uploads.
417
+ return _io.BufferedReader(buf)
418
+ if mode not in ("w", "x", "a"):
419
+ raise NotImplementedError(f"open(mode={mode!r})")
420
+ if mode == "x" and self.exists():
421
+ raise FileExistsError(str(self))
422
+ return self._upload_on_close("APPE" if mode == "a" else "STOR")
423
+
424
+ def _upload_on_close(self, cmd: str, initial: "bytes | None" = None):
425
+ """A write buffer that uploads with `cmd` (STOR/APPE) when it is
426
+ closed. The connection is looked up then, not when the file is opened:
427
+ a write that outlasts the server's idle timeout still lands, on a
428
+ reconnected session."""
429
+
430
+ def upload(buffer):
431
+ buffer.seek(0)
432
+ self._store(cmd, buffer)
433
+
434
+ return CommitOnClose(upload, initial)
435
+
436
+ def _store(self, cmd: str, fileobj) -> None:
437
+ """Upload `fileobj` with STOR/APPE on a live connection."""
438
+ try:
439
+ self._call("storbinary", f"{cmd} {self._wirepath}", fileobj)
440
+ except _ftplib.error_perm as error:
441
+ raise self._create_error(error) from error
442
+
443
+ def _create_error(self, error: "_ftplib.error_perm") -> OSError:
444
+ """OSError for a refused STOR/MKD of this path: an existing
445
+ directory, a missing or non-directory parent, or a refusal."""
446
+ try:
447
+ if self._fresh_stat().is_dir():
448
+ return IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
449
+ except FileNotFoundError:
450
+ pass
451
+ except OSError:
452
+ return PermissionError(_errno.EACCES, str(error), str(self))
453
+ parent = self.parent
454
+ if parent != self:
455
+ try:
456
+ parent_stat = parent._fresh_stat()
457
+ except FileNotFoundError:
458
+ return FileNotFoundError(
459
+ _errno.ENOENT, f"No such file or directory ({error})", str(self)
460
+ )
461
+ except OSError:
462
+ parent_stat = None
463
+ if parent_stat is not None and not parent_stat.is_dir():
464
+ return NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
465
+ return PermissionError(_errno.EACCES, str(error), str(self))
466
+
467
+ def _mkdir(self, mode):
468
+ try:
469
+ self._call("mkd", self._wirepath)
470
+ except _ftplib.error_perm as error:
471
+ if self.exists():
472
+ raise FileExistsError(
473
+ _errno.EEXIST, "File exists", str(self)
474
+ ) from error
475
+ raise self._create_error(error) from error
476
+
477
+ def unlink(self, missing_ok=False):
478
+ try:
479
+ self._call("delete", self._wirepath)
480
+ except _ftplib.error_perm as error:
481
+ translated = self._translate(error, self._is_a_directory)
482
+ if missing_ok and isinstance(translated, FileNotFoundError):
483
+ return
484
+ raise translated from error
485
+
486
+ def rmdir(self):
487
+ try:
488
+ self._call("rmd", self._wirepath)
489
+ except _ftplib.error_perm as error:
490
+ raise self._translate(error, self._rmdir_mismatch(error)) from error
491
+
492
+ def _rmdir_mismatch(self, error: "_ftplib.error_perm"):
493
+ def mismatch(st) -> "OSError | None":
494
+ if not st.is_dir():
495
+ return NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
496
+ if any(word in str(error).lower() for word in PERMISSION_WORDS):
497
+ return None
498
+ try:
499
+ empty = next(iter(self._scandir()), None) is None
500
+ except OSError:
501
+ return None
502
+ if not empty:
503
+ return OSError(_errno.ENOTEMPTY, "Directory not empty", str(self))
504
+ return None
505
+
506
+ return mismatch
507
+
508
+ def rename(self, target: "FTPPath | str"):
509
+ # A plain str target is a sibling rename (relative to self's
510
+ # parent), matching sftp.py's rename() semantics.
511
+ if isinstance(target, str):
512
+ target = self.parent / target
513
+ elif not isinstance(target, FTPPath) or not self._same_filesystem(target):
514
+ raise NotImplementedError("rename() onto another server")
515
+ destination = self._rebuild(target.as_posix())
516
+ try:
517
+ self._call("rename", self._wirepath, destination._wirepath)
518
+ except _ftplib.error_perm as error:
519
+ raise self._rename_error(error, destination) from error
520
+ # pathlib returns the new path.
521
+ return destination
522
+
523
+ def _rename_error(
524
+ self, error: "_ftplib.error_perm", destination: "FTPPath"
525
+ ) -> OSError:
526
+ """OSError for a refused rename. Whether a server replaces an existing
527
+ target is its own business (POSIX ones do, Windows ones answer `550
528
+ File exists`), so a refusal that does not read as a permission one,
529
+ with the source there and the target too, is `FileExistsError`."""
530
+ refused = self._translate(error)
531
+ if isinstance(refused, FileNotFoundError) or any(
532
+ word in str(error).lower() for word in PERMISSION_WORDS
533
+ ):
534
+ return refused
535
+ try:
536
+ destination._fresh_stat()
537
+ except OSError:
538
+ return refused
539
+ return FileExistsError(
540
+ _errno.EEXIST, f"File exists ({error})", str(destination)
541
+ )
542
+
543
+ def _utime(
544
+ self, *, atime: "float | None" = None, mtime: "float | None" = None
545
+ ) -> None:
546
+ # `MFMT` (draft-somers-ftp-mfxx) sets the modification time, in UTC and
547
+ # whole seconds; FTP has no access time to set. Only a server whose
548
+ # `FEAT` lists it is asked.
549
+ if mtime is None:
550
+ return
551
+ if not self._offers("MFMT"):
552
+ raise NotImplementedError("MFMT is not offered by this server")
553
+ stamp = _time.strftime("%Y%m%d%H%M%S", _time.gmtime(mtime))
554
+ try:
555
+ self._call("voidcmd", f"MFMT {stamp} {self._wirepath}")
556
+ except _ftplib.error_perm as error:
557
+ if reply_code(error) in UNSUPPORTED_REPLIES:
558
+ raise NotImplementedError(
559
+ f"MFMT not supported by this server ({error})"
560
+ ) from error
561
+ raise self._translate(error) from error
562
+
563
+ def chmod(self, mode: "int | str", *, follow_symlinks: bool = True):
564
+ # SITE CHMOD is a non-standard extension that many servers lack. A rejection of an
565
+ # existing path becomes NotImplementedError, which copy() skips; a missing path is
566
+ # FileNotFoundError.
567
+ if not follow_symlinks:
568
+ raise NotImplementedError("chmod(follow_symlinks=False)")
569
+ mode = _parse_mode(mode)
570
+ try:
571
+ self._call("voidcmd", f"SITE CHMOD {mode:o} {self._wirepath}")
572
+ except _ftplib.error_perm as error:
573
+ if reply_code(error) in UNSUPPORTED_REPLIES:
574
+ raise NotImplementedError(
575
+ f"SITE CHMOD not supported by this server ({error})"
576
+ ) from error
577
+ translated = self._translate(error)
578
+ if isinstance(translated, FileNotFoundError):
579
+ raise translated from error
580
+ raise NotImplementedError(
581
+ f"SITE CHMOD refused by the server ({error})"
582
+ ) from error
@@ -0,0 +1,27 @@
1
+ """Filename globbing over any pathapi `Path`.
2
+
3
+ Modelled on CPython's pathlib glob selectors, reworked to run over anything
4
+ that implements `_scandir()`/`iterdir()`/`is_dir()`/`name` like
5
+ `pathlib.Path` (`LocalPath`, `MemPath`, `FTPPath`, the archive paths).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ._patterns import (
11
+ ANY_PATTERN,
12
+ RECURSIVE,
13
+ WILCARD_PATTERN,
14
+ WILDCARD_PATTERN,
15
+ compile_pattern,
16
+ full_match,
17
+ parse_pattern,
18
+ )
19
+ from ._selection import glob, select
20
+
21
+ __all__ = [
22
+ "RECURSIVE",
23
+ "full_match",
24
+ "glob",
25
+ "parse_pattern",
26
+ "select",
27
+ ]