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/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)