ftp.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. # -*- coding: utf-8 -*-
  2. """
  3. FTP/SFTP Client mit persistenter Verbindung.
  4. Bietet Connect/Login/Disconnect, Upload/Download,
  5. Verzeichnis-Operationen und Datei-Listing.
  6. FTP: Verwendet ftplib (Python-Standard)
  7. SFTP: Benoetigt asyncssh (pip install asyncssh)
  8. """
  9. from __future__ import annotations
  10. import asyncio
  11. import ftplib
  12. from dataclasses import dataclass
  13. from enum import Enum
  14. from pathlib import Path
  15. from typing import Any
  16. from trixy_core.utils.debug import pinfo, pdebug, perror
  17. class FTPProtocol(Enum):
  18. """Unterstuetzte Protokolle."""
  19. FTP = "ftp"
  20. FTPS = "ftps" # FTP ueber TLS
  21. SFTP = "sftp" # SSH File Transfer
  22. @dataclass
  23. class FTPConfig:
  24. """FTP/SFTP Verbindungskonfiguration."""
  25. host: str
  26. port: int = 0 # 0 = Standard (FTP: 21, SFTP: 22)
  27. username: str = ""
  28. password: str = ""
  29. protocol: FTPProtocol = FTPProtocol.FTP
  30. # SFTP-spezifisch
  31. key_file: str = "" # Pfad zum SSH Private Key
  32. known_hosts: str = "" # Pfad zu known_hosts (leer = nicht pruefen)
  33. # Timeouts
  34. connect_timeout: float = 30.0
  35. transfer_timeout: float = 300.0
  36. @property
  37. def effective_port(self) -> int:
  38. """Gibt den effektiven Port zurueck."""
  39. if self.port > 0:
  40. return self.port
  41. if self.protocol == FTPProtocol.SFTP:
  42. return 22
  43. return 21
  44. class FTPClient:
  45. """
  46. FTP/FTPS/SFTP Client mit persistenter Verbindung.
  47. Beispiel FTP:
  48. ```python
  49. ftp = FTPClient(FTPConfig(
  50. host="ftp.example.com",
  51. username="user",
  52. password="pass",
  53. ))
  54. await ftp.connect()
  55. files = await ftp.list_dir("/models/")
  56. await ftp.download("/models/vosk.zip", "/tmp/vosk.zip")
  57. await ftp.upload("/tmp/results.json", "/uploads/results.json")
  58. await ftp.disconnect()
  59. ```
  60. Beispiel SFTP:
  61. ```python
  62. sftp = FTPClient(FTPConfig(
  63. host="ssh.example.com",
  64. username="pi",
  65. key_file="~/.ssh/id_rsa",
  66. protocol=FTPProtocol.SFTP,
  67. ))
  68. await sftp.connect()
  69. await sftp.download("/data/model.onnx", "./models/model.onnx")
  70. await sftp.disconnect()
  71. ```
  72. Context-Manager:
  73. ```python
  74. async with FTPClient(config) as ftp:
  75. await ftp.download(remote, local)
  76. ```
  77. """
  78. def __init__(self, config: FTPConfig) -> None:
  79. self._config = config
  80. self._ftp: ftplib.FTP | None = None # FTP/FTPS
  81. self._sftp_conn = None # asyncssh Connection
  82. self._sftp_client = None # asyncssh SFTPClient
  83. self._connected = False
  84. @property
  85. def is_connected(self) -> bool:
  86. """Prueft ob eine Verbindung besteht."""
  87. return self._connected
  88. @property
  89. def protocol(self) -> FTPProtocol:
  90. """Verwendetes Protokoll."""
  91. return self._config.protocol
  92. # === Context Manager ===
  93. async def __aenter__(self) -> "FTPClient":
  94. await self.connect()
  95. return self
  96. async def __aexit__(self, *exc) -> None:
  97. await self.disconnect()
  98. # === Verbindung ===
  99. async def connect(self) -> bool:
  100. """
  101. Stellt die Verbindung her.
  102. Returns:
  103. True bei Erfolg
  104. """
  105. cfg = self._config
  106. if self._connected:
  107. pdebug("FTP: Bereits verbunden")
  108. return True
  109. try:
  110. if cfg.protocol == FTPProtocol.SFTP:
  111. return await self._connect_sftp()
  112. else:
  113. return await self._connect_ftp()
  114. except Exception as e:
  115. perror(f"FTP Verbindungsfehler: {cfg.host}:{cfg.effective_port} — {e}")
  116. return False
  117. async def _connect_ftp(self) -> bool:
  118. """Verbindet per FTP/FTPS."""
  119. cfg = self._config
  120. # FTP oder FTPS
  121. if cfg.protocol == FTPProtocol.FTPS:
  122. self._ftp = ftplib.FTP_TLS()
  123. else:
  124. self._ftp = ftplib.FTP()
  125. self._ftp.connect(
  126. cfg.host,
  127. cfg.effective_port,
  128. timeout=int(cfg.connect_timeout),
  129. )
  130. if cfg.username:
  131. self._ftp.login(cfg.username, cfg.password)
  132. else:
  133. self._ftp.login() # Anonymous
  134. # TLS-Verschluesselung aktivieren (FTPS)
  135. if cfg.protocol == FTPProtocol.FTPS and isinstance(self._ftp, ftplib.FTP_TLS):
  136. self._ftp.prot_p()
  137. self._connected = True
  138. pinfo(f"FTP verbunden: {cfg.host}:{cfg.effective_port} ({cfg.protocol.value})")
  139. return True
  140. async def _connect_sftp(self) -> bool:
  141. """Verbindet per SFTP (SSH)."""
  142. try:
  143. import asyncssh
  144. except ImportError:
  145. perror("asyncssh nicht installiert. Installieren mit: pip install asyncssh")
  146. return False
  147. cfg = self._config
  148. connect_kwargs: dict[str, Any] = {
  149. "host": cfg.host,
  150. "port": cfg.effective_port,
  151. "username": cfg.username or None,
  152. }
  153. # Authentifizierung
  154. if cfg.key_file:
  155. connect_kwargs["client_keys"] = [Path(cfg.key_file).expanduser()]
  156. elif cfg.password:
  157. connect_kwargs["password"] = cfg.password
  158. # Known Hosts
  159. if cfg.known_hosts:
  160. connect_kwargs["known_hosts"] = cfg.known_hosts
  161. else:
  162. connect_kwargs["known_hosts"] = None # Nicht pruefen
  163. self._sftp_conn = await asyncssh.connect(**connect_kwargs)
  164. self._sftp_client = await self._sftp_conn.start_sftp_client()
  165. self._connected = True
  166. pinfo(f"SFTP verbunden: {cfg.host}:{cfg.effective_port}")
  167. return True
  168. async def disconnect(self) -> None:
  169. """Trennt die Verbindung."""
  170. if not self._connected:
  171. return
  172. try:
  173. if self._config.protocol == FTPProtocol.SFTP:
  174. if self._sftp_client:
  175. self._sftp_client.exit()
  176. self._sftp_client = None
  177. if self._sftp_conn:
  178. self._sftp_conn.close()
  179. self._sftp_conn = None
  180. else:
  181. if self._ftp:
  182. try:
  183. self._ftp.quit()
  184. except Exception:
  185. self._ftp.close()
  186. self._ftp = None
  187. except Exception:
  188. pass
  189. self._connected = False
  190. pdebug(f"FTP getrennt: {self._config.host}")
  191. # === Datei-Operationen ===
  192. async def download(
  193. self,
  194. remote_path: str,
  195. local_path: str,
  196. progress_callback: "callable | None" = None,
  197. ) -> bool:
  198. """
  199. Laedt eine Datei herunter.
  200. Args:
  201. remote_path: Pfad auf dem Server
  202. local_path: Lokaler Zielpfad
  203. progress_callback: callback(downloaded_bytes, total_bytes)
  204. Returns:
  205. True bei Erfolg
  206. """
  207. if not self._connected:
  208. perror("FTP: Nicht verbunden")
  209. return False
  210. Path(local_path).parent.mkdir(parents=True, exist_ok=True)
  211. try:
  212. if self._config.protocol == FTPProtocol.SFTP:
  213. await self._sftp_client.get(remote_path, local_path)
  214. else:
  215. downloaded = 0
  216. total = self._ftp.size(remote_path) or 0
  217. with open(local_path, "wb") as f:
  218. def _write_chunk(data: bytes) -> None:
  219. nonlocal downloaded
  220. f.write(data)
  221. downloaded += len(data)
  222. if progress_callback:
  223. progress_callback(downloaded, total)
  224. self._ftp.retrbinary(f"RETR {remote_path}", _write_chunk)
  225. pdebug(f"FTP Download: {remote_path} -> {local_path}")
  226. return True
  227. except Exception as e:
  228. perror(f"FTP Download-Fehler: {remote_path} — {e}")
  229. return False
  230. async def upload(
  231. self,
  232. local_path: str,
  233. remote_path: str,
  234. progress_callback: "callable | None" = None,
  235. ) -> bool:
  236. """
  237. Laedt eine Datei hoch.
  238. Args:
  239. local_path: Lokaler Quellpfad
  240. remote_path: Pfad auf dem Server
  241. progress_callback: callback(uploaded_bytes, total_bytes)
  242. Returns:
  243. True bei Erfolg
  244. """
  245. if not self._connected:
  246. perror("FTP: Nicht verbunden")
  247. return False
  248. if not Path(local_path).is_file():
  249. perror(f"FTP Upload: Datei nicht gefunden: {local_path}")
  250. return False
  251. try:
  252. if self._config.protocol == FTPProtocol.SFTP:
  253. await self._sftp_client.put(local_path, remote_path)
  254. else:
  255. total = Path(local_path).stat().st_size
  256. uploaded = 0
  257. with open(local_path, "rb") as f:
  258. def _read_callback(data: bytes) -> None:
  259. nonlocal uploaded
  260. uploaded += len(data)
  261. if progress_callback:
  262. progress_callback(uploaded, total)
  263. self._ftp.storbinary(
  264. f"STOR {remote_path}", f,
  265. callback=_read_callback,
  266. )
  267. pdebug(f"FTP Upload: {local_path} -> {remote_path}")
  268. return True
  269. except Exception as e:
  270. perror(f"FTP Upload-Fehler: {remote_path} — {e}")
  271. return False
  272. # === Verzeichnis-Operationen ===
  273. async def list_dir(self, remote_path: str = ".") -> list[str]:
  274. """
  275. Listet den Inhalt eines Verzeichnisses.
  276. Returns:
  277. Liste der Datei-/Verzeichnisnamen
  278. """
  279. if not self._connected:
  280. return []
  281. try:
  282. if self._config.protocol == FTPProtocol.SFTP:
  283. entries = await self._sftp_client.listdir(remote_path)
  284. return sorted(entries)
  285. else:
  286. return sorted(self._ftp.nlst(remote_path))
  287. except Exception as e:
  288. perror(f"FTP Listing-Fehler: {remote_path} — {e}")
  289. return []
  290. async def file_exists(self, remote_path: str) -> bool:
  291. """Prueft ob eine Datei auf dem Server existiert."""
  292. if not self._connected:
  293. return False
  294. try:
  295. if self._config.protocol == FTPProtocol.SFTP:
  296. stat = await self._sftp_client.stat(remote_path)
  297. return stat is not None
  298. else:
  299. self._ftp.size(remote_path)
  300. return True
  301. except Exception:
  302. return False
  303. async def file_size(self, remote_path: str) -> int:
  304. """Gibt die Groesse einer Datei auf dem Server zurueck (Bytes)."""
  305. if not self._connected:
  306. return 0
  307. try:
  308. if self._config.protocol == FTPProtocol.SFTP:
  309. stat = await self._sftp_client.stat(remote_path)
  310. return stat.size if stat else 0
  311. else:
  312. return self._ftp.size(remote_path) or 0
  313. except Exception:
  314. return 0
  315. async def mkdir(self, remote_path: str) -> bool:
  316. """Erstellt ein Verzeichnis auf dem Server."""
  317. if not self._connected:
  318. return False
  319. try:
  320. if self._config.protocol == FTPProtocol.SFTP:
  321. await self._sftp_client.mkdir(remote_path)
  322. else:
  323. self._ftp.mkd(remote_path)
  324. return True
  325. except Exception as e:
  326. perror(f"FTP mkdir-Fehler: {remote_path} — {e}")
  327. return False
  328. async def remove(self, remote_path: str) -> bool:
  329. """Loescht eine Datei auf dem Server."""
  330. if not self._connected:
  331. return False
  332. try:
  333. if self._config.protocol == FTPProtocol.SFTP:
  334. await self._sftp_client.remove(remote_path)
  335. else:
  336. self._ftp.delete(remote_path)
  337. return True
  338. except Exception as e:
  339. perror(f"FTP Loeschen-Fehler: {remote_path} — {e}")
  340. return False