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,418 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib as _contextlib
4
+ import errno as _errno
5
+ import ftplib as _ftplib
6
+ import itertools as _itertools
7
+ import ssl as _ssl
8
+ import threading as _thread
9
+ import time as _time
10
+ import typing as _ty
11
+ import weakref as _weakref
12
+
13
+ from .._lru import LRU as _LRU
14
+ from .._stubs import notimplemented as _notimplemented
15
+
16
+ DEFAULT_PORT = 21
17
+ """The control port `FTPBackend` connects to when it is not given one. `tls`
18
+ asks for explicit FTPS on that same port; port 990 is never assumed."""
19
+
20
+ DEFAULT_TIMEOUT = 30.0
21
+ """Socket timeout, in seconds, `FTPBackend` applies to connect, replies and
22
+ transfers when the caller does not pass one."""
23
+
24
+ IDLE_PROBE_SECONDS = 1.0
25
+ """A cached control connection that carried a command within this many seconds
26
+ is used as it is. One that has been idle longer, or has not carried a command
27
+ yet, is probed with `NOOP` first and replaced if the server dropped it.
28
+ Servers close idle sessions after tens of seconds at the least, so a run of
29
+ operations pays no probe and a pause long enough to lose the session pays one."""
30
+
31
+
32
+ class FTPEndpoint(_ty.NamedTuple):
33
+ """The server and account a backend connects to: what two backends must
34
+ share for their paths to name the same files. The password is not part of
35
+ it."""
36
+
37
+ host: str
38
+ port: int
39
+ user: "str | None"
40
+ tls: bool
41
+
42
+
43
+ class BaseFTPBackend(object):
44
+ """Contract for obtaining a connected, logged-in `ftplib.FTP` (or
45
+ `FTP_TLS`). Subclass this to plug in custom connection handling (tests
46
+ mock it directly, no real server); `FTPBackend` is the real
47
+ implementation. `endpoint`, when a subclass sets it, is a hashable value
48
+ naming the server and account: two backends with equal endpoints serve
49
+ the same files."""
50
+
51
+ # Weakly referenceable so a path can be told which backends it built.
52
+ __slots__ = ("__weakref__",)
53
+
54
+ endpoint: "_ty.Hashable | None" = None
55
+
56
+ @_notimplemented
57
+ def client(self) -> "_ftplib.FTP":
58
+ """A new connected, logged-in client."""
59
+
60
+ def close(self) -> None:
61
+ """Closes every control connection this backend holds, whichever
62
+ thread opened it; one a command is running on closes when that
63
+ command ends. Harmless when called again; a later use connects
64
+ afresh."""
65
+ _drop_backend(self)
66
+
67
+ def __enter__(self):
68
+ return self
69
+
70
+ def __exit__(self, *exc_info) -> None:
71
+ self.close()
72
+
73
+
74
+ class _SessionReuseFTP_TLS(_ftplib.FTP_TLS):
75
+ """`FTP_TLS` whose data connections resume the control connection's TLS
76
+ session. Stdlib `ntransfercmd` wraps the data socket without
77
+ `session=`, and servers such as vsftpd (`require_ssl_reuse=YES`, the
78
+ default) and FileZilla Server reject such a data connection with
79
+ `522`. With TLS 1.3 the session ticket arrives after the handshake; by
80
+ the first transfer the login replies have been read, so it is there."""
81
+
82
+ def ntransfercmd(self, cmd, rest=None):
83
+ # Skip FTP_TLS.ntransfercmd (it wraps without `session=`).
84
+ conn, size = super(_ftplib.FTP_TLS, self).ntransfercmd(cmd, rest)
85
+ if self._prot_p:
86
+ conn = self.context.wrap_socket(
87
+ conn,
88
+ server_hostname=self.host,
89
+ session=getattr(self.sock, "session", None),
90
+ )
91
+ return conn, size
92
+
93
+
94
+ class FTPBackend(BaseFTPBackend):
95
+ """The connections to one FTP server, as one account: stdlib
96
+ `ftplib.FTP`, or `ftplib.FTP_TLS` with `tls=True` (explicit FTPS on the
97
+ control port, `PROT P` for an encrypted data channel too).
98
+
99
+ Nothing connects when the backend is built. Each thread opens a control
100
+ connection on its first use and keeps it, so paths that share a backend
101
+ share a connection per thread (`close()` ends them, and so does the end
102
+ of the thread). `timeout` (seconds, default `DEFAULT_TIMEOUT` = 30)
103
+ bounds connect, every reply and every transfer read; `None` blocks
104
+ forever. Without `user` the login is `anonymous`.
105
+
106
+ `tls=True` verifies the server certificate and host name by default
107
+ (`ssl.create_default_context()`), before `USER`/`PASS` are sent. To
108
+ trust a private CA or a self-signed certificate, pass your own
109
+ `ssl_context` (e.g. `ssl.create_default_context(cafile=...)`). To turn
110
+ verification off entirely -- accepting any certificate, so anyone on
111
+ the network path can read the password -- pass `verify=False`.
112
+ `ssl_context` wins over `verify` when both are given. Data connections
113
+ reuse the control connection's TLS session.
114
+
115
+ A context manager: leaving the block calls `close()`."""
116
+
117
+ __slots__ = (
118
+ "host",
119
+ "port",
120
+ "user",
121
+ "password",
122
+ "tls",
123
+ "timeout",
124
+ "ssl_context",
125
+ "verify",
126
+ )
127
+
128
+ def __init__(
129
+ self,
130
+ host: str,
131
+ port: int = DEFAULT_PORT,
132
+ *,
133
+ user: "str | None" = None,
134
+ password: "str | None" = None,
135
+ tls: bool = False,
136
+ timeout: "float | None" = DEFAULT_TIMEOUT,
137
+ ssl_context: "_ssl.SSLContext | None" = None,
138
+ verify: bool = True,
139
+ ) -> None:
140
+ if not host:
141
+ raise ValueError("an FTP backend needs a host")
142
+ self.host = str(host)
143
+ self.port = DEFAULT_PORT if port is None else int(port)
144
+ self.user = user
145
+ self.password = password
146
+ self.tls = bool(tls)
147
+ self.timeout = timeout
148
+ self.ssl_context = ssl_context
149
+ self.verify = verify
150
+
151
+ @property
152
+ def endpoint(self) -> FTPEndpoint:
153
+ return FTPEndpoint(self.host, self.port, self.user, self.tls)
154
+
155
+ def __repr__(self) -> str:
156
+ shown = [repr(self.host), str(self.port)]
157
+ if self.user is not None:
158
+ shown.append(f"user={self.user!r}")
159
+ if self.tls:
160
+ shown.append("tls=True")
161
+ return f"{type(self).__name__}({', '.join(shown)})"
162
+
163
+ def __reduce__(self):
164
+ return (
165
+ _rebuild_backend,
166
+ (
167
+ type(self),
168
+ self.host,
169
+ self.port,
170
+ self.user,
171
+ self.password,
172
+ self.tls,
173
+ self.timeout,
174
+ self.ssl_context,
175
+ self.verify,
176
+ ),
177
+ )
178
+
179
+ def _tls_context(self) -> "_ssl.SSLContext":
180
+ if self.ssl_context is not None:
181
+ return self.ssl_context
182
+ context = _ssl.create_default_context()
183
+ if not self.verify:
184
+ context.check_hostname = False
185
+ context.verify_mode = _ssl.CERT_NONE
186
+ return context
187
+
188
+ def client(self) -> "_ftplib.FTP":
189
+ if self.tls:
190
+ client = _SessionReuseFTP_TLS(
191
+ context=self._tls_context(), timeout=self.timeout
192
+ )
193
+ else:
194
+ client = _ftplib.FTP(timeout=self.timeout)
195
+ try:
196
+ client.connect(self.host, self.port)
197
+ client.login(self.user or "anonymous", self.password or "")
198
+ if self.tls:
199
+ client.prot_p()
200
+ client.set_pasv(True)
201
+ except BaseException:
202
+ # A rejected certificate or a timed-out greeting must not leave
203
+ # the half-open control socket behind.
204
+ close = getattr(client, "close", None)
205
+ if close is not None:
206
+ close()
207
+ raise
208
+ return client
209
+
210
+
211
+ def _rebuild_backend(cls, host, port, user, password, tls, timeout, context, verify):
212
+ return cls(
213
+ host,
214
+ port,
215
+ user=user,
216
+ password=password,
217
+ tls=tls,
218
+ timeout=timeout,
219
+ ssl_context=context,
220
+ verify=verify,
221
+ )
222
+
223
+
224
+ _now = _time.monotonic
225
+
226
+
227
+ class _Session:
228
+ """What is known of one cached connection: when it last answered, whether
229
+ the server offers `MLST` and `MFMT`, and whether a command is running on
230
+ it. A connection dropped from the cache while a command runs on it is
231
+ closed when that command ends."""
232
+
233
+ __slots__ = ("last_used", "mlst", "mfmt", "busy", "retired")
234
+
235
+ def __init__(self):
236
+ self.last_used: "float | None" = None
237
+ self.mlst: "bool | None" = None
238
+ self.mfmt: "bool | None" = None
239
+ self.busy = 0
240
+ self.retired = False
241
+
242
+
243
+ _SESSION_ATTR = "_pathapi_session"
244
+ _SESSION_LOCK = _thread.Lock()
245
+
246
+
247
+ def session_of(client) -> "_Session | None":
248
+ session = getattr(client, _SESSION_ATTR, None)
249
+ if session is None:
250
+ session = _Session()
251
+ try:
252
+ setattr(client, _SESSION_ATTR, session)
253
+ except AttributeError:
254
+ return None # a client that takes no attributes is always probed
255
+ return session
256
+
257
+
258
+ def _create_ftpclient(backend: BaseFTPBackend, endpoint: object, scope: int):
259
+ return backend.client()
260
+
261
+
262
+ def _close_ftpclient(key: tuple, client: "_ftplib.FTP") -> None:
263
+ session = getattr(client, _SESSION_ATTR, None)
264
+ if session is not None:
265
+ with _SESSION_LOCK:
266
+ if session.busy:
267
+ # Dropped from the cache while a command is running on it
268
+ # (another thread's LRU overflow): its user closes it.
269
+ session.retired = True
270
+ return
271
+ client.close()
272
+
273
+
274
+ _THREAD_SCOPE = _thread.local()
275
+ _SCOPE_IDS = _itertools.count(1)
276
+
277
+
278
+ def _drop_scope(scope: int) -> None:
279
+ with _CACHED_CLIENTS.lock:
280
+ keys = [key for key in _CACHED_CLIENTS.cache if key[2] == scope]
281
+ for key in keys:
282
+ _CACHED_CLIENTS.discard(*key)
283
+
284
+
285
+ def _drop_backend(backend: BaseFTPBackend) -> None:
286
+ with _CACHED_CLIENTS.lock:
287
+ keys = [key for key in _CACHED_CLIENTS.cache if key[0] is backend]
288
+ for key in keys:
289
+ _CACHED_CLIENTS.discard(*key)
290
+
291
+
292
+ class _ThreadScope:
293
+ """Held in the thread's own storage, so it is released when the thread
294
+ ends and takes the thread's cached connections with it."""
295
+
296
+ def __init__(self):
297
+ self.id = next(_SCOPE_IDS)
298
+ _weakref.finalize(self, _drop_scope, self.id).atexit = False
299
+
300
+
301
+ def _scope_id() -> int:
302
+ """The calling thread's key in `_CACHED_CLIENTS`. Not the thread
303
+ identifier, which the operating system reuses for a later thread."""
304
+ scope = getattr(_THREAD_SCOPE, "scope", None)
305
+ if scope is None:
306
+ scope = _THREAD_SCOPE.scope = _ThreadScope()
307
+ return scope.id
308
+
309
+
310
+ # Keyed by (backend, endpoint, thread scope): ftplib clients are not
311
+ # thread-safe. Evicted and discarded clients are closed (`_close_ftpclient`),
312
+ # not left logged in, and a thread's clients are dropped when it ends.
313
+ _CACHED_CLIENTS = _LRU(_create_ftpclient, maxsize=128, on_evict=_close_ftpclient)
314
+
315
+
316
+ def cache_key(backend: BaseFTPBackend) -> tuple:
317
+ return (backend, backend.endpoint, _scope_id())
318
+
319
+
320
+ def cached_session(backend: BaseFTPBackend) -> "_Session | None":
321
+ """The session record of the calling thread's cached connection to
322
+ `backend`, or None when there is none."""
323
+ client = _CACHED_CLIENTS.cache.get(cache_key(backend))
324
+ return None if client is None else session_of(client)
325
+
326
+
327
+ def live_client(backend: BaseFTPBackend) -> "_ftplib.FTP":
328
+ """The calling thread's connection to `backend`: the cached one when it
329
+ carried a command within `IDLE_PROBE_SECONDS`, else after a `NOOP` that
330
+ finds it alive, else a new one."""
331
+ key = cache_key(backend)
332
+ client = _CACHED_CLIENTS(*key)
333
+ session = session_of(client)
334
+ if (
335
+ session is not None
336
+ and session.last_used is not None
337
+ and _now() - session.last_used <= IDLE_PROBE_SECONDS
338
+ ):
339
+ return client
340
+ try:
341
+ client.voidcmd("NOOP")
342
+ except (
343
+ OSError,
344
+ EOFError,
345
+ _ftplib.error_temp,
346
+ _ftplib.error_proto,
347
+ _ftplib.error_reply,
348
+ ):
349
+ # Replaced and closed (`_close_ftpclient`), not abandoned.
350
+ client = _CACHED_CLIENTS.invalidate(*key)
351
+ return client
352
+
353
+
354
+ @_contextlib.contextmanager
355
+ def wired(backend: BaseFTPBackend, label: str):
356
+ """The calling thread's live client of `backend`, for one command or
357
+ transfer; `label` names the path in the errors raised.
358
+
359
+ A complete error reply (`error_perm`/`error_temp`) leaves the control
360
+ channel in step and passes through for the caller to translate.
361
+ Anything else raised while the client is in use -- a dropped
362
+ connection, an unexpected reply, a KeyboardInterrupt or decode error
363
+ in the middle of a transfer whose final reply is still queued --
364
+ leaves it out of step, so the client is dropped from the cache and
365
+ closed; the next operation reconnects. Protocol errors and EOF are
366
+ re-raised as ConnectionError."""
367
+ key = cache_key(backend)
368
+ try:
369
+ client = live_client(backend)
370
+ except _ftplib.error_perm as error:
371
+ # A refused login is not a refusal for this path: it must not
372
+ # reach the caller's probes and read as "no such file".
373
+ raise PermissionError(
374
+ _errno.EACCES, f"FTP login refused: {error}", label
375
+ ) from error
376
+ except (_ftplib.Error, EOFError) as error:
377
+ raise ConnectionError(
378
+ _errno.ECONNREFUSED, f"FTP connection failed: {error!r}", label
379
+ ) from error
380
+ session = session_of(client)
381
+ if session is not None:
382
+ with _SESSION_LOCK:
383
+ session.busy += 1
384
+ try:
385
+ yield client
386
+ if session is not None:
387
+ session.last_used = _now()
388
+ except (_ftplib.error_perm, _ftplib.error_temp):
389
+ # A complete reply: the connection is in step.
390
+ if session is not None:
391
+ session.last_used = _now()
392
+ raise
393
+ except BaseException as error:
394
+ with _CACHED_CLIENTS.lock:
395
+ cached = _CACHED_CLIENTS.cache.get(key) is client
396
+ if cached:
397
+ _CACHED_CLIENTS.discard(*key)
398
+ if not cached:
399
+ client.close()
400
+ if isinstance(error, (_ftplib.Error, EOFError)):
401
+ raise ConnectionError(
402
+ _errno.ECONNABORTED, f"FTP session failed: {error!r}", label
403
+ ) from error
404
+ if isinstance(error, UnicodeDecodeError):
405
+ raise OSError(
406
+ _errno.EILSEQ,
407
+ "FTP listing is not valid "
408
+ f"{getattr(client, 'encoding', 'utf-8')}: {error}",
409
+ label,
410
+ ) from error
411
+ raise
412
+ finally:
413
+ if session is not None:
414
+ with _SESSION_LOCK:
415
+ session.busy -= 1
416
+ closing = session.retired and not session.busy
417
+ if closing:
418
+ client.close()
pathapi/ftp/_facts.py ADDED
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import calendar as _calendar
4
+ import stat as _stat
5
+ import time as _time
6
+
7
+ from .._names import is_safe_child_name as _is_safe_child_name
8
+ from .._filestat import FileStat
9
+
10
+ # Reply codes meaning "command not implemented" (RFC 959): the server lacks
11
+ # the command, as opposed to refusing it for this path.
12
+ UNSUPPORTED_REPLIES = ("500", "502", "504")
13
+
14
+ PERMISSION_WORDS = ("permission", "privilege", "denied", "not allowed", "access")
15
+
16
+
17
+ def reply_code(error: BaseException) -> str:
18
+ return str(error)[:3]
19
+
20
+
21
+ def is_child_entry(name: str, facts: dict) -> bool:
22
+ """Whether an MLSD entry names a child of the listed directory. The
23
+ `cdir`/`pdir` entries describe the directory itself and its parent, and
24
+ may carry any name -- RFC 3659's own example lists `tmp` and `/tmp` --
25
+ so they are skipped by type, not only as `.`/`..`; a name that is not one
26
+ path component (`providers.is_safe_child_name()`) is not a child name either."""
27
+ kind = str(facts.get("type", "")).lower()
28
+ return kind not in ("cdir", "pdir") and _is_safe_child_name(name)
29
+
30
+
31
+ def _parse_mlsd_time(value: str) -> int:
32
+ # MLSD "modify" fact: YYYYMMDDHHMMSS[.sss], always UTC (RFC 3659) --
33
+ # timegm, not datetime.timestamp(), which reads a naive time as local.
34
+ try:
35
+ return _calendar.timegm(_time.strptime(value[:14], "%Y%m%d%H%M%S"))
36
+ except ValueError:
37
+ return 0
38
+
39
+
40
+ def _facts_size(value) -> int:
41
+ """`st_size` from an MLSD `size` fact. A value that is not a plain
42
+ non-negative decimal number, or too large for any file, is unknown (0), as
43
+ a missing fact is."""
44
+ text = str(value or "")
45
+ if not (text.isascii() and text.isdigit()):
46
+ return 0
47
+ size = int(text)
48
+ return size if size < 1 << 63 else 0
49
+
50
+
51
+ def _parse_fact_line(line: str) -> "tuple[str, dict]":
52
+ """`(name, facts)` of one RFC 3659 `facts SP name` line; fact names are
53
+ lower-cased, as `ftplib.FTP.mlsd()` does."""
54
+ found, _, name = line.rstrip("\r\n").partition(" ")
55
+ facts = {}
56
+ for fact in found[:-1].split(";") if found else ():
57
+ key, _, value = fact.partition("=")
58
+ facts[key.lower()] = value
59
+ return name, facts
60
+
61
+
62
+ def lists_feature(reply: str, feature: str) -> bool:
63
+ """Whether a `FEAT` reply has a `feature` line (RFC 2389)."""
64
+ for line in reply.splitlines():
65
+ words = line.split(None, 1)
66
+ if line[:1] == " " and words and words[0].upper() == feature:
67
+ return True
68
+ return False
69
+
70
+
71
+ def mlst_facts(reply: str) -> "dict | None":
72
+ """The facts of the entry in an `MLST` reply (RFC 3659 7.2: `250-` line,
73
+ one entry line starting with a space, `250` line), or None when it holds
74
+ no entry that says what it is."""
75
+ for line in reply.splitlines()[1:]:
76
+ if line[:1] == " ":
77
+ _name, facts = _parse_fact_line(line[1:])
78
+ return facts if "type" in facts else None
79
+ return None
80
+
81
+
82
+ def _facts_mode(facts: dict, is_dir: bool) -> "int | None":
83
+ """`st_mode` from MLSD facts: `unix.mode` verbatim when the server sends
84
+ it, else the RFC 3659 `perm` fact (the login user's rights) as
85
+ read/write bits. None when neither fact is present."""
86
+ kind = _stat.S_IFDIR if is_dir else _stat.S_IFREG
87
+ unix_mode = facts.get("unix.mode")
88
+ if unix_mode:
89
+ try:
90
+ return kind | (int(unix_mode, 8) & 0o7777)
91
+ except ValueError:
92
+ pass
93
+ perm = facts.get("perm")
94
+ if perm is None:
95
+ return None
96
+ perm = set(perm.lower())
97
+ if is_dir:
98
+ bits = (0o555 if perm & set("el") else 0) | (0o200 if perm & set("cmp") else 0)
99
+ else:
100
+ bits = (0o444 if "r" in perm else 0) | (0o200 if perm & set("aw") else 0)
101
+ return kind | bits
102
+
103
+
104
+ def facts_to_filestat(facts: dict) -> FileStat:
105
+ """The `FileStat` of an entry described by MLSD/MLST `facts`."""
106
+ # Fact values are case-insensitive (RFC 3659 7.5.1).
107
+ kind = str(facts.get("type", "file")).lower()
108
+ size = _facts_size(facts.get("size"))
109
+ modify = facts.get("modify")
110
+ mtime = _parse_mlsd_time(modify) if modify else 0
111
+ is_dir = kind in ("dir", "cdir", "pdir")
112
+ return FileStat(
113
+ st_mode=_facts_mode(facts, is_dir),
114
+ st_size=size,
115
+ st_mtime=mtime,
116
+ is_dir=is_dir,
117
+ )