megfile 4.2.3__py3-none-any.whl → 4.2.4__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.
- megfile/__init__.py +5 -0
- megfile/cli.py +5 -9
- megfile/config.py +5 -0
- megfile/fs_path.py +2 -10
- megfile/lib/base_prefetch_reader.py +18 -7
- megfile/lib/s3_prefetch_reader.py +2 -1
- megfile/lib/s3_share_cache_reader.py +15 -10
- megfile/s3_path.py +8 -4
- megfile/sftp2.py +827 -0
- megfile/sftp2_path.py +1084 -0
- megfile/sftp_path.py +3 -15
- megfile/smart.py +5 -17
- megfile/utils/__init__.py +92 -9
- megfile/version.py +1 -1
- {megfile-4.2.3.dist-info → megfile-4.2.4.dist-info}/METADATA +3 -1
- {megfile-4.2.3.dist-info → megfile-4.2.4.dist-info}/RECORD +21 -19
- {megfile-4.2.3.dist-info → megfile-4.2.4.dist-info}/WHEEL +0 -0
- {megfile-4.2.3.dist-info → megfile-4.2.4.dist-info}/entry_points.txt +0 -0
- {megfile-4.2.3.dist-info → megfile-4.2.4.dist-info}/licenses/LICENSE +0 -0
- {megfile-4.2.3.dist-info → megfile-4.2.4.dist-info}/licenses/LICENSE.pyre +0 -0
- {megfile-4.2.3.dist-info → megfile-4.2.4.dist-info}/top_level.txt +0 -0
megfile/sftp2_path.py
ADDED
|
@@ -0,0 +1,1084 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import hashlib
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
from functools import cached_property
|
|
9
|
+
from logging import getLogger as get_logger
|
|
10
|
+
from stat import S_ISDIR, S_ISLNK, S_ISREG
|
|
11
|
+
from typing import IO, BinaryIO, Callable, Iterator, List, Optional, Tuple, Union
|
|
12
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
13
|
+
|
|
14
|
+
import ssh2.session # type: ignore
|
|
15
|
+
import ssh2.sftp # type: ignore
|
|
16
|
+
from ssh2.exceptions import SFTPProtocolError # type: ignore
|
|
17
|
+
|
|
18
|
+
from megfile.config import SFTP_MAX_RETRY_TIMES
|
|
19
|
+
from megfile.errors import SameFileError, _create_missing_ok_generator
|
|
20
|
+
from megfile.interfaces import ContextIterator, FileEntry, PathLike, StatResult
|
|
21
|
+
from megfile.lib.compare import is_same_file
|
|
22
|
+
from megfile.lib.compat import fspath
|
|
23
|
+
from megfile.lib.glob import FSFunc, iglob
|
|
24
|
+
from megfile.pathlike import URIPath
|
|
25
|
+
from megfile.smart_path import SmartPath
|
|
26
|
+
from megfile.utils import calculate_md5, copyfileobj, thread_local
|
|
27
|
+
|
|
28
|
+
_logger = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Sftp2Path",
|
|
32
|
+
"is_sftp2",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
SFTP2_USERNAME = "SFTP2_USERNAME"
|
|
36
|
+
SFTP2_PASSWORD = "SFTP2_PASSWORD"
|
|
37
|
+
SFTP2_PRIVATE_KEY_PATH = "SFTP2_PRIVATE_KEY_PATH"
|
|
38
|
+
SFTP2_PRIVATE_KEY_TYPE = "SFTP2_PRIVATE_KEY_TYPE"
|
|
39
|
+
SFTP2_PRIVATE_KEY_PASSWORD = "SFTP2_PRIVATE_KEY_PASSWORD"
|
|
40
|
+
SFTP2_MAX_UNAUTH_CONN = "SFTP2_MAX_UNAUTH_CONN"
|
|
41
|
+
MAX_RETRIES = SFTP_MAX_RETRY_TIMES
|
|
42
|
+
DEFAULT_SSH_CONNECT_TIMEOUT = 5
|
|
43
|
+
DEFAULT_SSH_KEEPALIVE_INTERVAL = 15
|
|
44
|
+
|
|
45
|
+
# SFTP2-specific buffer sizes and chunk sizes
|
|
46
|
+
SFTP2_BUFFER_SIZE = 1 * 2**20 # 1MB buffer for file operations
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _make_stat(stat) -> StatResult:
|
|
50
|
+
"""Convert ssh2.sftp stats to StatResult"""
|
|
51
|
+
# ssh2-python uses different attribute names than paramiko
|
|
52
|
+
size = getattr(stat, "filesize", 0) if stat else 0
|
|
53
|
+
mtime = getattr(stat, "mtime", 0.0) if stat else 0.0
|
|
54
|
+
# ssh2-python uses 'permissions' instead of 'st_mode'
|
|
55
|
+
mode = getattr(stat, "permissions", 0) if stat else 0
|
|
56
|
+
|
|
57
|
+
return StatResult(
|
|
58
|
+
size=size,
|
|
59
|
+
mtime=mtime,
|
|
60
|
+
isdir=S_ISDIR(mode),
|
|
61
|
+
islnk=S_ISLNK(mode),
|
|
62
|
+
extra=stat,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_private_key():
|
|
67
|
+
"""Get private key for SSH authentication"""
|
|
68
|
+
private_key_path = os.getenv(SFTP2_PRIVATE_KEY_PATH)
|
|
69
|
+
if private_key_path:
|
|
70
|
+
if not os.path.exists(private_key_path):
|
|
71
|
+
raise FileNotFoundError(f"Private key file not exist: '{private_key_path}'")
|
|
72
|
+
private_key_password = os.getenv(SFTP2_PRIVATE_KEY_PASSWORD)
|
|
73
|
+
if private_key_password:
|
|
74
|
+
return private_key_path, private_key_password
|
|
75
|
+
return private_key_path, ""
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def provide_connect_info(
|
|
80
|
+
hostname: str,
|
|
81
|
+
port: Optional[int] = None,
|
|
82
|
+
username: Optional[str] = None,
|
|
83
|
+
password: Optional[str] = None,
|
|
84
|
+
):
|
|
85
|
+
"""Provide connection information"""
|
|
86
|
+
if not port:
|
|
87
|
+
port = 22
|
|
88
|
+
if not username:
|
|
89
|
+
username = os.getenv(SFTP2_USERNAME)
|
|
90
|
+
if not username:
|
|
91
|
+
# 如果没有指定用户名,使用当前系统用户名
|
|
92
|
+
username = getpass.getuser()
|
|
93
|
+
if not password:
|
|
94
|
+
password = os.getenv(SFTP2_PASSWORD)
|
|
95
|
+
private_key = get_private_key()
|
|
96
|
+
return hostname, port, username, password, private_key
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sftp2_should_retry(error: Exception) -> bool:
|
|
100
|
+
"""Determine if an error should trigger a retry"""
|
|
101
|
+
if isinstance(error, (ConnectionError, socket.timeout)):
|
|
102
|
+
return True
|
|
103
|
+
elif isinstance(error, OSError):
|
|
104
|
+
for err_msg in ["Socket is closed", "Cannot assign requested address"]:
|
|
105
|
+
if err_msg in str(error):
|
|
106
|
+
return True
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _get_ssh2_session(
|
|
111
|
+
hostname: str,
|
|
112
|
+
port: Optional[int] = None,
|
|
113
|
+
username: Optional[str] = None,
|
|
114
|
+
password: Optional[str] = None,
|
|
115
|
+
) -> ssh2.session.Session:
|
|
116
|
+
"""Create SSH2 session"""
|
|
117
|
+
hostname, port, username, password, private_key = provide_connect_info(
|
|
118
|
+
hostname=hostname, port=port, username=username, password=password
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
122
|
+
sock.settimeout(DEFAULT_SSH_CONNECT_TIMEOUT)
|
|
123
|
+
sock.connect((hostname, port))
|
|
124
|
+
|
|
125
|
+
session = ssh2.session.Session()
|
|
126
|
+
session.handshake(sock)
|
|
127
|
+
|
|
128
|
+
# 尝试多种认证方法
|
|
129
|
+
authenticated = False
|
|
130
|
+
|
|
131
|
+
# 1. 如果提供了私钥,优先使用私钥认证
|
|
132
|
+
if private_key and username:
|
|
133
|
+
try:
|
|
134
|
+
# For ssh2-python, we need to handle key authentication differently
|
|
135
|
+
key_path, passphrase = private_key
|
|
136
|
+
result = session.userauth_publickey_fromfile(
|
|
137
|
+
username,
|
|
138
|
+
key_path,
|
|
139
|
+
passphrase=passphrase,
|
|
140
|
+
)
|
|
141
|
+
if result == 0: # 0 indicates success in ssh2-python
|
|
142
|
+
authenticated = True
|
|
143
|
+
_logger.debug(f"Authentication successed with key: {key_path}")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
_logger.debug(f"Private key authentication failed: {type(e).__name__}: {e}")
|
|
146
|
+
|
|
147
|
+
# 2. 如果提供了密码,尝试密码认证
|
|
148
|
+
if not authenticated and password and username:
|
|
149
|
+
try:
|
|
150
|
+
result = session.userauth_password(username, password)
|
|
151
|
+
if result == 0:
|
|
152
|
+
authenticated = True
|
|
153
|
+
_logger.debug("Authentication successed with password")
|
|
154
|
+
except Exception as e:
|
|
155
|
+
_logger.debug(f"Password authentication failed: {type(e).__name__}: {e}")
|
|
156
|
+
|
|
157
|
+
# 3. 尝试使用 SSH agent 认证
|
|
158
|
+
if not authenticated and username:
|
|
159
|
+
try:
|
|
160
|
+
# ssh2-python 使用 agent_init() 和 agent_auth() 方法
|
|
161
|
+
session.agent_init()
|
|
162
|
+
session.agent_auth(username)
|
|
163
|
+
authenticated = True
|
|
164
|
+
_logger.debug("Successfully authenticated with SSH agent")
|
|
165
|
+
except Exception as e:
|
|
166
|
+
_logger.debug(f"SSH agent authentication failed: {type(e).__name__}: {e}")
|
|
167
|
+
|
|
168
|
+
# 4. 尝试使用默认的公钥认证 (~/.ssh/id_rsa, ~/.ssh/id_dsa 等)
|
|
169
|
+
if not authenticated and username:
|
|
170
|
+
default_key_paths = [
|
|
171
|
+
os.path.expanduser("~/.ssh/id_rsa"),
|
|
172
|
+
os.path.expanduser("~/.ssh/id_dsa"),
|
|
173
|
+
os.path.expanduser("~/.ssh/id_ecdsa"),
|
|
174
|
+
os.path.expanduser("~/.ssh/id_ed25519"),
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
for key_path in default_key_paths:
|
|
178
|
+
if os.path.exists(key_path):
|
|
179
|
+
try:
|
|
180
|
+
result = session.userauth_publickey_fromfile(
|
|
181
|
+
username,
|
|
182
|
+
key_path, # 私钥文件路径
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if result == 0:
|
|
186
|
+
authenticated = True
|
|
187
|
+
_logger.debug(
|
|
188
|
+
f"Successfully authenticated with key: {key_path}"
|
|
189
|
+
)
|
|
190
|
+
break
|
|
191
|
+
except Exception as e:
|
|
192
|
+
_logger.debug(
|
|
193
|
+
f"Public key authentication with {key_path} failed: {e}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if not authenticated:
|
|
197
|
+
sock.close()
|
|
198
|
+
raise ValueError(
|
|
199
|
+
f"Authentication failed for {username}@{hostname}. "
|
|
200
|
+
"Please check your SSH configuration, SSH agent, or provide "
|
|
201
|
+
"explicit credentials."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return session
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def get_ssh2_session(
|
|
208
|
+
hostname: str,
|
|
209
|
+
port: Optional[int] = None,
|
|
210
|
+
username: Optional[str] = None,
|
|
211
|
+
password: Optional[str] = None,
|
|
212
|
+
) -> ssh2.session.Session:
|
|
213
|
+
"""Get cached SSH2 session"""
|
|
214
|
+
return thread_local(
|
|
215
|
+
f"ssh2_session:{hostname},{port},{username},{password}",
|
|
216
|
+
_get_ssh2_session,
|
|
217
|
+
hostname,
|
|
218
|
+
port,
|
|
219
|
+
username,
|
|
220
|
+
password,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _get_sftp2_client(
|
|
225
|
+
hostname: str,
|
|
226
|
+
port: Optional[int] = None,
|
|
227
|
+
username: Optional[str] = None,
|
|
228
|
+
password: Optional[str] = None,
|
|
229
|
+
) -> ssh2.sftp.SFTP:
|
|
230
|
+
"""Get SFTP2 client"""
|
|
231
|
+
session = get_ssh2_session(hostname, port, username, password)
|
|
232
|
+
sftp = session.sftp_init()
|
|
233
|
+
return sftp
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_sftp2_client(
|
|
237
|
+
hostname: str,
|
|
238
|
+
port: Optional[int] = None,
|
|
239
|
+
username: Optional[str] = None,
|
|
240
|
+
password: Optional[str] = None,
|
|
241
|
+
) -> ssh2.sftp.SFTP:
|
|
242
|
+
"""Get cached SFTP2 client"""
|
|
243
|
+
return thread_local(
|
|
244
|
+
f"sftp2_client:{hostname},{port},{username},{password}",
|
|
245
|
+
_get_sftp2_client,
|
|
246
|
+
hostname,
|
|
247
|
+
port,
|
|
248
|
+
username,
|
|
249
|
+
password,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def is_sftp2(path: PathLike) -> bool:
|
|
254
|
+
"""Test if a path is sftp2 path
|
|
255
|
+
|
|
256
|
+
:param path: Path to be tested
|
|
257
|
+
:returns: True of a path is sftp2 path, else False
|
|
258
|
+
"""
|
|
259
|
+
path = fspath(path)
|
|
260
|
+
parts = urlsplit(path)
|
|
261
|
+
return parts.scheme == "sftp2"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _sftp2_scan_pairs(
|
|
265
|
+
src_url: PathLike, dst_url: PathLike
|
|
266
|
+
) -> Iterator[Tuple[PathLike, PathLike]]:
|
|
267
|
+
for src_file_path in Sftp2Path(src_url).scan():
|
|
268
|
+
content_path = src_file_path[len(fspath(src_url)) :]
|
|
269
|
+
if len(content_path) > 0:
|
|
270
|
+
dst_file_path = Sftp2Path(dst_url).joinpath(content_path).path_with_protocol
|
|
271
|
+
else:
|
|
272
|
+
dst_file_path = dst_url
|
|
273
|
+
yield src_file_path, dst_file_path
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class Sftp2RawFile(io.RawIOBase):
|
|
277
|
+
"""Raw SFTP file wrapper - implements only readinto for BufferedReader"""
|
|
278
|
+
|
|
279
|
+
def __init__(self, sftp_handle, path: str, mode: str = "r"):
|
|
280
|
+
self.sftp_handle = sftp_handle
|
|
281
|
+
self.path = path
|
|
282
|
+
self.mode = mode
|
|
283
|
+
self.name = path
|
|
284
|
+
self._closed = False
|
|
285
|
+
|
|
286
|
+
def readable(self) -> bool:
|
|
287
|
+
return "r" in self.mode
|
|
288
|
+
|
|
289
|
+
def writable(self) -> bool:
|
|
290
|
+
return "w" in self.mode or "a" in self.mode or "x" in self.mode
|
|
291
|
+
|
|
292
|
+
def seekable(self) -> bool:
|
|
293
|
+
return True
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def closed(self) -> bool:
|
|
297
|
+
return self._closed
|
|
298
|
+
|
|
299
|
+
def readinto(self, buffer) -> int:
|
|
300
|
+
"""Read into a pre-allocated buffer. Required by BufferedReader."""
|
|
301
|
+
if self._closed:
|
|
302
|
+
raise ValueError("I/O operation on closed file")
|
|
303
|
+
|
|
304
|
+
# ssh2-python returns (bytes_read, data)
|
|
305
|
+
bytes_read, chunk = self.sftp_handle.read(len(buffer))
|
|
306
|
+
if bytes_read > 0:
|
|
307
|
+
# Direct memory copy should be faster
|
|
308
|
+
buffer[:bytes_read] = chunk
|
|
309
|
+
return bytes_read
|
|
310
|
+
return 0
|
|
311
|
+
|
|
312
|
+
def read(self, size: int = -1) -> bytes:
|
|
313
|
+
"""Fallback read method - optimized for direct use"""
|
|
314
|
+
if self._closed:
|
|
315
|
+
raise ValueError("I/O operation on closed file")
|
|
316
|
+
|
|
317
|
+
if size <= 0:
|
|
318
|
+
# For read-all, use readinto with BytesIO for consistency
|
|
319
|
+
result = io.BytesIO()
|
|
320
|
+
buffer = bytearray(SFTP2_BUFFER_SIZE)
|
|
321
|
+
while True:
|
|
322
|
+
n = self.readinto(buffer)
|
|
323
|
+
if n == 0:
|
|
324
|
+
break
|
|
325
|
+
result.write(buffer[:n])
|
|
326
|
+
return result.getvalue()
|
|
327
|
+
else:
|
|
328
|
+
# For fixed size reads, use readinto
|
|
329
|
+
buffer = bytearray(size)
|
|
330
|
+
n = self.readinto(buffer)
|
|
331
|
+
return bytes(buffer[:n])
|
|
332
|
+
|
|
333
|
+
def write(self, data: bytes) -> int:
|
|
334
|
+
if self._closed:
|
|
335
|
+
raise ValueError("I/O operation on closed file")
|
|
336
|
+
_, bytes_written = self.sftp_handle.write(bytes(data))
|
|
337
|
+
return bytes_written
|
|
338
|
+
|
|
339
|
+
def close(self):
|
|
340
|
+
if not self._closed:
|
|
341
|
+
self.sftp_handle.close()
|
|
342
|
+
self._closed = True
|
|
343
|
+
|
|
344
|
+
def flush(self):
|
|
345
|
+
"""Flush the file. This is a no-op for SFTP files."""
|
|
346
|
+
pass
|
|
347
|
+
|
|
348
|
+
def tell(self) -> int:
|
|
349
|
+
"""Return current position. Uses SFTP handle tell methods."""
|
|
350
|
+
if self._closed:
|
|
351
|
+
raise ValueError("I/O operation on closed file")
|
|
352
|
+
|
|
353
|
+
# Use SFTP handle's tell method
|
|
354
|
+
if hasattr(self.sftp_handle, "tell64"):
|
|
355
|
+
return self.sftp_handle.tell64()
|
|
356
|
+
else:
|
|
357
|
+
# If SFTP tell is not available or fails, raise error
|
|
358
|
+
raise OSError("tell not supported for this SFTP implementation")
|
|
359
|
+
|
|
360
|
+
def seek(self, offset: int, whence: int = 0) -> int:
|
|
361
|
+
"""Seek to position. Uses SFTP handle seek methods."""
|
|
362
|
+
if self._closed:
|
|
363
|
+
raise ValueError("I/O operation on closed file")
|
|
364
|
+
|
|
365
|
+
# Try to use SFTP handle's native seek functionality
|
|
366
|
+
if hasattr(self.sftp_handle, "seek64"):
|
|
367
|
+
# Calculate absolute position based on whence
|
|
368
|
+
if whence == 0: # SEEK_SET
|
|
369
|
+
target_pos = offset
|
|
370
|
+
elif whence == 1: # SEEK_CUR
|
|
371
|
+
current_pos = self.tell()
|
|
372
|
+
target_pos = current_pos + offset
|
|
373
|
+
elif whence == 2: # SEEK_END
|
|
374
|
+
# For SEEK_END, we need file size - not commonly supported
|
|
375
|
+
raise OSError("SEEK_END not supported for SFTP files")
|
|
376
|
+
else:
|
|
377
|
+
raise OSError(f"invalid whence ({whence}, should be 0, 1, or 2)")
|
|
378
|
+
|
|
379
|
+
if target_pos < 0:
|
|
380
|
+
raise OSError("negative seek position")
|
|
381
|
+
|
|
382
|
+
# Perform the seek
|
|
383
|
+
self.sftp_handle.seek64(target_pos)
|
|
384
|
+
return target_pos
|
|
385
|
+
else:
|
|
386
|
+
# Fallback: SFTP doesn't support seek
|
|
387
|
+
raise OSError("seek not supported for this SFTP implementation")
|
|
388
|
+
|
|
389
|
+
def fileno(self) -> int:
|
|
390
|
+
"""Return file descriptor. Not supported for SFTP."""
|
|
391
|
+
# Return -1 to indicate no file descriptor (standard practice)
|
|
392
|
+
return -1
|
|
393
|
+
|
|
394
|
+
def isatty(self) -> bool:
|
|
395
|
+
"""Return whether this is a tty. Always False for SFTP files."""
|
|
396
|
+
return False
|
|
397
|
+
|
|
398
|
+
def truncate(self, size: Optional[int] = None) -> int:
|
|
399
|
+
"""Truncate file. Not supported for SFTP."""
|
|
400
|
+
raise OSError("truncate not supported for SFTP files")
|
|
401
|
+
|
|
402
|
+
def __enter__(self):
|
|
403
|
+
return self
|
|
404
|
+
|
|
405
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
406
|
+
self.close()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@SmartPath.register
|
|
410
|
+
class Sftp2Path(URIPath):
|
|
411
|
+
"""sftp2 protocol
|
|
412
|
+
|
|
413
|
+
uri format:
|
|
414
|
+
- absolute path
|
|
415
|
+
- sftp2://[username[:password]@]hostname[:port]//file_path
|
|
416
|
+
- relative path
|
|
417
|
+
- sftp2://[username[:password]@]hostname[:port]/file_path
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
protocol = "sftp2"
|
|
421
|
+
|
|
422
|
+
def __init__(self, path: "PathLike", *other_paths: "PathLike"):
|
|
423
|
+
super().__init__(path, *other_paths)
|
|
424
|
+
parts = urlsplit(self.path)
|
|
425
|
+
self._urlsplit_parts = parts
|
|
426
|
+
self._real_path = parts.path
|
|
427
|
+
if parts.path.startswith("//"):
|
|
428
|
+
self._root_dir = "/"
|
|
429
|
+
else:
|
|
430
|
+
self._root_dir = "/" # Default to absolute path for ssh2
|
|
431
|
+
self._real_path = (
|
|
432
|
+
parts.path.lstrip("/")
|
|
433
|
+
if not parts.path.startswith("//")
|
|
434
|
+
else parts.path[2:]
|
|
435
|
+
)
|
|
436
|
+
if not self._real_path.startswith("/"):
|
|
437
|
+
self._real_path = f"/{self._real_path}"
|
|
438
|
+
|
|
439
|
+
@cached_property
|
|
440
|
+
def parts(self) -> Tuple[str, ...]:
|
|
441
|
+
"""A tuple giving access to the path's various components"""
|
|
442
|
+
if self._urlsplit_parts.path.startswith("//"):
|
|
443
|
+
new_parts = self._urlsplit_parts._replace(path="//")
|
|
444
|
+
else:
|
|
445
|
+
new_parts = self._urlsplit_parts._replace(path="/")
|
|
446
|
+
parts = [urlunsplit(new_parts)]
|
|
447
|
+
path = self._urlsplit_parts.path.lstrip("/")
|
|
448
|
+
if path != "":
|
|
449
|
+
parts.extend(path.split("/"))
|
|
450
|
+
return tuple(parts)
|
|
451
|
+
|
|
452
|
+
@property
|
|
453
|
+
def _client(self):
|
|
454
|
+
return get_sftp2_client(
|
|
455
|
+
hostname=self._urlsplit_parts.hostname,
|
|
456
|
+
port=self._urlsplit_parts.port,
|
|
457
|
+
username=self._urlsplit_parts.username,
|
|
458
|
+
password=self._urlsplit_parts.password,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
@property
|
|
462
|
+
def _session(self):
|
|
463
|
+
"""Get SSH session for executing server-side commands"""
|
|
464
|
+
return get_ssh2_session(
|
|
465
|
+
hostname=self._urlsplit_parts.hostname,
|
|
466
|
+
port=self._urlsplit_parts.port,
|
|
467
|
+
username=self._urlsplit_parts.username,
|
|
468
|
+
password=self._urlsplit_parts.password,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def _exec_command(self, command: List[str]) -> subprocess.CompletedProcess:
|
|
472
|
+
"""Execute a command on the remote server via SSH
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
subprocess.CompletedProcess object
|
|
476
|
+
"""
|
|
477
|
+
session = self._session
|
|
478
|
+
channel = session.open_session()
|
|
479
|
+
|
|
480
|
+
# Execute the command
|
|
481
|
+
channel.execute(shlex.join(command))
|
|
482
|
+
|
|
483
|
+
# Read output
|
|
484
|
+
stdout = io.BytesIO()
|
|
485
|
+
stderr = io.BytesIO()
|
|
486
|
+
|
|
487
|
+
while True:
|
|
488
|
+
# Read stdout
|
|
489
|
+
size, data = channel.read()
|
|
490
|
+
if size > 0:
|
|
491
|
+
stdout.write(data)
|
|
492
|
+
|
|
493
|
+
# Read stderr
|
|
494
|
+
size, data = channel.read_stderr()
|
|
495
|
+
if size > 0:
|
|
496
|
+
stderr.write(data)
|
|
497
|
+
|
|
498
|
+
# Check if finished
|
|
499
|
+
if channel.eof():
|
|
500
|
+
break
|
|
501
|
+
|
|
502
|
+
# Get exit status
|
|
503
|
+
exit_code = channel.get_exit_status()
|
|
504
|
+
channel.close()
|
|
505
|
+
|
|
506
|
+
return subprocess.CompletedProcess(
|
|
507
|
+
args=command,
|
|
508
|
+
returncode=exit_code,
|
|
509
|
+
stdout=stdout.getvalue().decode("utf-8", errors="replace"),
|
|
510
|
+
stderr=stderr.getvalue().decode("utf-8", errors="replace"),
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def _generate_path_object(self, sftp_local_path: str, resolve: bool = False):
|
|
514
|
+
if resolve or self._root_dir == "/":
|
|
515
|
+
sftp_local_path = f"//{sftp_local_path.lstrip('/')}"
|
|
516
|
+
else:
|
|
517
|
+
sftp_local_path = os.path.relpath(sftp_local_path, start=self._root_dir)
|
|
518
|
+
if sftp_local_path == ".":
|
|
519
|
+
sftp_local_path = "/"
|
|
520
|
+
new_parts = self._urlsplit_parts._replace(path=sftp_local_path)
|
|
521
|
+
return self.from_path(urlunsplit(new_parts))
|
|
522
|
+
|
|
523
|
+
def exists(self, followlinks: bool = False) -> bool:
|
|
524
|
+
"""
|
|
525
|
+
Test if the path exists
|
|
526
|
+
|
|
527
|
+
:param followlinks: False if regard symlink as file, else True
|
|
528
|
+
:returns: True if the path exists, else False
|
|
529
|
+
"""
|
|
530
|
+
try:
|
|
531
|
+
self.stat(follow_symlinks=followlinks)
|
|
532
|
+
return True
|
|
533
|
+
except FileNotFoundError:
|
|
534
|
+
return False
|
|
535
|
+
|
|
536
|
+
def getmtime(self, follow_symlinks: bool = False) -> float:
|
|
537
|
+
"""Get last-modified time of the file on the given path"""
|
|
538
|
+
return self.stat(follow_symlinks=follow_symlinks).mtime
|
|
539
|
+
|
|
540
|
+
def getsize(self, follow_symlinks: bool = False) -> int:
|
|
541
|
+
"""Get file size on the given file path (in bytes)"""
|
|
542
|
+
return self.stat(follow_symlinks=follow_symlinks).size
|
|
543
|
+
|
|
544
|
+
def glob(
|
|
545
|
+
self, pattern, recursive: bool = True, missing_ok: bool = True
|
|
546
|
+
) -> List["Sftp2Path"]:
|
|
547
|
+
"""Return path list in ascending alphabetical order"""
|
|
548
|
+
return list(
|
|
549
|
+
self.iglob(pattern=pattern, recursive=recursive, missing_ok=missing_ok)
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
def glob_stat(
|
|
553
|
+
self, pattern, recursive: bool = True, missing_ok: bool = True
|
|
554
|
+
) -> Iterator[FileEntry]:
|
|
555
|
+
"""Return a list contains tuples of path and file stat"""
|
|
556
|
+
for path_obj in self.iglob(
|
|
557
|
+
pattern=pattern, recursive=recursive, missing_ok=missing_ok
|
|
558
|
+
):
|
|
559
|
+
yield FileEntry(path_obj.name, path_obj.path, path_obj.lstat())
|
|
560
|
+
|
|
561
|
+
def iglob(
|
|
562
|
+
self, pattern, recursive: bool = True, missing_ok: bool = True
|
|
563
|
+
) -> Iterator["Sftp2Path"]:
|
|
564
|
+
"""Return path iterator in ascending alphabetical order"""
|
|
565
|
+
glob_path = self.path_with_protocol
|
|
566
|
+
if pattern:
|
|
567
|
+
glob_path = self.joinpath(pattern).path_with_protocol
|
|
568
|
+
|
|
569
|
+
def _scandir(dirname: str) -> Iterator[Tuple[str, bool]]:
|
|
570
|
+
result = []
|
|
571
|
+
for entry in self.from_path(dirname).scandir():
|
|
572
|
+
result.append((entry.name, entry.is_dir()))
|
|
573
|
+
for name, is_dir in sorted(result):
|
|
574
|
+
yield name, is_dir
|
|
575
|
+
|
|
576
|
+
def _exist(path: PathLike, followlinks: bool = False):
|
|
577
|
+
return self.from_path(path).exists(followlinks=followlinks)
|
|
578
|
+
|
|
579
|
+
def _is_dir(path: PathLike, followlinks: bool = False):
|
|
580
|
+
return self.from_path(path).is_dir(followlinks=followlinks)
|
|
581
|
+
|
|
582
|
+
fs = FSFunc(_exist, _is_dir, _scandir)
|
|
583
|
+
for real_path in _create_missing_ok_generator(
|
|
584
|
+
iglob(fspath(glob_path), recursive=recursive, fs=fs),
|
|
585
|
+
missing_ok,
|
|
586
|
+
FileNotFoundError(f"No match any file: {glob_path!r}"),
|
|
587
|
+
):
|
|
588
|
+
yield self.from_path(real_path)
|
|
589
|
+
|
|
590
|
+
def is_dir(self, followlinks: bool = False) -> bool:
|
|
591
|
+
"""Test if a path is directory"""
|
|
592
|
+
try:
|
|
593
|
+
stat = self.stat(follow_symlinks=followlinks)
|
|
594
|
+
return stat.is_dir()
|
|
595
|
+
except FileNotFoundError:
|
|
596
|
+
return False
|
|
597
|
+
|
|
598
|
+
def is_file(self, followlinks: bool = False) -> bool:
|
|
599
|
+
"""Test if a path is file"""
|
|
600
|
+
try:
|
|
601
|
+
stat = self.stat(follow_symlinks=followlinks)
|
|
602
|
+
return (
|
|
603
|
+
S_ISREG(stat.st_mode) if hasattr(stat, "st_mode") else not stat.is_dir()
|
|
604
|
+
)
|
|
605
|
+
except FileNotFoundError:
|
|
606
|
+
return False
|
|
607
|
+
|
|
608
|
+
def listdir(self) -> List[str]:
|
|
609
|
+
"""Get all contents of given sftp2 path"""
|
|
610
|
+
with self.scandir() as entries:
|
|
611
|
+
return sorted([entry.name for entry in entries])
|
|
612
|
+
|
|
613
|
+
def iterdir(self) -> Iterator["Sftp2Path"]:
|
|
614
|
+
"""Get all contents of given sftp2 path"""
|
|
615
|
+
with self.scandir() as entries:
|
|
616
|
+
for entry in entries:
|
|
617
|
+
yield self.joinpath(entry.name)
|
|
618
|
+
|
|
619
|
+
def load(self) -> BinaryIO:
|
|
620
|
+
"""Read all content on specified path and write into memory"""
|
|
621
|
+
with self.open(mode="rb") as f:
|
|
622
|
+
data = f.read()
|
|
623
|
+
return io.BytesIO(data)
|
|
624
|
+
|
|
625
|
+
def mkdir(self, mode=0o777, parents: bool = False, exist_ok: bool = False):
|
|
626
|
+
"""Make a directory on sftp2"""
|
|
627
|
+
if self.exists():
|
|
628
|
+
if not exist_ok:
|
|
629
|
+
raise FileExistsError(f"File exists: '{self.path_with_protocol}'")
|
|
630
|
+
return
|
|
631
|
+
|
|
632
|
+
if parents:
|
|
633
|
+
parent_path_objects = []
|
|
634
|
+
for parent_path_object in self.parents:
|
|
635
|
+
if parent_path_object.exists():
|
|
636
|
+
break
|
|
637
|
+
else:
|
|
638
|
+
parent_path_objects.append(parent_path_object)
|
|
639
|
+
for parent_path_object in parent_path_objects[::-1]:
|
|
640
|
+
parent_path_object.mkdir(mode=mode, parents=False, exist_ok=True)
|
|
641
|
+
try:
|
|
642
|
+
self._client.mkdir(self._real_path, mode)
|
|
643
|
+
except OSError:
|
|
644
|
+
if not self.exists():
|
|
645
|
+
raise
|
|
646
|
+
|
|
647
|
+
def realpath(self) -> str:
|
|
648
|
+
"""Return the real path of given path"""
|
|
649
|
+
return self.resolve().path_with_protocol
|
|
650
|
+
|
|
651
|
+
def _is_same_backend(self, other: "Sftp2Path") -> bool:
|
|
652
|
+
return (
|
|
653
|
+
self._urlsplit_parts.hostname == other._urlsplit_parts.hostname
|
|
654
|
+
and self._urlsplit_parts.username == other._urlsplit_parts.username
|
|
655
|
+
and self._urlsplit_parts.password == other._urlsplit_parts.password
|
|
656
|
+
and self._urlsplit_parts.port == other._urlsplit_parts.port
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
def _is_same_protocol(self, path):
|
|
660
|
+
return is_sftp2(path)
|
|
661
|
+
|
|
662
|
+
def rename(self, dst_path: PathLike, overwrite: bool = True) -> "Sftp2Path":
|
|
663
|
+
"""Rename file on sftp2"""
|
|
664
|
+
if not self._is_same_protocol(dst_path):
|
|
665
|
+
raise OSError(f"Not a {self.protocol} path: {dst_path!r}")
|
|
666
|
+
|
|
667
|
+
dst_path = self.from_path(str(dst_path).rstrip("/"))
|
|
668
|
+
src_stat = self.stat()
|
|
669
|
+
|
|
670
|
+
if self._is_same_backend(dst_path):
|
|
671
|
+
if overwrite:
|
|
672
|
+
dst_path.remove(missing_ok=True)
|
|
673
|
+
self._client.rename(self._real_path, dst_path._real_path)
|
|
674
|
+
else:
|
|
675
|
+
self.sync(dst_path, overwrite=overwrite)
|
|
676
|
+
self.remove(missing_ok=True)
|
|
677
|
+
else:
|
|
678
|
+
if self.is_dir():
|
|
679
|
+
for file_entry in self.scandir():
|
|
680
|
+
self.from_path(file_entry.path).rename(
|
|
681
|
+
dst_path.joinpath(file_entry.name)
|
|
682
|
+
)
|
|
683
|
+
self._client.rmdir(self._real_path)
|
|
684
|
+
else:
|
|
685
|
+
if overwrite or not dst_path.exists():
|
|
686
|
+
with self.open("rb") as fsrc:
|
|
687
|
+
with dst_path.open("wb") as fdst:
|
|
688
|
+
copyfileobj(fsrc, fdst)
|
|
689
|
+
self.unlink()
|
|
690
|
+
|
|
691
|
+
dst_path.utime(src_stat.st_atime, src_stat.st_mtime)
|
|
692
|
+
dst_path.chmod(src_stat.st_mode)
|
|
693
|
+
return dst_path
|
|
694
|
+
|
|
695
|
+
def replace(self, dst_path: PathLike, overwrite: bool = True) -> "Sftp2Path":
|
|
696
|
+
"""Move file on sftp2"""
|
|
697
|
+
return self.rename(dst_path=dst_path, overwrite=overwrite)
|
|
698
|
+
|
|
699
|
+
def remove(self, missing_ok: bool = False) -> None:
|
|
700
|
+
"""Remove the file or directory on sftp2"""
|
|
701
|
+
if missing_ok and not self.exists():
|
|
702
|
+
return
|
|
703
|
+
if self.is_dir():
|
|
704
|
+
for file_entry in self.scandir():
|
|
705
|
+
self.from_path(file_entry.path).remove(missing_ok=missing_ok)
|
|
706
|
+
self._client.rmdir(self._real_path)
|
|
707
|
+
else:
|
|
708
|
+
self._client.unlink(self._real_path)
|
|
709
|
+
|
|
710
|
+
def scan(self, missing_ok: bool = True, followlinks: bool = False) -> Iterator[str]:
|
|
711
|
+
"""Iteratively traverse only files in given directory"""
|
|
712
|
+
scan_stat_iter = self.scan_stat(missing_ok=missing_ok, followlinks=followlinks)
|
|
713
|
+
for file_entry in scan_stat_iter:
|
|
714
|
+
yield file_entry.path
|
|
715
|
+
|
|
716
|
+
def scan_stat(
|
|
717
|
+
self, missing_ok: bool = True, followlinks: bool = False
|
|
718
|
+
) -> Iterator[FileEntry]:
|
|
719
|
+
"""Iteratively traverse only files in given directory"""
|
|
720
|
+
|
|
721
|
+
def create_generator() -> Iterator[FileEntry]:
|
|
722
|
+
try:
|
|
723
|
+
stat = self.stat(follow_symlinks=followlinks)
|
|
724
|
+
except FileNotFoundError:
|
|
725
|
+
return
|
|
726
|
+
if not stat.is_dir():
|
|
727
|
+
yield FileEntry(
|
|
728
|
+
self.name,
|
|
729
|
+
self.path_with_protocol,
|
|
730
|
+
self.stat(follow_symlinks=followlinks),
|
|
731
|
+
)
|
|
732
|
+
return
|
|
733
|
+
|
|
734
|
+
for name in self.listdir():
|
|
735
|
+
current_path = self.joinpath(name)
|
|
736
|
+
if current_path.is_dir():
|
|
737
|
+
yield from current_path.scan_stat(
|
|
738
|
+
missing_ok=missing_ok, followlinks=followlinks
|
|
739
|
+
)
|
|
740
|
+
else:
|
|
741
|
+
yield FileEntry(
|
|
742
|
+
current_path.name,
|
|
743
|
+
current_path.path_with_protocol,
|
|
744
|
+
current_path.stat(follow_symlinks=followlinks),
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
return _create_missing_ok_generator(
|
|
748
|
+
create_generator(),
|
|
749
|
+
missing_ok,
|
|
750
|
+
FileNotFoundError(f"No match any file in: {self.path_with_protocol!r}"),
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
def scandir(self) -> ContextIterator:
|
|
754
|
+
"""Get all content of given file path"""
|
|
755
|
+
real_path = self._real_path
|
|
756
|
+
stat_result = None
|
|
757
|
+
try:
|
|
758
|
+
stat_result = self.stat(follow_symlinks=False)
|
|
759
|
+
except Exception:
|
|
760
|
+
raise NotADirectoryError(f"Not a directory: '{self.path_with_protocol}'")
|
|
761
|
+
|
|
762
|
+
if stat_result.is_symlink():
|
|
763
|
+
real_path = self.readlink()._real_path
|
|
764
|
+
elif not stat_result.is_dir():
|
|
765
|
+
raise NotADirectoryError(f"Not a directory: '{self.path_with_protocol}'")
|
|
766
|
+
|
|
767
|
+
def create_generator():
|
|
768
|
+
# Use opendir and readdir from ssh2-python
|
|
769
|
+
dir_handle = self._client.opendir(real_path)
|
|
770
|
+
try:
|
|
771
|
+
# ssh2-python's readdir returns a generator
|
|
772
|
+
# First call returns all entries, subsequent calls return empty
|
|
773
|
+
entries_gen = dir_handle.readdir()
|
|
774
|
+
entries = list(entries_gen) if entries_gen else []
|
|
775
|
+
|
|
776
|
+
for name_len, name_bytes, stat_obj in entries:
|
|
777
|
+
name = name_bytes.decode("utf-8")
|
|
778
|
+
if name in (".", ".."):
|
|
779
|
+
continue
|
|
780
|
+
|
|
781
|
+
# Convert stat_obj to StatResult
|
|
782
|
+
stat_info = _make_stat(stat_obj)
|
|
783
|
+
yield FileEntry(
|
|
784
|
+
name,
|
|
785
|
+
self.joinpath(name).path_with_protocol,
|
|
786
|
+
stat_info,
|
|
787
|
+
)
|
|
788
|
+
finally:
|
|
789
|
+
dir_handle.close()
|
|
790
|
+
|
|
791
|
+
return ContextIterator(create_generator())
|
|
792
|
+
|
|
793
|
+
def stat(self, follow_symlinks=True) -> StatResult:
|
|
794
|
+
"""Get StatResult of file on sftp2"""
|
|
795
|
+
try:
|
|
796
|
+
if follow_symlinks:
|
|
797
|
+
stat = self._client.stat(self._real_path)
|
|
798
|
+
else:
|
|
799
|
+
stat = self._client.lstat(self._real_path)
|
|
800
|
+
return _make_stat(stat)
|
|
801
|
+
except SFTPProtocolError as e: # pytype: disable=mro-error
|
|
802
|
+
raise FileNotFoundError(
|
|
803
|
+
f"No such file or directory: {self.path_with_protocol!r}"
|
|
804
|
+
) from e
|
|
805
|
+
|
|
806
|
+
def lstat(self) -> StatResult:
|
|
807
|
+
"""Get StatResult without following symlinks"""
|
|
808
|
+
return self.stat(follow_symlinks=False)
|
|
809
|
+
|
|
810
|
+
def unlink(self, missing_ok: bool = False) -> None:
|
|
811
|
+
"""Remove the file on sftp2"""
|
|
812
|
+
if missing_ok and not self.exists():
|
|
813
|
+
return
|
|
814
|
+
self._client.unlink(self._real_path)
|
|
815
|
+
|
|
816
|
+
def walk(
|
|
817
|
+
self, followlinks: bool = False
|
|
818
|
+
) -> Iterator[Tuple[str, List[str], List[str]]]:
|
|
819
|
+
"""Generate the file names in a directory tree by walking the tree top-down"""
|
|
820
|
+
if not self.exists(followlinks=followlinks):
|
|
821
|
+
return
|
|
822
|
+
|
|
823
|
+
if self.is_file(followlinks=followlinks):
|
|
824
|
+
return
|
|
825
|
+
|
|
826
|
+
stack = [self._real_path]
|
|
827
|
+
while stack:
|
|
828
|
+
root = stack.pop()
|
|
829
|
+
dirs, files = [], []
|
|
830
|
+
|
|
831
|
+
# Use scandir instead of readdir for consistency
|
|
832
|
+
root_path = self._generate_path_object(root)
|
|
833
|
+
with root_path.scandir() as entries:
|
|
834
|
+
for entry in entries:
|
|
835
|
+
if entry.is_dir():
|
|
836
|
+
dirs.append(entry.name)
|
|
837
|
+
elif entry.is_file():
|
|
838
|
+
files.append(entry.name)
|
|
839
|
+
|
|
840
|
+
dirs = sorted(dirs)
|
|
841
|
+
files = sorted(files)
|
|
842
|
+
|
|
843
|
+
yield self._generate_path_object(root).path_with_protocol, dirs, files
|
|
844
|
+
|
|
845
|
+
stack.extend(
|
|
846
|
+
(os.path.join(root, directory) for directory in reversed(dirs))
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
def resolve(self, strict=False) -> "Sftp2Path":
|
|
850
|
+
"""Return the canonical path"""
|
|
851
|
+
path = self._client.realpath(self._real_path)
|
|
852
|
+
return self._generate_path_object(path, resolve=True)
|
|
853
|
+
|
|
854
|
+
def md5(self, recalculate: bool = False, followlinks: bool = False):
|
|
855
|
+
"""Calculate the md5 value of the file"""
|
|
856
|
+
if self.is_dir():
|
|
857
|
+
hash_md5 = hashlib.md5()
|
|
858
|
+
for file_name in self.listdir():
|
|
859
|
+
chunk = (
|
|
860
|
+
self.joinpath(file_name)
|
|
861
|
+
.md5(recalculate=recalculate, followlinks=followlinks)
|
|
862
|
+
.encode()
|
|
863
|
+
)
|
|
864
|
+
hash_md5.update(chunk)
|
|
865
|
+
return hash_md5.hexdigest()
|
|
866
|
+
with self.open("rb") as src:
|
|
867
|
+
md5 = calculate_md5(src)
|
|
868
|
+
return md5
|
|
869
|
+
|
|
870
|
+
def symlink(self, dst_path: PathLike) -> None:
|
|
871
|
+
"""Create a symbolic link pointing to src_path named dst_path"""
|
|
872
|
+
dst_path = self.from_path(dst_path)
|
|
873
|
+
if dst_path.exists(followlinks=False):
|
|
874
|
+
raise FileExistsError(f"File exists: '{dst_path.path_with_protocol}'")
|
|
875
|
+
return self._client.symlink(self._real_path, dst_path._real_path)
|
|
876
|
+
|
|
877
|
+
def readlink(self) -> "Sftp2Path":
|
|
878
|
+
"""Return a Sftp2Path instance representing the path to which the
|
|
879
|
+
symbolic link points"""
|
|
880
|
+
if not self.exists():
|
|
881
|
+
raise FileNotFoundError(
|
|
882
|
+
f"No such file or directory: '{self.path_with_protocol}'"
|
|
883
|
+
)
|
|
884
|
+
if not self.is_symlink():
|
|
885
|
+
raise OSError(f"Not a symlink: {self.path_with_protocol!r}")
|
|
886
|
+
try:
|
|
887
|
+
path = self._client.readlink(self._real_path)
|
|
888
|
+
if not path:
|
|
889
|
+
raise OSError(f"Not a symlink: {self.path_with_protocol!r}")
|
|
890
|
+
if not path.startswith("/"):
|
|
891
|
+
return self.parent.joinpath(path)
|
|
892
|
+
return self._generate_path_object(path)
|
|
893
|
+
except FileNotFoundError:
|
|
894
|
+
raise FileNotFoundError(
|
|
895
|
+
f"No such file or directory: '{self.path_with_protocol}'"
|
|
896
|
+
)
|
|
897
|
+
except Exception:
|
|
898
|
+
raise OSError(f"Not a symlink: {self.path_with_protocol!r}")
|
|
899
|
+
|
|
900
|
+
def is_symlink(self) -> bool:
|
|
901
|
+
"""Test whether a path is a symbolic link"""
|
|
902
|
+
try:
|
|
903
|
+
return self.lstat().is_symlink()
|
|
904
|
+
except FileNotFoundError:
|
|
905
|
+
return False
|
|
906
|
+
|
|
907
|
+
def cwd(self) -> "Sftp2Path":
|
|
908
|
+
"""Return current working directory"""
|
|
909
|
+
path = self._client.realpath(".")
|
|
910
|
+
return self._generate_path_object(path)
|
|
911
|
+
|
|
912
|
+
def save(self, file_object: BinaryIO):
|
|
913
|
+
"""Write the opened binary stream to path"""
|
|
914
|
+
with self.open(mode="wb") as output:
|
|
915
|
+
output.write(file_object.read())
|
|
916
|
+
|
|
917
|
+
def open(
|
|
918
|
+
self,
|
|
919
|
+
mode: str = "r",
|
|
920
|
+
*,
|
|
921
|
+
buffering=-1,
|
|
922
|
+
encoding: Optional[str] = None,
|
|
923
|
+
errors: Optional[str] = None,
|
|
924
|
+
**kwargs,
|
|
925
|
+
) -> IO:
|
|
926
|
+
"""Open a file on the path"""
|
|
927
|
+
if "w" in mode or "x" in mode or "a" in mode:
|
|
928
|
+
if self.is_dir():
|
|
929
|
+
raise IsADirectoryError(f"Is a directory: {self.path_with_protocol!r}")
|
|
930
|
+
self.parent.mkdir(parents=True, exist_ok=True)
|
|
931
|
+
elif not self.exists():
|
|
932
|
+
raise FileNotFoundError(f"No such file: {self.path_with_protocol!r}")
|
|
933
|
+
|
|
934
|
+
# Convert mode for ssh2-python
|
|
935
|
+
ssh2_mode = 0
|
|
936
|
+
if "r" in mode:
|
|
937
|
+
ssh2_mode |= ssh2.sftp.LIBSSH2_FXF_READ
|
|
938
|
+
if "w" in mode:
|
|
939
|
+
ssh2_mode |= (
|
|
940
|
+
ssh2.sftp.LIBSSH2_FXF_WRITE
|
|
941
|
+
| ssh2.sftp.LIBSSH2_FXF_CREAT
|
|
942
|
+
| ssh2.sftp.LIBSSH2_FXF_TRUNC
|
|
943
|
+
)
|
|
944
|
+
if "a" in mode:
|
|
945
|
+
ssh2_mode |= (
|
|
946
|
+
ssh2.sftp.LIBSSH2_FXF_WRITE
|
|
947
|
+
| ssh2.sftp.LIBSSH2_FXF_CREAT
|
|
948
|
+
| ssh2.sftp.LIBSSH2_FXF_APPEND
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
sftp_handle = self._client.open(self._real_path, ssh2_mode, 0o644)
|
|
952
|
+
|
|
953
|
+
# Create raw file wrapper
|
|
954
|
+
raw_file = Sftp2RawFile(sftp_handle, self.path, mode)
|
|
955
|
+
|
|
956
|
+
if "r" in mode:
|
|
957
|
+
if "b" in mode:
|
|
958
|
+
# Binary read mode - use BufferedReader for optimal performance
|
|
959
|
+
fileobj = io.BufferedReader(raw_file, buffer_size=SFTP2_BUFFER_SIZE)
|
|
960
|
+
else:
|
|
961
|
+
# Text read mode - wrap BufferedReader with TextIOWrapper
|
|
962
|
+
buffered = io.BufferedReader(raw_file, buffer_size=SFTP2_BUFFER_SIZE)
|
|
963
|
+
fileobj = io.TextIOWrapper(buffered, encoding=encoding, errors=errors)
|
|
964
|
+
elif "w" in mode or "a" in mode:
|
|
965
|
+
if "b" in mode:
|
|
966
|
+
# Binary write mode - use BufferedWriter for optimal performance
|
|
967
|
+
fileobj = io.BufferedWriter(raw_file, buffer_size=SFTP2_BUFFER_SIZE)
|
|
968
|
+
else:
|
|
969
|
+
# Text write mode - wrap BufferedWriter with TextIOWrapper
|
|
970
|
+
buffered = io.BufferedWriter(raw_file, buffer_size=SFTP2_BUFFER_SIZE)
|
|
971
|
+
fileobj = io.TextIOWrapper(buffered, encoding=encoding, errors=errors)
|
|
972
|
+
else:
|
|
973
|
+
raise ValueError(f"Invalid mode: {mode}")
|
|
974
|
+
|
|
975
|
+
return fileobj
|
|
976
|
+
|
|
977
|
+
def chmod(self, mode: int, *, follow_symlinks: bool = True):
|
|
978
|
+
"""Change the file mode and permissions"""
|
|
979
|
+
return self._client.setstat(self._real_path, mode)
|
|
980
|
+
|
|
981
|
+
def absolute(self) -> "Sftp2Path":
|
|
982
|
+
"""Make the path absolute"""
|
|
983
|
+
return self.resolve()
|
|
984
|
+
|
|
985
|
+
def rmdir(self):
|
|
986
|
+
"""Remove this directory. The directory must be empty"""
|
|
987
|
+
if len(self.listdir()) > 0:
|
|
988
|
+
raise OSError(f"Directory not empty: '{self.path_with_protocol}'")
|
|
989
|
+
return self._client.rmdir(self._real_path)
|
|
990
|
+
|
|
991
|
+
def copy(
|
|
992
|
+
self,
|
|
993
|
+
dst_path: PathLike,
|
|
994
|
+
callback: Optional[Callable[[int], None]] = None,
|
|
995
|
+
followlinks: bool = False,
|
|
996
|
+
overwrite: bool = True,
|
|
997
|
+
):
|
|
998
|
+
"""Copy the file to the given destination path"""
|
|
999
|
+
if followlinks and self.is_symlink():
|
|
1000
|
+
return self.readlink().copy(dst_path=dst_path, callback=callback)
|
|
1001
|
+
|
|
1002
|
+
if not self._is_same_protocol(dst_path):
|
|
1003
|
+
raise OSError(f"Not a {self.protocol} path: {dst_path!r}")
|
|
1004
|
+
if str(dst_path).endswith("/"):
|
|
1005
|
+
raise IsADirectoryError(f"Is a directory: {dst_path!r}")
|
|
1006
|
+
|
|
1007
|
+
if self.is_dir():
|
|
1008
|
+
raise IsADirectoryError(f"Is a directory: {self.path_with_protocol!r}")
|
|
1009
|
+
|
|
1010
|
+
if not overwrite and self.from_path(dst_path).exists():
|
|
1011
|
+
return
|
|
1012
|
+
|
|
1013
|
+
self.from_path(os.path.dirname(fspath(dst_path))).makedirs(exist_ok=True)
|
|
1014
|
+
dst_path = self.from_path(dst_path)
|
|
1015
|
+
|
|
1016
|
+
if self._is_same_backend(dst_path):
|
|
1017
|
+
if self._real_path == dst_path._real_path:
|
|
1018
|
+
raise SameFileError(
|
|
1019
|
+
f"'{self.path}' and '{dst_path.path}' are the same file"
|
|
1020
|
+
)
|
|
1021
|
+
# Same server - use server-side copy command for efficiency
|
|
1022
|
+
exec_result = self._exec_command(
|
|
1023
|
+
[
|
|
1024
|
+
"cp",
|
|
1025
|
+
self._real_path,
|
|
1026
|
+
dst_path._real_path,
|
|
1027
|
+
]
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
if exec_result.returncode != 0:
|
|
1031
|
+
_logger.error(exec_result.stderr)
|
|
1032
|
+
raise OSError(
|
|
1033
|
+
f"Failed to copy file, returncode: {exec_result.returncode}, "
|
|
1034
|
+
f"{exec_result.stderr}"
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
if callback:
|
|
1038
|
+
callback(self.stat(follow_symlinks=followlinks).size)
|
|
1039
|
+
|
|
1040
|
+
else:
|
|
1041
|
+
# Fallback to traditional SFTP copy (download then upload)
|
|
1042
|
+
with self.open("rb") as fsrc:
|
|
1043
|
+
with dst_path.open("wb") as fdst:
|
|
1044
|
+
copyfileobj(fsrc, fdst, callback)
|
|
1045
|
+
|
|
1046
|
+
src_stat = self.stat()
|
|
1047
|
+
dst_path.utime(src_stat.st_atime, src_stat.st_mtime)
|
|
1048
|
+
dst_path.chmod(src_stat.st_mode)
|
|
1049
|
+
|
|
1050
|
+
def sync(
|
|
1051
|
+
self,
|
|
1052
|
+
dst_path: PathLike,
|
|
1053
|
+
followlinks: bool = False,
|
|
1054
|
+
force: bool = False,
|
|
1055
|
+
overwrite: bool = True,
|
|
1056
|
+
):
|
|
1057
|
+
"""Copy file/directory on src_url to dst_url"""
|
|
1058
|
+
if not self._is_same_protocol(dst_path):
|
|
1059
|
+
raise OSError(f"Not a {self.protocol} path: {dst_path!r}")
|
|
1060
|
+
|
|
1061
|
+
for src_file_path, dst_file_path in _sftp2_scan_pairs(
|
|
1062
|
+
self.path_with_protocol, dst_path
|
|
1063
|
+
):
|
|
1064
|
+
dst_path = self.from_path(dst_file_path)
|
|
1065
|
+
src_path = self.from_path(src_file_path)
|
|
1066
|
+
|
|
1067
|
+
if force:
|
|
1068
|
+
pass
|
|
1069
|
+
elif not overwrite and dst_path.exists():
|
|
1070
|
+
continue
|
|
1071
|
+
elif dst_path.exists() and is_same_file(
|
|
1072
|
+
src_path.stat(), dst_path.stat(), "copy"
|
|
1073
|
+
):
|
|
1074
|
+
continue
|
|
1075
|
+
|
|
1076
|
+
src_path.copy(
|
|
1077
|
+
dst_file_path,
|
|
1078
|
+
followlinks=followlinks,
|
|
1079
|
+
overwrite=True,
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
def utime(self, atime: Union[float, int], mtime: Union[float, int]) -> None:
|
|
1083
|
+
"""Set the access and modified times of the file"""
|
|
1084
|
+
self._client.utime(self._real_path, (atime, mtime))
|