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
« 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."""
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
22class FtpSession(ftplib.FTP):
23 """FTP session factory for ftputil with explicit port and timeout support."""
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.
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)
41class FtpClient(AbstractSyncClient):
42 """
43 FTP transport client using ftputil.
45 Handles file transfers between NAS and Seedbox servers. Provides basic
46 operations such as get, put, rename, chmod, and directory traversal.
47 """
49 app: Flask
50 _host: str
51 _login: str
52 _password: str
53 _port: str
54 _timeout: float | None
55 _client: Any | None
57 def __init__(self) -> None:
58 """Initialize the FTP client with application connection parameters."""
59 self.app = current_app
61 # Get config
62 config = self.app.seedboxsync_config
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")
69 raw_timeout = config.get("seedbox_timeout", False)
70 self._timeout = float(raw_timeout) if raw_timeout else None
72 self._client = None
74 def _connect_before(self) -> Any:
75 """
76 Initialize the FTP client if not already connected.
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")
84 # Close inactive connecion
85 if self._client is not None:
86 with contextlib.suppress(Exception):
87 self._client.close()
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
107 return self._client
109 def put(self, local_path: PathType, remote_path: PathType) -> None:
110 """
111 Upload a local file to the FTP server.
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)
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.
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)
140 if progress_callback is None:
141 client.download(remote_path, local_path)
142 return
144 total_size = client.stat(remote_path).st_size
145 transferred = 0
146 ftp_session = client._session
148 with Path(local_path).open("wb") as local_file:
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)
156 ftp_session.retrbinary(
157 f"RETR {remote_path}",
158 on_block,
159 )
161 def stat(self, filepath: PathType) -> Any:
162 """
163 Retrieve metadata for a remote file.
165 Args:
166 filepath (PathType): Remote file path.
168 Returns:
169 Any: Object with attributes similar to Python's os.stat.
170 """
171 client = self._connect_before()
172 filepath = fspath(filepath)
174 return client.stat(filepath)
176 def chdir(self, path: PathType | None = None) -> None:
177 """
178 Change the current working directory of the FTP session.
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))
187 def chmod(self, path: PathType, mode: int) -> None:
188 """
189 Change the mode (permissions) of a remote file.
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)
199 def rename(self, old_path: PathType, new_path: PathType) -> None:
200 """
201 Rename a file or directory on the remote server.
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)
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.
216 Args:
217 remote_path (PathType): Remote directory to traverse.
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
230 @staticmethod
231 def _remove_current_directory_prefix(client: Any, path: str) -> str:
232 """
233 Keep FTP walk paths compatible with SftpClient.walk('').
235 Args:
236 client (Any): Active ``ftputil`` client.
237 path (str): Path returned by ``ftputil``.
239 Returns:
240 str: Path relative to the current FTP directory.
241 """
242 if path == client.curdir:
243 return ""
245 prefix = client.curdir + client.sep
246 if path.startswith(prefix):
247 return path[len(prefix) :]
249 return path
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