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>
60 lines
2.0 KiB
Python
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)
|