initial commit
This commit is contained in:
202
plugins/sftp.py
Normal file
202
plugins/sftp.py
Normal file
@@ -0,0 +1,202 @@
|
||||
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'},
|
||||
]
|
||||
Reference in New Issue
Block a user