Files
pyssh-client/tests/test_sftp.py
Florian c39ef9c038 Qualite : pyproject.toml (ruff/pytest/pyright) + CI Gitea (lint/tests/build)
pyproject.toml devient le point d'entree unique de configuration des outils
(pytest.ini supprime, sa config y est deplacee). Les dependances restent dans
requirements.txt/requirements-dev.txt (deja utilises par setup.bat) plutot que
dupliquees ici : ce projet se distribue par PyInstaller, pas par "pip install".

ruff : configure puis passe sur tout app/ + tests/ (154 corrections
automatiques, purement mecaniques - tri des imports, imports multiples sur une
ligne separes, alias depreciees) suivies de 7 corrections manuelles ciblees :
  - app/ssh_connection.py : `raise ... from None` pour le refus explicite de
    passphrase (ce n'est pas un echec a diagnostiquer).
  - app/controllers/workspace_manager.py : zip() sans strict= documente en
    commentaire (deliberement tolerant a un workspace legerement perime).
  - tests/test_ai_redaction.py : nom de variable ambigu renomme.
  - E702 (construction compacte "widget = X(); widget.setY(...)" dans
    sftp_panel.py/tunnel_dialog.py) et E402 (quelques tests qui importent un
    symbole juste avant la section qui l'utilise) : ignores explicitement, ce
    sont des styles deliberes preexistants, pas des oublis.
`ruff check app main.py tests` est desormais propre.

pyright : mode "basic" (le code n'a jamais ete type). Plus de 200 signalements
sur ~300 etaient le meme faux positif connu de l'ecosysteme PySide6 (acces
plat aux enums Qt, ex. Qt.UserRole, valide a l'execution mais que les stubs ne
reconnaissent que sous leur forme imbriquee) : desactive avec justification en
commentaire. Reste un socle de 77 signalements reels (surtout des acces
Optional non filtres dans app/ui/tabs.py et app/ssh_connection.py) : non
corrige dans cette phase - resserrer le typage la ou pyright a raison est un
travail a part, pas une consequence automatique de l'ajout de l'outil. La CI
le lance en mode informatif (continue-on-error) tant que ce socle n'est pas
resorbe.

.gitea/workflows/ci.yml : lint (ruff, bloquant), tests (pytest, bloquant),
typecheck (pyright, informatif), build (PyInstaller, sur main uniquement,
artefact du dossier portable). Necessite un runner Gitea Actions Windows
enregistre - PySide6/pywinpty/Credential Manager/ACL NTFS sont Windows
uniquement, comme l'application elle-meme ; non verifiable depuis ce poste.

Suite complete revalidee apres coup (33/33) pour confirmer qu'aucune des 154
corrections automatiques n'a modifie de comportement.

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

189 lines
6.2 KiB
Python

"""Test de la logique SFTP avec un client SFTP simule (adosse a un dossier temporaire)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import os
import shutil
import stat as statmod
import tempfile
import time
from pathlib import Path
from PySide6.QtWidgets import QApplication
from app.sftp_panel import NAME_ROLE, PARENT_ROLE, SFTPPanel
class FakeAttr:
def __init__(self, path: Path):
st = path.stat()
self.filename = path.name
self.st_mode = st.st_mode
self.st_size = st.st_size
self.st_mtime = st.st_mtime
class FakeSFTP:
"""Implemente juste ce que le panneau utilise, adosse a un vrai dossier local."""
def __init__(self, root: Path):
self.root = root
def _real(self, path: str) -> Path:
return self.root / path.lstrip("/")
def normalize(self, path):
return "/"
def listdir_attr(self, path):
return [FakeAttr(p) for p in sorted(self._real(path).iterdir())]
def stat(self, path):
return FakeAttr(self._real(path))
def mkdir(self, path):
self._real(path).mkdir()
def rename(self, old, new):
self._real(old).rename(self._real(new))
def remove(self, path):
self._real(path).unlink()
def rmdir(self, path):
self._real(path).rmdir()
def chmod(self, path, mode):
self._real(path).chmod(mode)
def put(self, local, remote, callback=None):
shutil.copyfile(local, self._real(remote))
if callback:
callback(os.path.getsize(local), os.path.getsize(local))
def get(self, remote, local, callback=None):
shutil.copyfile(self._real(remote), local)
if callback:
callback(os.path.getsize(local), os.path.getsize(local))
app = QApplication([])
def wait_idle(panel_side, timeout=5.0):
"""Pompe la boucle d'evenements Qt jusqu'a ce que toutes les operations SFTP
soumises par ce panneau (navigate/refresh/etc, desormais asynchrones) soient
revenues."""
deadline = time.time() + timeout
app.processEvents()
while panel_side._callbacks and time.time() < deadline:
app.processEvents()
time.sleep(0.01)
app.processEvents()
remote_root = Path(tempfile.mkdtemp())
local_root = Path(tempfile.mkdtemp())
# contenu distant simule
(remote_root / "dossier_distant").mkdir()
(remote_root / "config.yml").write_text("cle: valeur\n", encoding="utf-8")
(remote_root / "gros_fichier.log").write_text("x" * 5000, encoding="utf-8")
# contenu local
(local_root / "a_envoyer.txt").write_text("contenu local\n", encoding="utf-8")
(local_root / "dossier_local").mkdir()
(local_root / "dossier_local" / "interne.txt").write_text("imbrique\n", encoding="utf-8")
panel = SFTPPanel(FakeSFTP(remote_root))
panel.local.navigate(str(local_root))
panel.resize(1000, 600)
wait_idle(panel.remote) # laisse le temps a la navigation initiale (asynchrone) d'arriver
print("=== 1. Listing distant avec colonnes ===")
tree = panel.remote.tree
for i in range(tree.topLevelItemCount()):
it = tree.topLevelItem(i)
print(f" {it.text(0):<28} | {it.text(1):>9} | {it.text(2):<17} | {it.text(3)}")
assert tree.topLevelItemCount() == 4, "attendu .. + 3 entrees"
print(" -> colonnes Nom/Taille/Modifie/Droits presentes:", all(tree.headerItem().text(c) for c in range(4)))
print()
print("=== 2. Envoi par glisser-deposer (local -> distant) ===")
payload = {"side": "local", "entries": [{"name": "a_envoyer.txt", "is_dir": False}]}
panel._on_transfer_payload(payload)
deadline = time.time() + 5
while time.time() < deadline and panel._worker and panel._worker.isRunning():
app.processEvents()
time.sleep(0.02)
app.processEvents()
sent = (remote_root / "a_envoyer.txt")
print(" fichier arrive sur le serveur:", sent.exists())
assert sent.exists()
print()
print("=== 3. Envoi recursif d'un dossier ===")
panel._on_transfer_payload({"side": "local", "entries": [{"name": "dossier_local", "is_dir": True}]})
deadline = time.time() + 5
while time.time() < deadline and panel._worker and panel._worker.isRunning():
app.processEvents()
time.sleep(0.02)
app.processEvents()
nested = remote_root / "dossier_local" / "interne.txt"
print(" dossier + fichier imbrique transferes:", nested.exists())
assert nested.exists()
print()
print("=== 4. Reception (distant -> local) ===")
panel._on_transfer_payload({"side": "remote", "entries": [{"name": "config.yml", "is_dir": False}]})
deadline = time.time() + 5
while time.time() < deadline and panel._worker and panel._worker.isRunning():
app.processEvents()
time.sleep(0.02)
app.processEvents()
got = local_root / "config.yml"
print(" fichier recupere en local:", got.exists(), "| contenu identique:",
got.read_text(encoding="utf-8") == "cle: valeur\n")
assert got.exists()
print()
print("=== 5. Renommage distant ===")
panel.remote.navigate("/")
wait_idle(panel.remote)
item = next(panel.remote.tree.topLevelItem(i) for i in range(panel.remote.tree.topLevelItemCount())
if panel.remote.tree.topLevelItem(i).data(0, NAME_ROLE) == "config.yml")
item.setSelected(True)
panel.remote.sftp.rename("/config.yml", "/config_renomme.yml")
panel.remote.refresh()
wait_idle(panel.remote)
names = [panel.remote.tree.topLevelItem(i).data(0, NAME_ROLE)
for i in range(panel.remote.tree.topLevelItemCount())]
print(" entrees apres renommage:", [n for n in names if n])
assert "config_renomme.yml" in names
print()
print("=== 6. Permissions (chmod) ===")
panel.remote.sftp.chmod("/config_renomme.yml", 0o600)
mode = statmod.S_IMODE((remote_root / "config_renomme.yml").stat().st_mode)
print(f" droits appliques: {mode:03o}")
print()
print("=== 7. Suppression recursive distante ===")
panel.remote._rmdir_recursive("/dossier_local")
panel.remote.refresh()
wait_idle(panel.remote)
print(" dossier supprime du serveur:", not (remote_root / "dossier_local").exists())
assert not (remote_root / "dossier_local").exists()
print()
print("=== 8. Entree '..' presente pour remonter ===")
has_parent = any(panel.remote.tree.topLevelItem(i).data(0, PARENT_ROLE)
for i in range(panel.remote.tree.topLevelItemCount()))
print(" ligne '..' presente:", has_parent)
assert has_parent
panel.shutdown()
print()
print("TOUS LES TESTS SFTP OK")