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.
- pathapi/AGENTS.md +2355 -0
- pathapi/README.md +205 -0
- pathapi/__init__.py +62 -0
- pathapi/_commit.py +116 -0
- pathapi/_discard.py +92 -0
- pathapi/_errorpolicy.py +106 -0
- pathapi/_filestat.py +194 -0
- pathapi/_lru.py +138 -0
- pathapi/_names.py +70 -0
- pathapi/_permissions.py +102 -0
- pathapi/_stubs.py +76 -0
- pathapi/_tree.py +136 -0
- pathapi/archive/__init__.py +44 -0
- pathapi/archive/_backend.py +393 -0
- pathapi/archive/_names.py +63 -0
- pathapi/archive/_pack.py +394 -0
- pathapi/archive/_path.py +352 -0
- pathapi/archive/_snapshot.py +269 -0
- pathapi/archive/_tar.py +126 -0
- pathapi/archive/_write.py +359 -0
- pathapi/archive/_zip.py +563 -0
- pathapi/archive/_zipentries.py +272 -0
- pathapi/checksum.py +61 -0
- pathapi/exceptions.py +22 -0
- pathapi/fspath.py +513 -0
- pathapi/ftp/__init__.py +27 -0
- pathapi/ftp/_backend.py +418 -0
- pathapi/ftp/_facts.py +117 -0
- pathapi/ftp/_path.py +582 -0
- pathapi/glob/__init__.py +27 -0
- pathapi/glob/_entries.py +103 -0
- pathapi/glob/_patterns.py +164 -0
- pathapi/glob/_recursion.py +180 -0
- pathapi/glob/_selection.py +303 -0
- pathapi/http/__init__.py +84 -0
- pathapi/http/_body.py +156 -0
- pathapi/http/_builtin.py +553 -0
- pathapi/http/_client.py +211 -0
- pathapi/http/_errors.py +79 -0
- pathapi/http/_follow.py +230 -0
- pathapi/http/_headers.py +233 -0
- pathapi/http/_pool.py +169 -0
- pathapi/http/_proxies.py +142 -0
- pathapi/http/_response.py +255 -0
- pathapi/http/_settings.py +12 -0
- pathapi/http/_status.py +68 -0
- pathapi/http/_transports.py +173 -0
- pathapi/http/testing/__init__.py +80 -0
- pathapi/http/testing/__main__.py +10 -0
- pathapi/http/testing/suite/conftest.py +98 -0
- pathapi/http/testing/suite/test_client_default.py +285 -0
- pathapi/http/testing/suite/test_client_errors.py +181 -0
- pathapi/http/testing/suite/test_client_peer_text.py +86 -0
- pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
- pathapi/http/testing/suite/test_client_redirects.py +231 -0
- pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
- pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
- pathapi/http/testing/suite/test_transport_body.py +302 -0
- pathapi/http/testing/suite/test_transport_send.py +662 -0
- pathapi/http/testing/suite/transport_fakes.py +133 -0
- pathapi/http/testing/suite/transport_guard.py +174 -0
- pathapi/http/testing/suite/transport_wire.py +511 -0
- pathapi/mempath.py +438 -0
- pathapi/path/__init__.py +19 -0
- pathapi/path/_info.py +10 -0
- pathapi/path/_path.py +198 -0
- pathapi/path/_pure.py +522 -0
- pathapi/path/_relation.py +10 -0
- pathapi/path/_transfer.py +10 -0
- pathapi/protocols/__init__.py +35 -0
- pathapi/protocols/_bundles.py +22 -0
- pathapi/protocols/_errors.py +12 -0
- pathapi/protocols/_info.py +65 -0
- pathapi/protocols/_relation.py +191 -0
- pathapi/protocols/_removal.py +69 -0
- pathapi/protocols/_support.py +122 -0
- pathapi/protocols/_transfer.py +100 -0
- pathapi/protocols/checksum.py +80 -0
- pathapi/protocols/fs.py +429 -0
- pathapi/protocols/io.py +224 -0
- pathapi/protocols/links.py +135 -0
- pathapi/protocols/pure.py +31 -0
- pathapi/protocols/resolve.py +97 -0
- pathapi/protocols/transfer.py +656 -0
- pathapi/protocols/tree.py +492 -0
- pathapi/providers.py +48 -0
- pathapi/py.typed +1 -0
- pathapi/sync/__init__.py +8 -0
- pathapi/sync/_compare.py +157 -0
- pathapi/sync/_events.py +97 -0
- pathapi/sync/_links.py +93 -0
- pathapi/sync/_offer.py +33 -0
- pathapi/sync/_paths.py +50 -0
- pathapi/sync/_replace.py +98 -0
- pathapi/sync/_syncer.py +989 -0
- pathapi/testing/__init__.py +96 -0
- pathapi/testing/_contract.py +522 -0
- pathapi/testing/_fixtures.py +83 -0
- pathapi/testing/_read.py +279 -0
- pathapi-0.1.0.dist-info/METADATA +242 -0
- pathapi-0.1.0.dist-info/RECORD +104 -0
- pathapi-0.1.0.dist-info/WHEEL +4 -0
- pathapi-0.1.0.dist-info/entry_points.txt +2 -0
- pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
pathapi/ftp/_backend.py
ADDED
|
@@ -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
|
+
)
|