Coverage for seedboxsync/core/sync/client/ftp.py: 100%

97 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 FTP protocol.""" 

8 

9from collections.abc import Generator 

10import contextlib 

11import ftplib 

12from os import fspath 

13from pathlib import Path 

14from typing import Any 

15import ftputil 

16import ftputil.error 

17from seedboxsync.core import Flask, current_app 

18from seedboxsync.core.exception import SeedboxsyncConnectionError 

19from seedboxsync.core.sync import AbstractSyncClient, PathType, ProgressCallback 

20 

21 

22class FtpSession(ftplib.FTP): 

23 """FTP session factory for ftputil with explicit port and timeout support.""" 

24 

25 def __init__(self, host: str, user: str, password: str, port: int = 21, timeout: float = -999) -> None: 

26 """ 

27 Connect and authenticate an FTP session. 

28 

29 Args: 

30 host (str): FTP server hostname. 

31 user (str): FTP account username. 

32 password (str): FTP account password. 

33 port (int): FTP server port. 

34 timeout (float): Connection timeout passed to ``ftplib``. 

35 """ 

36 super().__init__() 

37 self.connect(host, port, timeout=timeout) 

38 self.login(user, password) 

39 

40 

41class FtpClient(AbstractSyncClient): 

42 """ 

43 FTP transport client using ftputil. 

44 

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

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

47 """ 

48 

49 app: Flask 

50 _host: str 

51 _login: str 

52 _password: str 

53 _port: str 

54 _timeout: float | None 

55 _client: Any | None 

56 

57 def __init__(self) -> None: 

58 """Initialize the FTP client with application connection parameters.""" 

59 self.app = current_app 

60 

61 # Get config 

62 config = self.app.seedboxsync_config 

63 

64 self._host = config.get("seedbox_host", "") 

65 self._login = config.get("seedbox_login", "") 

66 self._password = config.get("seedbox_password", "") 

67 self._port = config.get("seedbox_port", "21") 

68 

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

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

71 

72 self._client = None 

73 

74 def _connect_before(self) -> Any: 

75 """ 

76 Initialize the FTP client if not already connected. 

77 

78 Raises: 

79 SeedboxsyncConnectionError: If connection or authentication fails. 

80 """ 

81 if self._client is None or self._client.closed: 

82 self.app.logger.debug("Init or reload ftputil.FTPHost") 

83 

84 # Close inactive connecion 

85 if self._client is not None: 

86 with contextlib.suppress(Exception): 

87 self._client.close() 

88 

89 try: 

90 self._client = ftputil.FTPHost( 

91 self._host, 

92 self._login, 

93 self._password, 

94 port=int(self._port), 

95 timeout=self._timeout, 

96 session_factory=FtpSession, 

97 ) 

98 except ( 

99 ftputil.error.FTPError, 

100 ftplib.Error, 

101 OSError, 

102 EOFError, 

103 ValueError, 

104 ) as exc: 

105 raise SeedboxsyncConnectionError(f"{exc!s}\nFailed to establish a connection. Ensure the host, port, login and password are correct.") from exc 

106 

107 return self._client 

108 

109 def put(self, local_path: PathType, remote_path: PathType) -> None: 

110 """ 

111 Upload a local file to the FTP server. 

112 

113 Args: 

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

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

116 """ 

117 client = self._connect_before() 

118 local_path = fspath(local_path) 

119 remote_path = fspath(remote_path) 

120 client.upload(local_path, remote_path) 

121 

122 def get( 

123 self, 

124 remote_path: PathType, 

125 local_path: PathType, 

126 progress_callback: ProgressCallback | None = None, 

127 ) -> None: 

128 """ 

129 Download a remote file from the FTP server. 

130 

131 Args: 

132 remote_path (PathType): Path of the remote file. 

133 local_path (PathType): Destination path on the local host. 

134 progress_callback (ProgressCallback | None): Optional callback receiving bytes_transferred and total_bytes. 

135 """ 

136 client = self._connect_before() 

137 remote_path = fspath(remote_path) 

138 local_path = fspath(local_path) 

139 

140 if progress_callback is None: 

141 client.download(remote_path, local_path) 

142 return 

143 

144 total_size = client.stat(remote_path).st_size 

145 transferred = 0 

146 ftp_session = client._session 

147 

148 with Path(local_path).open("wb") as local_file: 

149 

150 def on_block(data: bytes) -> None: 

151 nonlocal transferred 

152 local_file.write(data) 

153 transferred += len(data) 

154 progress_callback(transferred, total_size) 

155 

156 ftp_session.retrbinary( 

157 f"RETR {remote_path}", 

158 on_block, 

159 ) 

160 

161 def stat(self, filepath: PathType) -> Any: 

162 """ 

163 Retrieve metadata for a remote file. 

164 

165 Args: 

166 filepath (PathType): Remote file path. 

167 

168 Returns: 

169 Any: Object with attributes similar to Python's os.stat. 

170 """ 

171 client = self._connect_before() 

172 filepath = fspath(filepath) 

173 

174 return client.stat(filepath) 

175 

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

177 """ 

178 Change the current working directory of the FTP session. 

179 

180 Args: 

181 path (Optional[PathType]): Target directory. If None, no change occurs. 

182 """ 

183 client = self._connect_before() 

184 if path is not None: 

185 client.chdir(fspath(path)) 

186 

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

188 """ 

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

190 

191 Args: 

192 path (PathType): Path of the file. 

193 mode (int): Unix-style permissions (like os.chmod). 

194 """ 

195 client = self._connect_before() 

196 path = fspath(path) 

197 client.chmod(path, mode) 

198 

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

200 """ 

201 Rename a file or directory on the remote server. 

202 

203 Args: 

204 old_path (PathType): Existing path. 

205 new_path (PathType): New path. 

206 """ 

207 client = self._connect_before() 

208 old_path = fspath(old_path) 

209 new_path = fspath(new_path) 

210 client.rename(old_path, new_path) 

211 

212 def walk(self, remote_path: PathType) -> Generator[tuple[str, list[str], list[str]]]: 

213 """ 

214 Walk through remote directories, yielding paths, folders, and files. 

215 

216 Args: 

217 remote_path (PathType): Remote directory to traverse. 

218 

219 Yields: 

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

221 """ 

222 client = self._connect_before() 

223 remote_path = fspath(remote_path) 

224 walk_path = remote_path if remote_path != "" else client.curdir 

225 for path, folders, files in client.walk(walk_path): 

226 if remote_path == "": 

227 path = self._remove_current_directory_prefix(client, path) 

228 yield path, folders, files 

229 

230 @staticmethod 

231 def _remove_current_directory_prefix(client: Any, path: str) -> str: 

232 """ 

233 Keep FTP walk paths compatible with SftpClient.walk(''). 

234 

235 Args: 

236 client (Any): Active ``ftputil`` client. 

237 path (str): Path returned by ``ftputil``. 

238 

239 Returns: 

240 str: Path relative to the current FTP directory. 

241 """ 

242 if path == client.curdir: 

243 return "" 

244 

245 prefix = client.curdir + client.sep 

246 if path.startswith(prefix): 

247 return path[len(prefix) :] 

248 

249 return path 

250 

251 def close(self) -> None: 

252 """Close the FTP client and underlying connection.""" 

253 if self._client is not None: 

254 self.app.logger.debug("Close ftputil.FTPHost client") 

255 self._client.close() 

256 self._client = None