Files
pyssh-client/app/sftp/worker.py
Florian fd848c2025 feat: rendre les operations SFTP asynchrones
RemoteFilePanel appelait sftp.listdir_attr/mkdir/rename/remove/chmod/get
directement depuis le thread GUI : un dossier distant lent ou une latence
reseau gelait toute l'interface. Seuls upload/download passaient deja par un
thread dedie (TransferWorker), et celui-ci reutilisait le meme SFTPClient que
la navigation - paramiko.SFTPClient n'est pas concu pour etre pilote par
plusieurs threads a la fois.

- app/sftp/worker.py : SftpWorker, un thread proprietaire exclusif d'un canal
  SFTP, avec une file de jobs (callables) et les resultats renvoyes par signal
  Qt (marshalling automatique vers le thread GUI).
- RemoteFilePanel soumet desormais toutes ses operations via ce worker avec
  callbacks succes/erreur ; un compteur de generation ignore les reponses de
  navigations devenues obsoletes (double-clic rapide, edition puis navigation
  ailleurs pendant le renvoi).
- TransferWorker ouvre son propre canal SFTP (`sftp_factory`) au lieu de
  partager celui de la navigation : un gros transfert ne bloque plus le
  listage de dossiers. SFTPPanel(sftp, transfer_sftp_factory=...) permet
  d'injecter ce second canal (paramiko.SFTPClient.from_transport) ; a defaut,
  le canal de navigation est reutilise tel quel (cas des tests).
- SessionTabWidget/SFTPTabWidget ouvrent ce second canal et appellent
  SFTPPanel.shutdown() a la fermeture de l'onglet (arrete proprement les
  threads avant destruction des widgets).

tests/test_sftp.py adapte pour pomper la boucle d'evenements Qt apres les
operations desormais asynchrones (meme couverture, timing different).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:42:46 +02:00

60 lines
2.0 KiB
Python

"""Execution asynchrone des operations SFTP.
paramiko.SFTPClient n'est pas concu pour etre pilote depuis plusieurs threads a
la fois : ce module fait en sorte qu'un seul thread possede jamais un client SFTP
donne. Toute operation (listage, mkdir, rename, remove, chmod, stat, ...) est
soumise a la file de ce thread sous forme de callable ; le resultat revient sur
le thread appelant (GUI) via un signal Qt, qui les mets automatiquement en file
d'attente quand emetteur et recepteur vivent dans des threads differents.
"""
from __future__ import annotations
import queue
import threading
from dataclasses import dataclass
from typing import Any, Callable
from PySide6.QtCore import QThread, Signal
@dataclass
class _Job:
request_id: int
fn: Callable[[], Any]
class SftpWorker(QThread):
"""Un worker = un canal SFTP, jamais partage avec un autre thread."""
result_ready = Signal(int, object, object) # request_id, resultat, exception|None
def __init__(self, parent=None):
super().__init__(parent)
self._queue: queue.Queue[_Job | None] = queue.Queue()
self._next_id = 0
self._lock = threading.Lock()
def submit(self, fn: Callable[[], Any]) -> int:
"""Met `fn` en file pour execution sur le thread du worker. Renvoie un
identifiant a rapprocher du signal `result_ready`."""
with self._lock:
self._next_id += 1
request_id = self._next_id
self._queue.put(_Job(request_id, fn))
return request_id
def run(self) -> None:
while True:
job = self._queue.get()
if job is None: # sentinelle posee par stop()
break
try:
result = job.fn()
self.result_ready.emit(job.request_id, result, None)
except Exception as exc: # noqa: BLE001 - remonte a l'appelant via le signal
self.result_ready.emit(job.request_id, None, exc)
def stop(self) -> None:
"""Arrete le worker une fois les jobs deja en file executes."""
self._queue.put(None)