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

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.""" 

8 

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 

20 

21 

22class SftpClient(AbstractSyncClient): 

23 """ 

24 SFTP transport client using Paramiko. 

25 

26 Handles file transfers between NAS and Seedbox servers. Provides basic 

27 operations such as get, put, rename, chmod, and directory traversal. 

28 """ 

29 

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 

39 

40 def __init__(self) -> None: 

41 """Initialize the SFTP client with application connection parameters.""" 

42 self.app = current_app 

43 

44 # Get config 

45 config = self.app.seedboxsync_config 

46 

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", "") 

51 

52 raw_timeout = config.get("seedbox_timeout", False) 

53 self._timeout = float(raw_timeout) if raw_timeout else None 

54 

55 self._max_concurrent_prefetch_requests = int(config.get("seedbox_max_concurrent_prefetch_requests", 128)) 

56 self._transport = None 

57 

58 self.app.logger.debug(f"Use sftp://{self._login}:****@{self._host}:{self._port}") 

59 

60 def _connect_before(self) -> None: 

61 """ 

62 Initialize the SFTP transport and client if not already connected. 

63 

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") 

69 

70 # Close inactive connecion 

71 if self._transport is not None: 

72 with contextlib.suppress(Exception): 

73 self._transport.close() 

74 

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 

81 

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 

86 

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 

92 

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()}") 

99 

100 def put(self, local_path: PathType, remote_path: PathType) -> SFTPAttributes: 

101 """ 

102 Upload a local file to the SFTP server. 

103 

104 Args: 

105 local_path (PathType): Path to the local file. 

106 remote_path (PathType): Destination path on the server (including filename). 

107 

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) 

114 

115 return self._client.put(local_path, remote_path) 

116 

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. 

125 

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) 

134 

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 ) 

141 

142 def stat(self, filepath: PathType) -> SFTPAttributes: 

143 """ 

144 Retrieve metadata for a remote file. 

145 

146 Args: 

147 filepath (PathType): Remote file path. 

148 

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) 

155 

156 return self._client.stat(filepath) 

157 

158 def chdir(self, path: PathType | None = None) -> None: 

159 """ 

160 Change the current working directory of the SFTP session. 

161 

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) 

168 

169 def chmod(self, path: PathType, mode: int) -> None: 

170 """ 

171 Change the mode (permissions) of a remote file. 

172 

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) 

180 

181 def rename(self, old_path: PathType, new_path: PathType) -> None: 

182 """ 

183 Rename a file or directory on the remote server. 

184 

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) 

193 

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. 

197 

198 Args: 

199 remote_path (PathType): Remote directory to traverse. 

200 

201 Yields: 

202 tuple[str, list[str], list[str]]: (current_path, folders, files) 

203 

204 Note: 

205 Simplified version of os.walk for SFTP. Efficient for large directories. 

206 

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 

221 

222 for folder in folders: 

223 new_path = str(Path(remote_path) / folder) 

224 yield from self.walk(new_path) 

225 

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()