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/ssh.py ADDED
@@ -0,0 +1,776 @@
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
+ """SSH/SFTP path implementation using asyncssh.
8
+
9
+ URL format::
10
+
11
+ ssh://[user@]host[:port]/path/to/file
12
+
13
+ Recommended: define hosts in ``~/.ssh/config`` and use ``ssh://alias/path``.
14
+ Aliases are resolved eagerly via the OpenSSH client config parser; see
15
+ :attr:`SSHPath.resolved_target`. Two aliases that resolve to the same
16
+ ``(host, port, user)`` share one pooled connection.
17
+
18
+ Password env vars are deliberately **not** supported -- use key-based auth
19
+ (agent or ``IdentityFile`` in ``~/.ssh/config``), or pass ``password=`` as a
20
+ kwarg for programmatic use.
21
+
22
+ When stdin is a TTY and no ``password=`` was supplied, the backend prompts
23
+ lazily via :mod:`getpass` for password / keyboard-interactive challenges
24
+ (PAM, OTP, 2FA). Set ``ASANYPATH_INTERACTIVE=0`` to force non-interactive
25
+ behavior in scripts.
26
+
27
+ Non-secret env defaults (used only when the URL and ssh_config don't supply):
28
+
29
+ SSH_USER (default: $USER)
30
+ SSH_KEY_FILE single path; pass a list via kwarg for several
31
+ SSH_KNOWN_HOSTS "none" to disable host-key check; otherwise a file path.
32
+ Default: asyncssh's default (~/.ssh/known_hosts)
33
+ SSH_CONFIG OpenSSH client config path; "" disables; otherwise
34
+ ~/.ssh/config and /etc/ssh/ssh_config are consulted
35
+ when present.
36
+
37
+ Connections are pooled per resolved ``(host, port, user)`` at module level;
38
+ one cached ``SFTPClient`` is reused for the lifetime of each connection.
39
+ Call :func:`disconnect_all` to close everything (mainly for tests/shutdown).
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import asyncio
45
+ import getpass
46
+ import stat as stat_module
47
+ import sys
48
+ from collections.abc import AsyncIterator
49
+ from os import getenv
50
+ from pathlib import Path
51
+ from time import time
52
+ from types import SimpleNamespace
53
+ from typing import TYPE_CHECKING
54
+
55
+ import asyncssh
56
+ from asyncssh.config import SSHClientConfig
57
+
58
+ from asanypath.cloud import CloudPathMixin
59
+ from asanypath.options import AccessGrant, AccessPolicy, AccessPolicyPatch, BackendOptions
60
+
61
+ if TYPE_CHECKING:
62
+ from typing import Self
63
+
64
+
65
+ _CONN_CACHE: dict[tuple, asyncssh.SSHClientConnection] = {}
66
+ _CONN_LOCKS: dict[tuple, asyncio.Lock] = {}
67
+
68
+
69
+ def disconnect_all() -> None:
70
+ """Close every cached SSH connection."""
71
+ for conn in list(_CONN_CACHE.values()):
72
+ try:
73
+ conn.close()
74
+ except Exception: # noqa: BLE001 # pragma: no cover
75
+ pass # pragma: no cover
76
+ _CONN_CACHE.clear()
77
+ _CONN_LOCKS.clear()
78
+
79
+
80
+ _UNSET = object()
81
+
82
+ # asyncssh's ``SFTPNoSuchFile`` does not subclass ``FileNotFoundError``/``OSError``,
83
+ # so existence checks must translate it explicitly (else exists()/is_dir() raise
84
+ # instead of returning False, breaking copy/rename to new remote paths).
85
+ _sftp_no_such_file = getattr(asyncssh.sftp, "SFTPNoSuchFile", None)
86
+ _NOT_FOUND_ERRORS: tuple[type[BaseException], ...] = (
87
+ (FileNotFoundError, _sftp_no_such_file) if _sftp_no_such_file else (FileNotFoundError,)
88
+ )
89
+
90
+
91
+ def _interactive() -> bool:
92
+ """Whether the backend may prompt the user for credentials."""
93
+ if getenv("ASANYPATH_INTERACTIVE") == "0":
94
+ return False
95
+ try:
96
+ return sys.stdin.isatty()
97
+ except (AttributeError, ValueError): # pragma: no cover
98
+ return False # pragma: no cover
99
+
100
+
101
+ class _InteractiveSSHClient(asyncssh.SSHClient):
102
+ """SSHClient that prompts via :mod:`getpass` for missing credentials.
103
+
104
+ Used only when stdin is a TTY and no static password was supplied.
105
+ The prompted password is cached on the instance so a single login
106
+ attempt doesn't re-ask after a failed kbdint round.
107
+ """
108
+
109
+ def __init__(self, label: str) -> None:
110
+ self._label = label
111
+ self._cached_password: str | None = None
112
+
113
+ async def _prompt(self, msg: str, *, hidden: bool = True) -> str:
114
+ loop = asyncio.get_running_loop()
115
+ fn = getpass.getpass if hidden else input
116
+ return await loop.run_in_executor(None, fn, msg)
117
+
118
+ async def password_auth_requested(self) -> str | None:
119
+ if self._cached_password is None:
120
+ self._cached_password = await self._prompt(f"Password for {self._label}: ")
121
+ return self._cached_password
122
+
123
+ async def kbdint_auth_requested(self) -> str:
124
+ return ""
125
+
126
+ async def kbdint_challenge_received(
127
+ self, name: str, instructions: str, lang: str, prompts
128
+ ) -> list[str] | None:
129
+ if not prompts:
130
+ return []
131
+ if name:
132
+ sys.stderr.write(f"{name}\n")
133
+ if instructions:
134
+ sys.stderr.write(f"{instructions}\n")
135
+ responses: list[str] = []
136
+ for prompt, echo in prompts:
137
+ # Reuse cached password for password-like challenges so the user
138
+ # isn't prompted twice when the server tries password and then
139
+ # keyboard-interactive with a "Password:" prompt.
140
+ if not echo and self._cached_password and "password" in prompt.lower():
141
+ responses.append(self._cached_password)
142
+ else:
143
+ responses.append(await self._prompt(prompt, hidden=not echo))
144
+ return responses
145
+
146
+
147
+ def _discover_ssh_config() -> list[str]:
148
+ """Return the list of OpenSSH client config files to consult.
149
+
150
+ Honors ``$SSH_CONFIG`` as a single override (empty string disables
151
+ discovery entirely). Otherwise looks for ``~/.ssh/config`` and
152
+ ``/etc/ssh/ssh_config`` in OpenSSH order, returning paths that exist
153
+ or are likely to exist. asyncssh will handle missing files gracefully.
154
+ """
155
+ override = getenv("SSH_CONFIG")
156
+ if override is not None:
157
+ return [override] if override else []
158
+ # Always include both paths; asyncssh handles missing files
159
+ paths = [
160
+ str(Path.home() / ".ssh" / "config"),
161
+ "/etc/ssh/ssh_config",
162
+ ]
163
+ return paths
164
+
165
+
166
+ def _resolve_alias(
167
+ host: str | None,
168
+ port: int | None,
169
+ user: str | None,
170
+ ) -> tuple[str | None, int | None, str | None]:
171
+ """Expand ``(host, port, user)`` via OpenSSH config; return resolved values.
172
+
173
+ Falls back to the supplied values when no config is available or no
174
+ match is found. ``port`` / ``user`` come back as ``None`` when neither
175
+ the supplied value nor the config provides one, so the caller can
176
+ decide on an env-level default.
177
+ """
178
+ cfg_paths = _discover_ssh_config()
179
+ if not host or not cfg_paths:
180
+ return host, port, user
181
+ try:
182
+ local_user = getpass.getuser()
183
+ except Exception: # noqa: BLE001
184
+ local_user = ""
185
+ try:
186
+ cfg = SSHClientConfig.load(None, cfg_paths, False, False, False, local_user, (), host, ())
187
+ except Exception: # noqa: BLE001
188
+ return host, port, user
189
+ resolved_host = cfg.get("Hostname") or host
190
+ resolved_port_raw = port or cfg.get("Port")
191
+ resolved_port = int(resolved_port_raw) if resolved_port_raw is not None else None
192
+ resolved_user = user or cfg.get("User")
193
+ return resolved_host, resolved_port, resolved_user
194
+
195
+
196
+ class SSHPath(CloudPathMixin):
197
+ """Async SFTP path backed by :mod:`asyncssh`."""
198
+
199
+ protocol: str = "ssh"
200
+ _supports_range_read: bool = True
201
+
202
+ def __init__(
203
+ self,
204
+ *parts,
205
+ host: str | None = None,
206
+ port: int | None = None,
207
+ username: str | None = None,
208
+ password: str | None = None,
209
+ client_keys: list[str] | str | None = None,
210
+ known_hosts: str | tuple | None = _UNSET, # type: ignore[assignment]
211
+ ) -> None:
212
+ super().__init__(*parts)
213
+ cfg = self.env_config
214
+ # Distinguish "typed" values (kwarg/URL) from env defaults so
215
+ # ssh_config gets a chance to fill in port/user before we fall
216
+ # back to SSH_PORT/SSH_USER.
217
+ typed_host = host or self._path.host
218
+ typed_port = port or self._path.port
219
+ typed_user = username or self._path.user
220
+ self._typed_port = typed_port
221
+ self._typed_user = typed_user
222
+ self._host = typed_host or cfg.host
223
+ self._port = typed_port or cfg.port
224
+ self._user = typed_user or cfg.user
225
+ self._password = password if password is not None else cfg.password
226
+ keys = client_keys if client_keys is not None else cfg.client_keys
227
+ self._client_keys = [keys] if isinstance(keys, str) else keys
228
+ self._known_hosts = known_hosts if known_hosts is not _UNSET else cfg.known_hosts
229
+ # Eager alias resolution. str(self) keeps the original URL;
230
+ # .resolved_target surfaces what we connect to; the connection
231
+ # pool keys on the resolved tuple.
232
+ r_host, r_port, r_user = _resolve_alias(self._host, typed_port, typed_user)
233
+ self._resolved_host = r_host
234
+ self._resolved_port = r_port if r_port is not None else cfg.port
235
+ self._resolved_user = r_user if r_user is not None else cfg.user
236
+ # Promote explicit params into class cache so derived instances
237
+ # (parent, /, with_name, ...) inherit them — mirrors S3/GCS.
238
+ if host:
239
+ cfg.host = host
240
+ if port:
241
+ cfg.port = port
242
+ if username:
243
+ cfg.user = username
244
+ if password is not None:
245
+ cfg.password = password
246
+ if client_keys is not None:
247
+ cfg.client_keys = keys
248
+ if known_hosts is not _UNSET:
249
+ cfg.known_hosts = known_hosts
250
+
251
+ def __repr__(self) -> str:
252
+ typed = (self._host, self._port, self._user)
253
+ resolved = (self._resolved_host, self._resolved_port, self._resolved_user)
254
+ if typed == resolved:
255
+ return f"{type(self).__name__}({str(self)!r})"
256
+ return f"{type(self).__name__}({str(self)!r} → {self.resolved_target})"
257
+
258
+ @property
259
+ def resolved_target(self) -> str:
260
+ """Connection target after ssh_config alias expansion."""
261
+ user = f"{self._resolved_user}@" if self._resolved_user else ""
262
+ port = (
263
+ f":{self._resolved_port}" if self._resolved_port and self._resolved_port != 22 else ""
264
+ )
265
+ return f"ssh://{user}{self._resolved_host}{port}{self._item_path}"
266
+
267
+ @classmethod
268
+ def _create_env_config(cls) -> SimpleNamespace:
269
+ key_file = getenv("SSH_KEY_FILE")
270
+ known = getenv("SSH_KNOWN_HOSTS")
271
+ if known and known.lower() == "none":
272
+ known_hosts: object = () # asyncssh: empty tuple disables checking
273
+ elif known:
274
+ known_hosts = known
275
+ else:
276
+ known_hosts = None # asyncssh default (~/.ssh/known_hosts)
277
+ port = getenv("SSH_PORT")
278
+ config_paths = _discover_ssh_config()
279
+ return SimpleNamespace(
280
+ host=getenv("SSH_HOST"),
281
+ port=int(port) if port else 22,
282
+ user=getenv("SSH_USER") or getenv("USER"),
283
+ password=None,
284
+ client_keys=[key_file] if key_file else None,
285
+ known_hosts=known_hosts,
286
+ config_paths=config_paths,
287
+ )
288
+
289
+ # ------------------------------------------------------------------
290
+ # CloudPathMixin hooks
291
+ # ------------------------------------------------------------------
292
+
293
+ @property
294
+ def _item_path(self) -> str:
295
+ # ``/~`` / ``/~/foo`` encode "remote home" (scp-style) -- translate
296
+ # to SFTP cwd (= user's home after login).
297
+ p = self._path.path or "/"
298
+ if p == "/~":
299
+ return "."
300
+ if p.startswith("/~/"):
301
+ return "." + p[2:]
302
+ return p
303
+
304
+ @property
305
+ def _native_kwargs(self) -> dict:
306
+ return {
307
+ "host": self._resolved_host,
308
+ "port": self._resolved_port,
309
+ "user": self._resolved_user,
310
+ }
311
+
312
+ # ------------------------------------------------------------------
313
+ # Connection / SFTP pool
314
+ # ------------------------------------------------------------------
315
+
316
+ @property
317
+ def _conn_key(self) -> tuple:
318
+ # Pool by *resolved* target so two aliases pointing at the same
319
+ # machine share one connection.
320
+ return (self._resolved_host, self._resolved_port, self._resolved_user)
321
+
322
+ async def _get_conn(self) -> asyncssh.SSHClientConnection:
323
+ key = self._conn_key
324
+ lock = _CONN_LOCKS.setdefault(key, asyncio.Lock())
325
+ async with lock:
326
+ conn = _CONN_CACHE.get(key)
327
+ if conn is None or conn.is_closed():
328
+ conn = await self._connect_fresh()
329
+ _CONN_CACHE[key] = conn
330
+ return conn
331
+
332
+ def _credstore_key(self) -> str:
333
+ return (
334
+ f"ssh:{self._resolved_host or self._host or ''}"
335
+ f":{self._resolved_port or ''}"
336
+ f":{self._resolved_user or ''}"
337
+ )
338
+
339
+ async def _connect_fresh(self) -> asyncssh.SSHClientConnection:
340
+ # Pass the original (typed) host so asyncssh re-applies
341
+ # ssh_config (IdentityFile, ProxyJump, UserKnownHostsFile, …)
342
+ # using the alias as the Host pattern. Our resolved values are
343
+ # only used for pool keying / cache identity.
344
+ from asanypath import _credstore
345
+
346
+ base_kwargs: dict = {
347
+ "host": self._host,
348
+ "known_hosts": self._known_hosts,
349
+ "config": self.env_config.config_paths or (),
350
+ }
351
+ # Only pass typed URL/kwarg user/port. If they came from env defaults,
352
+ # let asyncssh apply ssh_config alias User/Port instead.
353
+ if self._typed_port is not None:
354
+ base_kwargs["port"] = self._typed_port
355
+ if self._typed_user is not None:
356
+ base_kwargs["username"] = self._typed_user
357
+ if self._client_keys is not None:
358
+ base_kwargs["client_keys"] = self._client_keys
359
+ if self._password is not None:
360
+ return await asyncssh.connect(password=self._password, **base_kwargs)
361
+
362
+ cred_key = self._credstore_key()
363
+ cached = _credstore.load(cred_key) if _interactive() else None
364
+ if cached is not None:
365
+ try:
366
+ return await asyncssh.connect(password=cached, **base_kwargs)
367
+ except asyncssh.PermissionDenied:
368
+ _credstore.forget(cred_key)
369
+ if not _interactive():
370
+ return await asyncssh.connect(**base_kwargs)
371
+
372
+ label = self._resolved_host or self._host or "ssh"
373
+ if self._resolved_user:
374
+ label = f"{self._resolved_user}@{label}"
375
+ captured: list[_InteractiveSSHClient] = []
376
+
377
+ def _factory() -> _InteractiveSSHClient:
378
+ inst = _InteractiveSSHClient(label)
379
+ captured.append(inst)
380
+ return inst
381
+
382
+ conn = await asyncssh.connect(client_factory=_factory, **base_kwargs)
383
+ if captured and captured[0]._cached_password:
384
+ _credstore.save(cred_key, captured[0]._cached_password)
385
+ return conn
386
+
387
+ async def _sftp(self) -> asyncssh.SFTPClient:
388
+ conn = await self._get_conn()
389
+ sftp = getattr(conn, "_asanypath_sftp", None)
390
+ # asyncssh.SFTPClient doesn't expose ``exit_status`` on all versions.
391
+ # Reuse cached client unless an explicit non-None ``exit_status``
392
+ # attribute exists (e.g. from test doubles / alternate implementations).
393
+ sftp_exit_status = getattr(sftp, "exit_status", None) if sftp is not None else None
394
+ if sftp is None or sftp_exit_status is not None:
395
+ sftp = await conn.start_sftp_client()
396
+ conn._asanypath_sftp = sftp # type: ignore[attr-defined]
397
+ return sftp
398
+
399
+ async def _run_remote(self, cmd: str, *, timeout: float | None = 15.0) -> tuple[str, int]:
400
+ """Run a shell command on the remote host via OpenSSH subprocess.
401
+
402
+ Uses the original typed host alias so that OpenSSH applies the full
403
+ ``~/.ssh/config`` — including ControlMaster, ProxyJump, and
404
+ IdentityFile — exactly as the user configured it. This means a
405
+ pre-existing ControlMaster socket makes this call near-instant.
406
+
407
+ Returns ``(stdout_text, returncode)``. Never raises.
408
+ """
409
+ import shutil
410
+
411
+ ssh_bin = shutil.which("ssh")
412
+ if ssh_bin is None:
413
+ return "", -1
414
+ args: list[str] = [ssh_bin, "-o", "BatchMode=yes"]
415
+ # Pass only URL/kwarg-typed user and port; let OpenSSH apply the
416
+ # Host block for everything else so ControlMaster matching works.
417
+ if self._typed_user is not None:
418
+ args += ["-l", self._typed_user]
419
+ if self._typed_port is not None:
420
+ args += ["-p", str(self._typed_port)]
421
+ args.append(self._host or self._resolved_host or "")
422
+ args.append(cmd)
423
+ proc = None
424
+ try:
425
+ proc = await asyncio.create_subprocess_exec(
426
+ *args,
427
+ stdout=asyncio.subprocess.PIPE,
428
+ stderr=asyncio.subprocess.PIPE,
429
+ )
430
+ coro = proc.communicate()
431
+ stdout_b, _ = (
432
+ await asyncio.wait_for(coro, timeout=timeout) if timeout is not None else await coro
433
+ )
434
+ return stdout_b.decode("utf-8", errors="replace"), proc.returncode or 0
435
+ except Exception:
436
+ if proc is not None:
437
+ try:
438
+ proc.kill()
439
+ except Exception: # noqa: BLE001
440
+ pass
441
+ return "", -1
442
+
443
+ async def _list_dir_remote(self) -> list[tuple[str, bool]] | None:
444
+ """List immediate children via OpenSSH subprocess (1 SSH call).
445
+
446
+ Returned as ``[(name, is_dir), ...]`` or ``None`` when the subprocess
447
+ approach is unavailable (no ``ssh`` binary) or the remote doesn't
448
+ support GNU find ``-printf``.
449
+ """
450
+ import shlex
451
+
452
+ path_q = shlex.quote(self._item_path)
453
+ cmd = f"find {path_q} -maxdepth 1 -mindepth 1 -printf '%y\\t%f\\n' 2>/dev/null"
454
+ output, rc = await self._run_remote(cmd, timeout=5.0)
455
+ if rc != 0 or "\t" not in output:
456
+ return None
457
+ result: list[tuple[str, bool]] = []
458
+ for line in output.splitlines():
459
+ if "\t" not in line:
460
+ continue
461
+ type_char, name = line.split("\t", 1)
462
+ if not name:
463
+ continue
464
+ result.append((name, type_char == "d"))
465
+ return result
466
+
467
+ # ------------------------------------------------------------------
468
+ # Stat / type checks
469
+ # ------------------------------------------------------------------
470
+
471
+ async def _attrs(self, follow_symlinks: bool = True):
472
+ # Reuse attrs cached by a parent ``iterdir`` to save a round-trip
473
+ # per child (e.g. when ``ls -l`` stats every entry).
474
+ cached = getattr(self, "_cached_attrs", None)
475
+ if cached is not None and follow_symlinks:
476
+ return cached
477
+ sftp = await self._sftp()
478
+ try:
479
+ if follow_symlinks:
480
+ return await sftp.stat(self._item_path)
481
+ return await sftp.lstat(self._item_path)
482
+ except _NOT_FOUND_ERRORS as e:
483
+ raise FileNotFoundError(2, "No such file or directory", str(self)) from e
484
+
485
+ async def exists(self) -> bool:
486
+ try:
487
+ await self._attrs()
488
+ return True
489
+ except FileNotFoundError:
490
+ return False
491
+
492
+ async def is_dir(self) -> bool:
493
+ try:
494
+ attrs = await self._attrs()
495
+ except FileNotFoundError:
496
+ return False
497
+ return stat_module.S_ISDIR(attrs.permissions or 0)
498
+
499
+ async def is_file(self) -> bool:
500
+ try:
501
+ attrs = await self._attrs()
502
+ except FileNotFoundError:
503
+ return False
504
+ return stat_module.S_ISREG(attrs.permissions or 0)
505
+
506
+ async def is_symlink(self) -> bool:
507
+ try:
508
+ attrs = await self._attrs(follow_symlinks=False)
509
+ except FileNotFoundError:
510
+ return False
511
+ return stat_module.S_ISLNK(attrs.permissions or 0)
512
+
513
+ async def stat(self, *, follow_symlinks: bool = True):
514
+ attrs = await self._attrs(follow_symlinks=follow_symlinks)
515
+ return SimpleNamespace(
516
+ st_size=attrs.size,
517
+ st_mtime=attrs.mtime,
518
+ st_atime=attrs.atime,
519
+ st_ctime=attrs.mtime,
520
+ st_mode=attrs.permissions,
521
+ st_uid=attrs.uid,
522
+ st_gid=attrs.gid,
523
+ )
524
+
525
+ async def get_access_policy(
526
+ self, *, backend_options: BackendOptions | None = None
527
+ ) -> AccessPolicy:
528
+ """Return POSIX permissions and numeric ownership from SFTP attributes."""
529
+ attrs = await self._attrs()
530
+ mode = attrs.permissions or 0
531
+
532
+ def actions(shift: int) -> frozenset[str]:
533
+ return frozenset(
534
+ action
535
+ for action, bit in (("read", 4), ("write", 2), ("execute", 1))
536
+ if mode >> shift & bit
537
+ )
538
+
539
+ return AccessPolicy(
540
+ owner=str(attrs.uid) if attrs.uid is not None else None,
541
+ group=str(attrs.gid) if attrs.gid is not None else None,
542
+ grants=(
543
+ AccessGrant("owner", actions(6)),
544
+ AccessGrant("group", actions(3)),
545
+ AccessGrant("everyone", actions(0)),
546
+ ),
547
+ )
548
+
549
+ async def update_access_policy(
550
+ self, policy_patch: AccessPolicyPatch, *, backend_options: BackendOptions | None = None
551
+ ) -> None:
552
+ """Apply POSIX ownership and mode grants through SFTP."""
553
+ sftp = await self._sftp()
554
+ if policy_patch.owner is not None or policy_patch.group is not None:
555
+ try:
556
+ uid = -1 if policy_patch.owner is None else int(policy_patch.owner)
557
+ gid = -1 if policy_patch.group is None else int(policy_patch.group)
558
+ except ValueError as error:
559
+ raise ValueError("SSH ownership must use numeric uid and gid strings") from error
560
+ await sftp.chown(self._item_path, uid, gid)
561
+ if policy_patch.grants:
562
+ attrs = await self._attrs()
563
+ mode = attrs.permissions or 0
564
+ shifts = {"owner": 6, "group": 3, "everyone": 0}
565
+ for grant in policy_patch.grants:
566
+ if grant.principal not in shifts:
567
+ raise ValueError(f"unsupported SSH policy principal: {grant.principal}")
568
+ bits = sum(
569
+ {"read": 4, "write": 2, "execute": 1}[action] for action in grant.actions
570
+ )
571
+ shift = shifts[grant.principal]
572
+ mode = mode & ~(0o7 << shift) | (bits << shift)
573
+ await sftp.chmod(self._item_path, mode)
574
+
575
+ async def checksums(self) -> dict[str, str]:
576
+ from hashlib import md5, sha1, sha256
577
+
578
+ data = await self.read_bytes()
579
+ return {
580
+ "md5": md5(data).hexdigest(),
581
+ "sha1": sha1(data).hexdigest(),
582
+ "sha256": sha256(data).hexdigest(),
583
+ }
584
+
585
+ # ------------------------------------------------------------------
586
+ # Read / write
587
+ # ------------------------------------------------------------------
588
+
589
+ async def read_bytes(self) -> bytes:
590
+ sftp = await self._sftp()
591
+ async with sftp.open(self._item_path, "rb") as f:
592
+ return await f.read()
593
+
594
+ async def write_bytes(self, data: bytes) -> int:
595
+ sftp = await self._sftp()
596
+ async with sftp.open(self._item_path, "wb") as f:
597
+ await f.write(data)
598
+ return len(data)
599
+
600
+ async def _range_read(self, start: int, end: int) -> bytes:
601
+ sftp = await self._sftp()
602
+ async with sftp.open(self._item_path, "rb") as f:
603
+ await f.seek(start)
604
+ return await f.read(end - start + 1)
605
+
606
+ # ------------------------------------------------------------------
607
+ # Directory / unlink / rename
608
+ # ------------------------------------------------------------------
609
+
610
+ async def unlink(self, missing_ok: bool = False) -> None:
611
+ sftp = await self._sftp()
612
+ try:
613
+ await sftp.remove(self._item_path)
614
+ except _NOT_FOUND_ERRORS:
615
+ if not missing_ok:
616
+ raise FileNotFoundError(2, "No such file or directory", str(self)) from None
617
+
618
+ async def mkdir(self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False) -> None:
619
+ sftp = await self._sftp()
620
+ try:
621
+ if parents:
622
+ await sftp.makedirs(self._item_path, exist_ok=exist_ok)
623
+ else:
624
+ await sftp.mkdir(self._item_path)
625
+ except FileExistsError:
626
+ if not exist_ok:
627
+ raise
628
+
629
+ async def rmdir(self, *, recursive: bool = False) -> None:
630
+ sftp = await self._sftp()
631
+ if recursive:
632
+ await sftp.rmtree(self._item_path)
633
+ return
634
+ await sftp.rmdir(self._item_path)
635
+
636
+ async def rename(self, target: str | Self, *, force: bool = False) -> Self:
637
+ target_path = target if isinstance(target, type(self)) else type(self)(str(target))
638
+ from asanypath._transfer import async_destination_state, require_force
639
+
640
+ needs_copy, same_path, destination_exists = await async_destination_state(self, target_path)
641
+ if not needs_copy:
642
+ if same_path:
643
+ return target_path
644
+ await self.unlink()
645
+ return target_path
646
+ if destination_exists:
647
+ if not force:
648
+ require_force(force, target_path)
649
+ return await super().rename(str(target), force=True)
650
+ if (target_path._host, target_path._port) == (self._host, self._port):
651
+ sftp = await self._sftp()
652
+ await sftp.rename(self._item_path, target_path._item_path)
653
+ return target_path
654
+ return await super().rename(str(target))
655
+
656
+ async def replace(self, target: str | Self) -> Self:
657
+ target_path = target if isinstance(target, type(self)) else type(self)(str(target))
658
+ if (target_path._host, target_path._port) == (self._host, self._port):
659
+ sftp = await self._sftp()
660
+ # posix_rename atomically replaces dest if it exists.
661
+ await sftp.posix_rename(self._item_path, target_path._item_path)
662
+ return target_path
663
+ return await super().replace(str(target))
664
+
665
+ async def touch(self, mode: int = 0o666, exist_ok: bool = True) -> None:
666
+ if await self.exists():
667
+ if not exist_ok:
668
+ raise FileExistsError(17, f"File exists: '{self}'")
669
+ sftp = await self._sftp()
670
+ now = time()
671
+ await sftp.utime(self._item_path, (now, now))
672
+ return
673
+ await self.write_bytes(b"")
674
+
675
+ async def iterdir(self, *, fresh: bool = False) -> AsyncIterator[Self]:
676
+ sftp = await self._sftp()
677
+ try:
678
+ names = await sftp.readdir(self._item_path)
679
+ except (FileNotFoundError, NotADirectoryError) as e:
680
+ raise NotADirectoryError(20, f"Not a directory: '{self}'") from e
681
+ for entry in names:
682
+ if entry.filename in (".", ".."):
683
+ continue
684
+ child = self / entry.filename
685
+ # ``readdir`` already returned attrs for every entry; cache
686
+ # them so ``stat()``/``is_dir()`` don't issue another LSTAT.
687
+ child._cached_attrs = entry.attrs # type: ignore[attr-defined]
688
+ yield child
689
+
690
+ async def walk(
691
+ self,
692
+ top_down: bool = True,
693
+ on_error=None,
694
+ follow_symlinks: bool = False,
695
+ ) -> AsyncIterator[tuple[Self, list[str], list[str]]]:
696
+ """Walk directory tree.
697
+
698
+ Tries a single remote ``find`` call first (benefits from OpenSSH
699
+ ControlMaster — effectively free when a master socket exists).
700
+ Falls back to sequential SFTP ``readdir`` when ``ssh`` is absent or
701
+ the remote find doesn't support GNU ``-printf``.
702
+ """
703
+ import shlex
704
+ from collections import defaultdict
705
+
706
+ path_q = shlex.quote(self._item_path)
707
+ follow_flag = "-L " if follow_symlinks else ""
708
+ find_cmd = f"find {follow_flag}{path_q} -mindepth 1 -printf '%y\\t%P\\n' 2>/dev/null"
709
+ output, rc = await self._run_remote(find_cmd, timeout=30.0)
710
+
711
+ if rc == 0 and "\t" in output:
712
+ # Fast path: reconstruct the tree from a flat find listing.
713
+ children_map: dict[str, list[tuple[str, bool]]] = defaultdict(list)
714
+ for line in output.splitlines():
715
+ if "\t" not in line:
716
+ continue
717
+ type_char, rel_path = line.split("\t", 1)
718
+ if not rel_path:
719
+ continue
720
+ parts = rel_path.split("/")
721
+ parent_rel = "/".join(parts[:-1])
722
+ name = parts[-1]
723
+ children_map[parent_rel].append((name, type_char == "d"))
724
+
725
+ async def _emit(rel_parent: str, base: SSHPath): # type: ignore[name-defined]
726
+ entry_list = children_map.get(rel_parent, [])
727
+ dirs = [n for n, is_d in entry_list if is_d]
728
+ files = [n for n, is_d in entry_list if not is_d]
729
+ if top_down:
730
+ yield base, dirs, files
731
+ for n, is_d in entry_list:
732
+ if is_d:
733
+ child_rel = f"{rel_parent}/{n}" if rel_parent else n
734
+ async for triple in _emit(child_rel, base / n):
735
+ yield triple
736
+ if not top_down:
737
+ yield base, dirs, files
738
+
739
+ async for triple in _emit("", self):
740
+ yield triple
741
+ return
742
+
743
+ # SFTP fallback: one readdir round trip per directory.
744
+ sftp = await self._sftp()
745
+ try:
746
+ entries = await sftp.readdir(self._item_path)
747
+ except (FileNotFoundError, NotADirectoryError) as e:
748
+ if on_error is not None:
749
+ on_error(e)
750
+ return
751
+ raise
752
+ dirs: list[Self] = []
753
+ files: list[Self] = []
754
+ for entry in entries:
755
+ if entry.filename in (".", ".."):
756
+ continue
757
+ child = self / entry.filename
758
+ if stat_module.S_ISDIR(entry.attrs.permissions or 0):
759
+ dirs.append(child)
760
+ else:
761
+ files.append(child)
762
+ if top_down:
763
+ yield self, [d.name for d in dirs], [f.name for f in files]
764
+ for d in dirs:
765
+ async for triple in d.walk(
766
+ top_down=top_down, on_error=on_error, follow_symlinks=follow_symlinks
767
+ ):
768
+ yield triple
769
+ if not top_down:
770
+ yield self, [d.name for d in dirs], [f.name for f in files]
771
+
772
+ def open(self, mode="r", buffering=-1, encoding=None, errors=None, newline=None):
773
+ raise NotImplementedError(
774
+ f"{type(self).__name__}.open() not implemented; "
775
+ "use read_bytes/write_bytes/read_text/write_text."
776
+ )