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