import os import stat import posixpath import paramiko from .base import AbstractFS class SFTPfs(AbstractFS): PLUGIN_NAME = "sftp" PLUGIN_LABEL = "SFTP" def __init__(self): self._client = None # paramiko SSHClient self._sftp = None # paramiko SFTPClient self._root = "/" self._connected = False def connect(self, config: dict): host = config['host'] port = int(config.get('port', 22)) username = config['username'] password = config.get('password') or None key_path = config.get('key_path') or None self._root = config.get('root_path', '/') self._client = paramiko.SSHClient() self._client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) connect_kwargs = dict(hostname=host, port=port, username=username, timeout=10) if key_path: connect_kwargs['key_filename'] = key_path if password: connect_kwargs['password'] = password self._client.connect(**connect_kwargs) self._sftp = self._client.open_sftp() self._connected = True def disconnect(self): try: if self._sftp: self._sftp.close() if self._client: self._client.close() except Exception: pass self._sftp = None self._client = None self._connected = False def is_connected(self) -> bool: if not self._connected or not self._sftp: return False try: self._sftp.stat('.') return True except Exception: self._connected = False return False def _reconnect_if_needed(self, config): if not self.is_connected(): self.connect(config) # ─── Navigation ────────────────────────────────────────────── def list(self, path: str) -> list: items = [] for attr in self._sftp.listdir_attr(path): is_dir = stat.S_ISDIR(attr.st_mode) items.append({ 'name': attr.filename, 'path': posixpath.join(path, attr.filename), 'is_dir': is_dir, 'size': attr.st_size if not is_dir else 0, 'mtime': attr.st_mtime, }) items.sort(key=lambda e: (not e['is_dir'], e['name'].lower())) return items def isdir(self, path: str) -> bool: try: return stat.S_ISDIR(self._sftp.stat(path).st_mode) except Exception: return False def exists(self, path: str) -> bool: try: self._sftp.stat(path) return True except Exception: return False def getsize(self, path: str) -> int: return self._sftp.stat(path).st_size def join(self, *parts) -> str: return posixpath.join(*parts) def basename(self, path: str) -> str: return posixpath.basename(path) def dirname(self, path: str) -> str: return posixpath.dirname(path) def relpath(self, path: str, base: str) -> str: # posixpath n'a pas relpath, on le simule if path.startswith(base): rel = path[len(base):] return rel.lstrip('/') return path # ─── Opérations ────────────────────────────────────────────── def mkdir(self, path: str): """Crée le dossier et tous les parents manquants.""" parts = path.split('/') current = '' for part in parts: if not part: current = '/' continue current = posixpath.join(current, part) try: self._sftp.stat(current) except IOError: self._sftp.mkdir(current) def rename(self, old_path: str, new_path: str): self._sftp.rename(old_path, new_path) def remove(self, path: str): if self.isdir(path): for attr in self._sftp.listdir_attr(path): child = posixpath.join(path, attr.filename) if stat.S_ISDIR(attr.st_mode): self.remove(child) else: self._sftp.remove(child) self._sftp.rmdir(path) else: self._sftp.remove(path) def walk(self, path: str): """os.walk équivalent pour SFTP.""" dirs = [] files = [] for attr in self._sftp.listdir_attr(path): if stat.S_ISDIR(attr.st_mode): dirs.append(attr.filename) else: files.append(attr.filename) yield path, dirs, files for d in dirs: yield from self.walk(posixpath.join(path, d)) # ─── Transfert ─────────────────────────────────────────────── def read_chunks(self, path: str, chunk_size: int = 4 * 1024 * 1024): # Limiter le pipelining Paramiko pour contrôler la RAM import paramiko old_max = paramiko.sftp_file.SFTPFile.MAX_REQUEST_SIZE paramiko.sftp_file.SFTPFile.MAX_REQUEST_SIZE = 32768 # 32KB natif SFTP import gc SFTP_BLOCK = 32768 try: with self._sftp.open(path, 'rb') as f: accumulated = bytearray() while True: block = f.read(SFTP_BLOCK) if not block: break accumulated += block if len(accumulated) >= chunk_size: data = bytes(accumulated) accumulated = bytearray() gc.collect() # forcer Python à rendre la mémoire yield data if accumulated: yield bytes(accumulated) finally: paramiko.sftp_file.SFTPFile.MAX_REQUEST_SIZE = old_max def write_chunks(self, path: str, chunks): self.mkdir(posixpath.dirname(path)) with self._sftp.open(path, 'wb') as f: for chunk in chunks: f.write(chunk) # ─── Config ────────────────────────────────────────────────── @classmethod def get_config_fields(cls) -> list: return [ {'name': 'host', 'label': 'Hôte', 'type': 'text', 'required': True, 'placeholder': 'sftp.exemple.com'}, {'name': 'port', 'label': 'Port', 'type': 'number', 'required': False, 'default': 22}, {'name': 'username', 'label': 'Utilisateur', 'type': 'text', 'required': True, 'placeholder': 'user'}, {'name': 'password', 'label': 'Mot de passe', 'type': 'password', 'required': False, 'placeholder': 'Laisser vide si clé SSH'}, {'name': 'key_path', 'label': 'Clé SSH (chemin)', 'type': 'text', 'required': False, 'placeholder': '/root/.ssh/id_rsa'}, {'name': 'root_path', 'label': 'Dossier racine', 'type': 'text', 'required': False, 'default': '/', 'placeholder': '/home/user'}, ]