asanypath 2.0.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.
- asanypath/__init__.py +81 -0
- asanypath/__init__.pyi +62 -0
- asanypath/_credstore.py +102 -0
- asanypath/_transfer.py +216 -0
- asanypath/_version.py +24 -0
- asanypath/artifactory.py +366 -0
- asanypath/asanypath.py +556 -0
- asanypath/asanypath.pyi +225 -0
- asanypath/azure.py +274 -0
- asanypath/batcher.py +206 -0
- asanypath/cli.py +2566 -0
- asanypath/cloud.py +1042 -0
- asanypath/common.py +355 -0
- asanypath/exceptions.py +43 -0
- asanypath/ftp.py +543 -0
- asanypath/gcs.py +253 -0
- asanypath/http.py +516 -0
- asanypath/local.py +817 -0
- asanypath/options.py +57 -0
- asanypath/s3.py +277 -0
- asanypath/ssh.py +776 -0
- asanypath/sync.py +220 -0
- asanypath/unsupported.py +202 -0
- asanypath-2.0.0.dist-info/METADATA +591 -0
- asanypath-2.0.0.dist-info/RECORD +28 -0
- asanypath-2.0.0.dist-info/WHEEL +4 -0
- asanypath-2.0.0.dist-info/entry_points.txt +2 -0
- asanypath-2.0.0.dist-info/licenses/LICENSE +21 -0
asanypath/ftp.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
# Copyright (C) 2026 Axis Communications AB, Lund, Sweden
|
|
2
|
+
#
|
|
3
|
+
# Use of this source code is governed by an MIT-style
|
|
4
|
+
# license that can be found in the LICENSE file or at
|
|
5
|
+
# https://opensource.org/licenses/MIT.
|
|
6
|
+
|
|
7
|
+
"""FTP / FTPS path implementation using :mod:`aioftp` (optional dependency).
|
|
8
|
+
|
|
9
|
+
URL format::
|
|
10
|
+
|
|
11
|
+
ftp://[user[:password]@]host[:port]/path/to/file
|
|
12
|
+
ftps://[user[:password]@]host[:port]/path/to/file # explicit TLS (AUTH TLS)
|
|
13
|
+
|
|
14
|
+
Recommended secret store: ``~/.netrc`` (stdlib :mod:`netrc`; honored when no
|
|
15
|
+
password is in the URL/kwargs). Password env vars are deliberately **not**
|
|
16
|
+
supported.
|
|
17
|
+
|
|
18
|
+
When stdin is a TTY and the server rejects the initial login, the backend
|
|
19
|
+
prompts once via :mod:`getpass` and retries. Set ``ASANYPATH_INTERACTIVE=0``
|
|
20
|
+
to force non-interactive behavior in scripts.
|
|
21
|
+
|
|
22
|
+
Non-secret env defaults (used only when the URL doesn't supply)::
|
|
23
|
+
|
|
24
|
+
FTP_USER (default: "anonymous")
|
|
25
|
+
FTP_PORT (default: 21 for both ftp and ftps)
|
|
26
|
+
FTP_HOST
|
|
27
|
+
|
|
28
|
+
``ftps://`` is **explicit** FTPS (RFC 4217, ``AUTH TLS`` over port 21). For
|
|
29
|
+
implicit FTPS pass ``port=990`` — the same TLS context applies. lftp-style
|
|
30
|
+
bookmarks (``~/.lftp/rc``) are not consulted; ``~/.netrc`` is the only
|
|
31
|
+
credential store.
|
|
32
|
+
|
|
33
|
+
Install this backend with ``pip install asanypath[ftp]``.
|
|
34
|
+
|
|
35
|
+
FTP is a deprecated protocol with no atomic rename, no guaranteed range reads,
|
|
36
|
+
and no rich metadata. This implementation embraces the "any" in
|
|
37
|
+
:mod:`asanypath` only for the motivated user — the operations that cannot be
|
|
38
|
+
implemented reliably raise :class:`NotImplementedError` rather than silently
|
|
39
|
+
degrading:
|
|
40
|
+
|
|
41
|
+
* :meth:`FTPPath.replace` — FTP rename is not atomic
|
|
42
|
+
* :meth:`FTPPath._range_read` — REST support is server-dependent
|
|
43
|
+
* :meth:`FTPPath.open` — no random-access file handles
|
|
44
|
+
|
|
45
|
+
Connections (one logged-in :class:`aioftp.Client` each) are pooled per
|
|
46
|
+
``(host, port, user, tls)`` at module level. Each operation is serialized
|
|
47
|
+
behind a per-pool :class:`asyncio.Lock` since a single FTP control channel
|
|
48
|
+
cannot multiplex commands. Call :func:`disconnect_all` to close everything.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import asyncio
|
|
54
|
+
import getpass
|
|
55
|
+
import netrc
|
|
56
|
+
import sys
|
|
57
|
+
import warnings
|
|
58
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
59
|
+
from datetime import datetime, timezone
|
|
60
|
+
from os import getenv
|
|
61
|
+
from types import SimpleNamespace
|
|
62
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
63
|
+
|
|
64
|
+
import aioftp
|
|
65
|
+
|
|
66
|
+
from asanypath.cloud import CloudPathMixin
|
|
67
|
+
from asanypath.options import AccessGrant, AccessPolicy, AccessPolicyPatch, BackendOptions
|
|
68
|
+
|
|
69
|
+
if TYPE_CHECKING:
|
|
70
|
+
from typing import Self
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
T = TypeVar("T")
|
|
74
|
+
|
|
75
|
+
_CLIENT_CACHE: dict[tuple, aioftp.Client] = {}
|
|
76
|
+
_CLIENT_LOCKS: dict[tuple, asyncio.Lock] = {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def disconnect_all() -> None:
|
|
80
|
+
"""Close every cached FTP client."""
|
|
81
|
+
for client in list(_CLIENT_CACHE.values()):
|
|
82
|
+
try:
|
|
83
|
+
# ``quit`` is the polite shutdown but it's a coroutine; in a
|
|
84
|
+
# synchronous cleanup we can only close the underlying streams.
|
|
85
|
+
client.close()
|
|
86
|
+
except Exception: # noqa: BLE001 # pragma: no cover
|
|
87
|
+
pass # pragma: no cover
|
|
88
|
+
_CLIENT_CACHE.clear()
|
|
89
|
+
_CLIENT_LOCKS.clear()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_UNSET = object()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _interactive() -> bool:
|
|
96
|
+
"""Whether the backend may prompt the user for credentials."""
|
|
97
|
+
if getenv("ASANYPATH_INTERACTIVE") == "0":
|
|
98
|
+
return False
|
|
99
|
+
try:
|
|
100
|
+
return sys.stdin.isatty()
|
|
101
|
+
except (AttributeError, ValueError): # pragma: no cover
|
|
102
|
+
return False # pragma: no cover
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def _prompt_password(label: str) -> str:
|
|
106
|
+
loop = asyncio.get_running_loop()
|
|
107
|
+
return await loop.run_in_executor(None, getpass.getpass, f"Password for {label}: ")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_mlst_mtime(modify: str | None) -> float | None:
|
|
111
|
+
"""Parse an MLST ``modify`` timestamp (``YYYYMMDDHHMMSS[.ffffff]``)."""
|
|
112
|
+
if not modify:
|
|
113
|
+
return None
|
|
114
|
+
fmt = "%Y%m%d%H%M%S.%f" if "." in modify else "%Y%m%d%H%M%S"
|
|
115
|
+
return datetime.strptime(modify, fmt).replace(tzinfo=timezone.utc).timestamp()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_NETRC_WARNED = False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _netrc_lookup(host: str | None) -> tuple[str | None, str | None, str | None]:
|
|
122
|
+
"""Return ``(login, password, account)`` for ``host`` from ``~/.netrc``.
|
|
123
|
+
|
|
124
|
+
Honors ``$NETRC`` like curl/requests. Returns ``(None, None, None)`` when
|
|
125
|
+
the file is missing or the host has no entry. Emits a single warning when
|
|
126
|
+
the file exists but cannot be parsed (e.g., unsafe permissions).
|
|
127
|
+
"""
|
|
128
|
+
if not host:
|
|
129
|
+
return (None, None, None)
|
|
130
|
+
path = getenv("NETRC") or None
|
|
131
|
+
try:
|
|
132
|
+
rc = netrc.netrc(path)
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
return (None, None, None)
|
|
135
|
+
except netrc.NetrcParseError as exc:
|
|
136
|
+
global _NETRC_WARNED
|
|
137
|
+
if not _NETRC_WARNED:
|
|
138
|
+
warnings.warn(
|
|
139
|
+
f"~/.netrc could not be parsed ({exc}); falling back to anonymous FTP.",
|
|
140
|
+
stacklevel=2,
|
|
141
|
+
)
|
|
142
|
+
_NETRC_WARNED = True
|
|
143
|
+
return (None, None, None)
|
|
144
|
+
auth = rc.authenticators(host)
|
|
145
|
+
if auth is None:
|
|
146
|
+
return (None, None, None)
|
|
147
|
+
login, account, password = auth
|
|
148
|
+
return (login, password, account)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class FTPPath(CloudPathMixin):
|
|
152
|
+
"""Async FTP path backed by :mod:`aioftp`."""
|
|
153
|
+
|
|
154
|
+
protocol: str = "ftp"
|
|
155
|
+
_tls: bool = False
|
|
156
|
+
_default_port: int = 21
|
|
157
|
+
_supports_range_read: bool = False
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
*parts,
|
|
162
|
+
host: str | None = None,
|
|
163
|
+
port: int | None = None,
|
|
164
|
+
username: str | None = None,
|
|
165
|
+
password: str | None = None,
|
|
166
|
+
account: str | None = None,
|
|
167
|
+
) -> None:
|
|
168
|
+
super().__init__(*parts)
|
|
169
|
+
cfg = self.env_config
|
|
170
|
+
self._host = host or self._path.host or cfg.host
|
|
171
|
+
self._port = port or self._path.port or cfg.port
|
|
172
|
+
url_pw = self._path.password
|
|
173
|
+
url_user = self._path.user
|
|
174
|
+
# Username precedence: kwarg > URL > netrc > env default.
|
|
175
|
+
# Password precedence: kwarg > URL > netrc > anonymous default.
|
|
176
|
+
netrc_user, netrc_pw, netrc_account = _netrc_lookup(self._host)
|
|
177
|
+
self._user = username or url_user or netrc_user or cfg.user
|
|
178
|
+
if password is not None:
|
|
179
|
+
self._password = password
|
|
180
|
+
elif url_pw is not None:
|
|
181
|
+
self._password = url_pw
|
|
182
|
+
elif netrc_pw is not None and (username is None and url_user is None):
|
|
183
|
+
# Only use netrc password when we also took the netrc username,
|
|
184
|
+
# so we don't pair the wrong credential with an overridden user.
|
|
185
|
+
self._password = netrc_pw
|
|
186
|
+
elif self._user in (None, "anonymous"):
|
|
187
|
+
self._password = "anonymous@"
|
|
188
|
+
else:
|
|
189
|
+
# Real user but no password: leave None so the keyring cache or
|
|
190
|
+
# the interactive prompt can supply one.
|
|
191
|
+
self._password = None
|
|
192
|
+
self._account = account if account is not None else netrc_account
|
|
193
|
+
# Promote explicit params into class cache so derived instances
|
|
194
|
+
# (parent, /, with_name, ...) inherit them.
|
|
195
|
+
if host:
|
|
196
|
+
cfg.host = host
|
|
197
|
+
if port:
|
|
198
|
+
cfg.port = port
|
|
199
|
+
if username:
|
|
200
|
+
cfg.user = username
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def _create_env_config(cls) -> SimpleNamespace:
|
|
204
|
+
port = getenv("FTP_PORT")
|
|
205
|
+
return SimpleNamespace(
|
|
206
|
+
host=getenv("FTP_HOST"),
|
|
207
|
+
port=int(port) if port else cls._default_port,
|
|
208
|
+
user=getenv("FTP_USER") or "anonymous",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
# CloudPathMixin hooks
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def _item_path(self) -> str:
|
|
217
|
+
return self._path.path or "/"
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def _native_kwargs(self) -> dict:
|
|
221
|
+
return {"host": self._host, "port": self._port, "user": self._user, "tls": self._tls}
|
|
222
|
+
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
# Connection pool
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def _client_key(self) -> tuple:
|
|
229
|
+
return (self._host, self._port, self._user, self._tls)
|
|
230
|
+
|
|
231
|
+
async def _ensure_client(self) -> aioftp.Client:
|
|
232
|
+
key = self._client_key
|
|
233
|
+
client = _CLIENT_CACHE.get(key)
|
|
234
|
+
if client is not None and client.stream is not None:
|
|
235
|
+
return client
|
|
236
|
+
from asanypath import _credstore
|
|
237
|
+
|
|
238
|
+
cred_key = f"ftp:{self._host or ''}:{self._port or ''}:{self._user or ''}"
|
|
239
|
+
password = self._password
|
|
240
|
+
used_cache = False
|
|
241
|
+
if password is None and _interactive():
|
|
242
|
+
cached = _credstore.load(cred_key)
|
|
243
|
+
if cached is not None:
|
|
244
|
+
password = cached
|
|
245
|
+
used_cache = True
|
|
246
|
+
try:
|
|
247
|
+
client = await self._connect_and_login(password)
|
|
248
|
+
except aioftp.StatusCodeError:
|
|
249
|
+
if used_cache:
|
|
250
|
+
_credstore.forget(cred_key)
|
|
251
|
+
if not _interactive():
|
|
252
|
+
raise
|
|
253
|
+
label = f"{self._user}@{self._host}" if self._user else self._host or "ftp"
|
|
254
|
+
prompted = await _prompt_password(label)
|
|
255
|
+
client = await self._connect_and_login(prompted)
|
|
256
|
+
_credstore.save(cred_key, prompted)
|
|
257
|
+
_CLIENT_CACHE[key] = client
|
|
258
|
+
return client
|
|
259
|
+
|
|
260
|
+
async def _connect_and_login(self, password: str | None) -> aioftp.Client:
|
|
261
|
+
client = aioftp.Client(ssl=True if self._tls else None)
|
|
262
|
+
await client.connect(self._host, self._port)
|
|
263
|
+
try:
|
|
264
|
+
if self._account is not None:
|
|
265
|
+
await client.login(self._user, password, account=self._account)
|
|
266
|
+
else:
|
|
267
|
+
await client.login(self._user, password)
|
|
268
|
+
except BaseException:
|
|
269
|
+
try:
|
|
270
|
+
client.close()
|
|
271
|
+
except Exception: # noqa: BLE001
|
|
272
|
+
pass
|
|
273
|
+
raise
|
|
274
|
+
return client
|
|
275
|
+
|
|
276
|
+
async def _op(self, fn: Callable[[aioftp.Client], Awaitable[T]]) -> T:
|
|
277
|
+
"""Run ``fn(client)`` serialized on the per-pool lock."""
|
|
278
|
+
key = self._client_key
|
|
279
|
+
lock = _CLIENT_LOCKS.setdefault(key, asyncio.Lock())
|
|
280
|
+
async with lock:
|
|
281
|
+
client = await self._ensure_client()
|
|
282
|
+
return await fn(client)
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
# Stat / type checks
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
async def exists(self) -> bool:
|
|
289
|
+
return await self._op(lambda c: c.exists(self._item_path))
|
|
290
|
+
|
|
291
|
+
async def is_dir(self) -> bool:
|
|
292
|
+
try:
|
|
293
|
+
return await self._op(lambda c: c.is_dir(self._item_path))
|
|
294
|
+
except aioftp.StatusCodeError:
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
async def is_file(self) -> bool:
|
|
298
|
+
try:
|
|
299
|
+
return await self._op(lambda c: c.is_file(self._item_path))
|
|
300
|
+
except aioftp.StatusCodeError:
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
async def is_symlink(self) -> bool:
|
|
304
|
+
# FTP has no portable symlink probe.
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
async def stat(self, *, follow_symlinks: bool = True):
|
|
308
|
+
info = await self._op(lambda c: c.stat(self._item_path))
|
|
309
|
+
size = info.get("size")
|
|
310
|
+
mode_raw = info.get("unix.mode")
|
|
311
|
+
return SimpleNamespace(
|
|
312
|
+
st_size=int(size) if size is not None else 0,
|
|
313
|
+
st_mtime=_parse_mlst_mtime(info.get("modify")),
|
|
314
|
+
st_atime=None,
|
|
315
|
+
st_ctime=_parse_mlst_mtime(info.get("create") or info.get("modify")),
|
|
316
|
+
st_mode=int(mode_raw, 8) if isinstance(mode_raw, str) else mode_raw,
|
|
317
|
+
st_uid=None,
|
|
318
|
+
st_gid=None,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
async def get_access_policy(
|
|
322
|
+
self, *, backend_options: BackendOptions | None = None
|
|
323
|
+
) -> AccessPolicy:
|
|
324
|
+
"""Return mode grants when the server exposes MLST ``unix.mode``."""
|
|
325
|
+
info = await self._op(lambda client: client.stat(self._item_path))
|
|
326
|
+
mode_raw = info.get("unix.mode")
|
|
327
|
+
if not isinstance(mode_raw, str):
|
|
328
|
+
raise NotImplementedError("FTP server does not expose POSIX mode metadata")
|
|
329
|
+
mode = int(mode_raw, 8)
|
|
330
|
+
|
|
331
|
+
def actions(shift: int) -> frozenset[str]:
|
|
332
|
+
return frozenset(
|
|
333
|
+
action
|
|
334
|
+
for action, bit in (("read", 4), ("write", 2), ("execute", 1))
|
|
335
|
+
if mode >> shift & bit
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return AccessPolicy(
|
|
339
|
+
grants=(
|
|
340
|
+
AccessGrant("owner", actions(6)),
|
|
341
|
+
AccessGrant("group", actions(3)),
|
|
342
|
+
AccessGrant("everyone", actions(0)),
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
async def update_access_policy(
|
|
347
|
+
self, policy_patch: AccessPolicyPatch, *, backend_options: BackendOptions | None = None
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Apply mode grants with the optional FTP ``SITE CHMOD`` extension."""
|
|
350
|
+
if policy_patch.owner is not None or policy_patch.group is not None:
|
|
351
|
+
raise NotImplementedError("FTP does not support portable ownership changes")
|
|
352
|
+
if not policy_patch.grants:
|
|
353
|
+
return
|
|
354
|
+
info = await self._op(lambda client: client.stat(self._item_path))
|
|
355
|
+
mode_raw = info.get("unix.mode")
|
|
356
|
+
if not isinstance(mode_raw, str):
|
|
357
|
+
raise NotImplementedError("FTP server does not expose POSIX mode metadata")
|
|
358
|
+
mode = int(mode_raw, 8)
|
|
359
|
+
shifts = {"owner": 6, "group": 3, "everyone": 0}
|
|
360
|
+
for grant in policy_patch.grants:
|
|
361
|
+
if grant.principal not in shifts:
|
|
362
|
+
raise ValueError(f"unsupported FTP policy principal: {grant.principal}")
|
|
363
|
+
bits = sum({"read": 4, "write": 2, "execute": 1}[action] for action in grant.actions)
|
|
364
|
+
shift = shifts[grant.principal]
|
|
365
|
+
mode = mode & ~(0o7 << shift) | (bits << shift)
|
|
366
|
+
command = f"SITE CHMOD {mode & 0o777:03o} {self._item_path}"
|
|
367
|
+
await self._op(lambda client: client.command(command, "2xx"))
|
|
368
|
+
|
|
369
|
+
async def checksums(self) -> dict[str, str]:
|
|
370
|
+
from hashlib import md5, sha1, sha256
|
|
371
|
+
|
|
372
|
+
data = await self.read_bytes()
|
|
373
|
+
return {
|
|
374
|
+
"md5": md5(data).hexdigest(),
|
|
375
|
+
"sha1": sha1(data).hexdigest(),
|
|
376
|
+
"sha256": sha256(data).hexdigest(),
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
# ------------------------------------------------------------------
|
|
380
|
+
# Read / write
|
|
381
|
+
# ------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
async def read_bytes(self) -> bytes:
|
|
384
|
+
path = self._item_path
|
|
385
|
+
|
|
386
|
+
async def _read(client: aioftp.Client) -> bytes:
|
|
387
|
+
async with client.download_stream(path) as stream:
|
|
388
|
+
return await stream.read()
|
|
389
|
+
|
|
390
|
+
return await self._op(_read)
|
|
391
|
+
|
|
392
|
+
async def write_bytes(self, data: bytes) -> int:
|
|
393
|
+
path = self._item_path
|
|
394
|
+
|
|
395
|
+
async def _write(client: aioftp.Client) -> int:
|
|
396
|
+
async with client.upload_stream(path) as stream:
|
|
397
|
+
await stream.write(data)
|
|
398
|
+
return len(data)
|
|
399
|
+
|
|
400
|
+
return await self._op(_write)
|
|
401
|
+
|
|
402
|
+
async def _range_read(self, start: int, end: int) -> bytes:
|
|
403
|
+
raise NotImplementedError(
|
|
404
|
+
f"{type(self).__name__}._range_read() is unreliable on FTP "
|
|
405
|
+
"(REST support is server-dependent); use read_bytes()."
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# ------------------------------------------------------------------
|
|
409
|
+
# Directory / unlink / rename
|
|
410
|
+
# ------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
async def unlink(self, missing_ok: bool = False) -> None:
|
|
413
|
+
path = self._item_path
|
|
414
|
+
try:
|
|
415
|
+
await self._op(lambda c: c.remove_file(path))
|
|
416
|
+
except aioftp.StatusCodeError as e:
|
|
417
|
+
if missing_ok and _is_missing(e):
|
|
418
|
+
return
|
|
419
|
+
if _is_missing(e):
|
|
420
|
+
raise FileNotFoundError(2, f"No such file: '{self}'") from e
|
|
421
|
+
raise
|
|
422
|
+
|
|
423
|
+
async def mkdir(self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False) -> None:
|
|
424
|
+
path = self._item_path
|
|
425
|
+
if exist_ok and await self._op(lambda c: c.exists(path)):
|
|
426
|
+
return
|
|
427
|
+
try:
|
|
428
|
+
await self._op(lambda c: c.make_directory(path, parents=parents))
|
|
429
|
+
except aioftp.StatusCodeError as e:
|
|
430
|
+
if exist_ok:
|
|
431
|
+
return
|
|
432
|
+
raise FileExistsError(17, f"File exists: '{self}'") from e
|
|
433
|
+
|
|
434
|
+
async def rmdir(self, *, recursive: bool = False) -> None:
|
|
435
|
+
path = self._item_path
|
|
436
|
+
if recursive:
|
|
437
|
+
await self._op(lambda c: c.remove(path))
|
|
438
|
+
return
|
|
439
|
+
await self._op(lambda c: c.remove_directory(path))
|
|
440
|
+
|
|
441
|
+
async def rename(self, target: str | Self, *, force: bool = False) -> Self:
|
|
442
|
+
target_path = target if isinstance(target, type(self)) else type(self)(str(target))
|
|
443
|
+
from asanypath._transfer import async_destination_state, require_force
|
|
444
|
+
|
|
445
|
+
needs_copy, same_path, destination_exists = await async_destination_state(self, target_path)
|
|
446
|
+
if not needs_copy:
|
|
447
|
+
if same_path:
|
|
448
|
+
return target_path
|
|
449
|
+
await self.unlink()
|
|
450
|
+
return target_path
|
|
451
|
+
if destination_exists:
|
|
452
|
+
if not force:
|
|
453
|
+
require_force(force, target_path)
|
|
454
|
+
return await super().rename(str(target), force=True)
|
|
455
|
+
if (target_path._host, target_path._port) == (self._host, self._port):
|
|
456
|
+
src, dst = self._item_path, target_path._item_path
|
|
457
|
+
await self._op(lambda c: c.rename(src, dst))
|
|
458
|
+
return target_path
|
|
459
|
+
return await super().rename(str(target))
|
|
460
|
+
|
|
461
|
+
async def replace(self, target: str | Self) -> Self:
|
|
462
|
+
raise NotImplementedError(
|
|
463
|
+
f"{type(self).__name__}.replace() is not supported: FTP rename is "
|
|
464
|
+
"not atomic and cannot guarantee replace semantics. Use rename() "
|
|
465
|
+
"after explicit unlink() if you accept the race."
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
async def touch(self, mode: int = 0o666, exist_ok: bool = True) -> None:
|
|
469
|
+
if await self.exists():
|
|
470
|
+
if not exist_ok:
|
|
471
|
+
raise FileExistsError(17, f"File exists: '{self}'")
|
|
472
|
+
# FTP has no portable MFMT/UTIME; rewrite a zero-byte payload only if the
|
|
473
|
+
# file is already empty would still be a lie. Refuse rather than fake it.
|
|
474
|
+
return
|
|
475
|
+
await self.write_bytes(b"")
|
|
476
|
+
|
|
477
|
+
async def iterdir(self, *, fresh: bool = False) -> AsyncIterator[Self]:
|
|
478
|
+
path = self._item_path
|
|
479
|
+
|
|
480
|
+
async def _collect(client: aioftp.Client) -> list:
|
|
481
|
+
return [item async for item in client.list(path)]
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
entries = await self._op(_collect)
|
|
485
|
+
except aioftp.StatusCodeError as e:
|
|
486
|
+
raise NotADirectoryError(20, f"Not a directory: '{self}'") from e
|
|
487
|
+
for entry_path, _info in entries:
|
|
488
|
+
yield self / entry_path.name
|
|
489
|
+
|
|
490
|
+
async def walk(
|
|
491
|
+
self,
|
|
492
|
+
top_down: bool = True,
|
|
493
|
+
on_error=None,
|
|
494
|
+
follow_symlinks: bool = False,
|
|
495
|
+
) -> AsyncIterator[tuple[Self, list[str], list[str]]]:
|
|
496
|
+
path = self._item_path
|
|
497
|
+
|
|
498
|
+
async def _collect(client: aioftp.Client) -> list:
|
|
499
|
+
return [item async for item in client.list(path)]
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
entries = await self._op(_collect)
|
|
503
|
+
except aioftp.StatusCodeError as e:
|
|
504
|
+
if on_error is not None:
|
|
505
|
+
on_error(e)
|
|
506
|
+
return
|
|
507
|
+
raise
|
|
508
|
+
dirs: list[Self] = []
|
|
509
|
+
files: list[Self] = []
|
|
510
|
+
for entry_path, info in entries:
|
|
511
|
+
child = self / entry_path.name
|
|
512
|
+
if info.get("type") == "dir":
|
|
513
|
+
dirs.append(child)
|
|
514
|
+
else:
|
|
515
|
+
files.append(child)
|
|
516
|
+
if top_down:
|
|
517
|
+
yield self, [d.name for d in dirs], [f.name for f in files]
|
|
518
|
+
for d in dirs:
|
|
519
|
+
async for triple in d.walk(
|
|
520
|
+
top_down=top_down, on_error=on_error, follow_symlinks=follow_symlinks
|
|
521
|
+
):
|
|
522
|
+
yield triple
|
|
523
|
+
if not top_down:
|
|
524
|
+
yield self, [d.name for d in dirs], [f.name for f in files]
|
|
525
|
+
|
|
526
|
+
def open(self, mode="r", buffering=-1, encoding=None, errors=None, newline=None):
|
|
527
|
+
raise NotImplementedError(
|
|
528
|
+
f"{type(self).__name__}.open() not implemented; "
|
|
529
|
+
"use read_bytes/write_bytes/read_text/write_text."
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
class FTPSPath(FTPPath):
|
|
534
|
+
"""FTP over explicit TLS (FTPES, AUTH TLS)."""
|
|
535
|
+
|
|
536
|
+
protocol: str = "ftps"
|
|
537
|
+
_tls: bool = True
|
|
538
|
+
_default_port: int = 21
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _is_missing(err: aioftp.StatusCodeError) -> bool:
|
|
542
|
+
"""True if the server reply carries a 550 'no such file' code."""
|
|
543
|
+
return any(str(c) == "550" for c in err.received_codes)
|