Coverage for seedboxsync/core/sync/client/sftp.py: 99%
93 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 17:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 17:46 +0000
1#
2# Copyright (C) 2015-2026 Guillaume Kulakowski <guillaume@kulakowski.fr>
3#
4# For the full copyright and license information, please view the LICENSE
5# file that was distributed with this source code.
6#
7"""Transport client using the SFTP protocol."""
9from collections.abc import Generator
10import contextlib
11from os import fspath
12from pathlib import Path
13import socket
14from stat import S_ISDIR
15import paramiko
16from paramiko.sftp_attr import SFTPAttributes
17from seedboxsync.core import Flask, current_app
18from seedboxsync.core.exception import SeedboxsyncConnectionError
19from seedboxsync.core.sync import AbstractSyncClient, PathType, ProgressCallback
22class SftpClient(AbstractSyncClient):
23 """
24 SFTP transport client using Paramiko.
26 Handles file transfers between NAS and Seedbox servers. Provides basic
27 operations such as get, put, rename, chmod, and directory traversal.
28 """
30 app: Flask
31 _host: str
32 _login: str
33 _password: str
34 _port: str
35 _timeout: float | None
36 _transport: paramiko.Transport | None
37 _client: paramiko.SFTPClient
38 _max_concurrent_prefetch_requests: int
40 def __init__(self) -> None:
41 """Initialize the SFTP client with application connection parameters."""
42 self.app = current_app
44 # Get config
45 config = self.app.seedboxsync_config
47 self._host = config.get("seedbox_host", "")
48 self._login = config.get("seedbox_login", "")
49 self._password = config.get("seedbox_password", "")
50 self._port = config.get("seedbox_port", "")
52 raw_timeout = config.get("seedbox_timeout", False)
53 self._timeout = float(raw_timeout) if raw_timeout else None
55 self._max_concurrent_prefetch_requests = int(config.get("seedbox_max_concurrent_prefetch_requests", 128))
56 self._transport = None
58 self.app.logger.debug(f"Use sftp://{self._login}:****@{self._host}:{self._port}")
60 def _connect_before(self) -> None:
61 """
62 Initialize the SFTP transport and client if not already connected.
64 Raises:
65 SeedboxsyncConnectionError: If connection or authentication fails.
66 """
67 if self._transport is None or not self._transport.is_active():
68 self.app.logger.debug("Init or reload paramiko.Transport")
70 # Close inactive connecion
71 if self._transport is not None:
72 with contextlib.suppress(Exception):
73 self._transport.close()
75 try:
76 self._transport = paramiko.Transport((self._host, int(self._port)))
77 except (socket.gaierror, ConnectionRefusedError) as exc:
78 raise SeedboxsyncConnectionError(
79 f"{exc!s}\nFailed to establish a connection. " + "Ensure the host and port are correct, and that no firewall is blocking access."
80 ) from exc
82 try:
83 self._transport.connect(username=self._login, password=self._password)
84 except paramiko.AuthenticationException as exc:
85 raise SeedboxsyncConnectionError(f"{exc!s}\nFailed to establish a connection. Ensure the login and password are correct.") from exc
87 self.app.logger.debug("Init paramiko.SFTPClient from transport")
88 client = paramiko.SFTPClient.from_transport(self._transport)
89 if client is None:
90 raise SeedboxsyncConnectionError("Failed to initialize SFTPClient from transport.")
91 self._client = client
93 # Setup timeout
94 if self._timeout is not None:
95 channel = self._client.get_channel()
96 if channel is not None:
97 channel.settimeout(self._timeout)
98 self.app.logger.debug(f"Timeout is set to {channel.gettimeout()}")
100 def put(self, local_path: PathType, remote_path: PathType) -> SFTPAttributes:
101 """
102 Upload a local file to the SFTP server.
104 Args:
105 local_path (PathType): Path to the local file.
106 remote_path (PathType): Destination path on the server (including filename).
108 Returns:
109 SFTPAttributes: Metadata of the uploaded file.
110 """
111 self._connect_before()
112 local_path = fspath(local_path)
113 remote_path = fspath(remote_path)
115 return self._client.put(local_path, remote_path)
117 def get(
118 self,
119 remote_path: PathType,
120 local_path: PathType,
121 progress_callback: ProgressCallback | None = None,
122 ) -> None:
123 """
124 Download a remote file from the SFTP server.
126 Args:
127 remote_path (PathType): Path of the remote file.
128 local_path (PathType): Destination path on the local host.
129 progress_callback (ProgressCallback | None): Optional callback receiving bytes_transferred.
130 """
131 self._connect_before()
132 remote_path = fspath(remote_path)
133 local_path = fspath(local_path)
135 self._client.get(
136 remote_path,
137 local_path,
138 callback=progress_callback,
139 max_concurrent_prefetch_requests=self._max_concurrent_prefetch_requests,
140 )
142 def stat(self, filepath: PathType) -> SFTPAttributes:
143 """
144 Retrieve metadata for a remote file.
146 Args:
147 filepath (PathType): Remote file path.
149 Returns:
150 SFTPAttributes: Object with attributes similar to Python's os.stat:
151 st_mode, st_size, st_uid, st_gid, st_atime, st_mtime.
152 """
153 self._connect_before()
154 filepath = fspath(filepath)
156 return self._client.stat(filepath)
158 def chdir(self, path: PathType | None = None) -> None:
159 """
160 Change the current working directory of the SFTP session.
162 Args:
163 path (Optional[PathType]): Target directory. If None, no change occurs.
164 """
165 self._connect_before()
166 path = fspath(path) if path is not None else None
167 self._client.chdir(path)
169 def chmod(self, path: PathType, mode: int) -> None:
170 """
171 Change the mode (permissions) of a remote file.
173 Args:
174 path (PathType): Path of the file.
175 mode (int): Unix-style permissions (like os.chmod).
176 """
177 self._connect_before()
178 path = fspath(path)
179 self._client.chmod(path, mode)
181 def rename(self, old_path: PathType, new_path: PathType) -> None:
182 """
183 Rename a file or directory on the remote server.
185 Args:
186 old_path (PathType): Existing path.
187 new_path (PathType): New path.
188 """
189 self._connect_before()
190 old_path = fspath(old_path)
191 new_path = fspath(new_path)
192 self._client.posix_rename(old_path, new_path)
194 def walk(self, remote_path: PathType) -> Generator[tuple[str, list[str], list[str]]]:
195 """
196 Walk through remote directories, yielding paths, folders, and files.
198 Args:
199 remote_path (PathType): Remote directory to traverse.
201 Yields:
202 tuple[str, list[str], list[str]]: (current_path, folders, files)
204 Note:
205 Simplified version of os.walk for SFTP. Efficient for large directories.
207 Source:
208 https://gist.github.com/johnfink8/2190472
209 """
210 self._connect_before()
211 remote_path = fspath(remote_path)
212 path = remote_path
213 files: list[str] = []
214 folders: list[str] = []
215 for f in self._client.listdir_attr(remote_path):
216 if f.st_mode is not None and S_ISDIR(f.st_mode):
217 folders.append(f.filename)
218 else:
219 files.append(f.filename)
220 yield path, folders, files
222 for folder in folders:
223 new_path = str(Path(remote_path) / folder)
224 yield from self.walk(new_path)
226 def close(self) -> None:
227 """Close the SFTP transport client and underlying connection."""
228 if self._transport is not None:
229 self.app.logger.debug("Close paramiko.Transport client")
230 self._transport.close()