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/_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
|
pathapi/glob/__init__.py
ADDED
|
@@ -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
|
+
]
|