megfile 4.2.2__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/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))