Files
Seedmover/plugins/sftp.py
2026-03-23 22:24:24 +01:00

202 lines
7.3 KiB
Python

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'},
]