From c39ef9c03881366b572be4ff6ed8b67e6061d4fb Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 5 Sep 2026 07:46:50 +0200 Subject: [PATCH] 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 --- .gitea/workflows/ci.yml | 76 ++++++++++++++++++++++++ app/ai/service.py | 2 +- app/ai/settings.py | 2 +- app/bastion.py | 15 +++-- app/controllers/broadcast_manager.py | 2 +- app/controllers/connection_controller.py | 22 +++---- app/controllers/session_controller.py | 29 ++++++--- app/controllers/workspace_manager.py | 22 +++++-- app/dialogs/ai_command_dialog.py | 8 ++- app/dialogs/ai_prompts.py | 2 +- app/dialogs/ai_settings_dialog.py | 14 ++++- app/dialogs/group_settings_dialog.py | 33 +++++++--- app/dialogs/key_dialog.py | 23 +++++-- app/dialogs/known_hosts_dialog.py | 11 +++- app/dialogs/quick_connect_dialog.py | 15 +++-- app/dialogs/session_dialog.py | 44 ++++++++++---- app/dialogs/snippet_dialog.py | 15 ++++- app/dialogs/tunnel_dialog.py | 15 ++++- app/dialogs/workspace_dialog.py | 14 ++++- app/groups.py | 6 +- app/key_manager.py | 8 +-- app/main_window.py | 40 +++++++------ app/openssh/config.py | 4 +- app/session_manager.py | 6 +- app/sftp/worker.py | 3 +- app/sftp_panel.py | 28 ++++++--- app/snippets.py | 4 +- app/ssh_connection.py | 11 ++-- app/storage/json_store.py | 2 +- app/terminal_widget.py | 9 ++- app/theme.py | 4 +- app/tunnels.py | 2 +- app/ui/ai_panel.py | 13 +++- app/ui/split_pane.py | 9 ++- app/ui/tabs.py | 46 ++++++++------ app/workspace.py | 8 +-- pyproject.toml | 61 +++++++++++++++++++ pytest.ini | 3 - requirements-dev.txt | 2 + tests/test_ai_command_dialog.py | 4 +- tests/test_ai_danger.py | 6 +- tests/test_ai_panel.py | 10 ++-- tests/test_ai_prompts.py | 12 +++- tests/test_ai_provider.py | 8 ++- tests/test_ai_redaction.py | 8 ++- tests/test_ai_service.py | 14 ++++- tests/test_ai_settings.py | 22 ++++--- tests/test_ai_tab_integration.py | 6 +- tests/test_atomic_storage.py | 8 ++- tests/test_auto_reconnect.py | 4 +- tests/test_bastion.py | 18 ++++-- tests/test_clipboard.py | 10 ++-- tests/test_credentials.py | 24 +++++--- tests/test_detect.py | 4 +- tests/test_full.py | 5 +- tests/test_fuzzy.py | 4 +- tests/test_keepalive.py | 4 +- tests/test_key_protection.py | 6 +- tests/test_keys.py | 10 +++- tests/test_no_secrets_in_errors.py | 8 ++- tests/test_openssh_config.py | 4 +- tests/test_quick_connect.py | 21 ++++--- tests/test_reconnect_backoff.py | 4 +- tests/test_session_dialog_bastion.py | 20 +++++-- tests/test_sftp.py | 8 +-- tests/test_snippets.py | 10 ++-- tests/test_socks_tunnel.py | 6 +- tests/test_split_terminal.py | 15 ++--- tests/test_tags_and_groups.py | 12 ++-- tests/test_terminal_comfort.py | 8 ++- tests/test_tunnels.py | 6 +- tests/test_workspace.py | 23 ++++--- 72 files changed, 697 insertions(+), 268 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 pyproject.toml delete mode 100644 pytest.ini diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..469b854 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +# Runner Windows requis : PySide6/pywinpty/Windows Credential Manager/ACL NTFS +# ne fonctionnent que sous Windows, comme l'application elle-meme. +on: + push: + branches: ["**"] + pull_request: + +jobs: + lint: + runs-on: windows + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Installer les dependances de developpement + run: pip install -r requirements-dev.txt + - name: ruff check + run: ruff check app main.py tests + + tests: + runs-on: windows + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Installer les dependances + run: pip install -r requirements-dev.txt + - name: pytest (rendu Qt hors-ecran) + env: + QT_QPA_PLATFORM: offscreen + PYTHONIOENCODING: utf-8 + PYTHONUTF8: "1" + run: pytest tests -v + + typecheck: + runs-on: windows + # Informatif pour l'instant : le code n'a jamais ete type avant cette phase, + # 77 signalements (Optional non filtre, principalement dans app/ui/tabs.py + # et app/ssh_connection.py) restent a trier au cas par cas. A rendre + # bloquant une fois ce fond resorbe - voir le commentaire dans pyproject.toml. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Installer les dependances + run: pip install -r requirements-dev.txt + - name: pyright + run: pyright + + build: + runs-on: windows + needs: [lint, tests] + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Installer les dependances et PyInstaller + run: | + pip install -r requirements.txt + pip install pyinstaller + - name: Generer l'icone + run: python tools/make_icon.py + - name: Construire l'executable portable + run: python -m PyInstaller --noconfirm --clean PySSHClient.spec + - uses: actions/upload-artifact@v4 + with: + name: PySSH-Client-portable + path: dist/PySSH Client diff --git a/app/ai/service.py b/app/ai/service.py index 75babce..d4dcd7d 100644 --- a/app/ai/service.py +++ b/app/ai/service.py @@ -4,7 +4,7 @@ from __future__ import annotations from PySide6.QtCore import QThread, Signal -from app.ai.provider import AIProvider, AIError +from app.ai.provider import AIError, AIProvider KIND_EXPLAIN = "explain" KIND_DIAGNOSE = "diagnose" diff --git a/app/ai/settings.py b/app/ai/settings.py index 12b773e..9b654ca 100644 --- a/app/ai/settings.py +++ b/app/ai/settings.py @@ -8,9 +8,9 @@ from __future__ import annotations from dataclasses import dataclass +from app.ai.provider import AIProvider, OpenAICompatibleProvider from app.app_settings import get_setting, set_setting from app.credentials import CredentialStore -from app.ai.provider import AIProvider, OpenAICompatibleProvider AI_API_KEY_CREDENTIAL = "ai:api_key" diff --git a/app/bastion.py b/app/bastion.py index bc4d81b..f79032b 100644 --- a/app/bastion.py +++ b/app/bastion.py @@ -9,12 +9,17 @@ from __future__ import annotations from dataclasses import dataclass -from app.session_manager import ( - SessionStore, BastionConfig, - BASTION_NONE, BASTION_SESSION, BASTION_OPENSSH, BASTION_MANUAL, - AUTH_AUTO, AUTH_KEY, -) from app.credentials import session_credential_key +from app.session_manager import ( + AUTH_AUTO, + AUTH_KEY, + BASTION_MANUAL, + BASTION_NONE, + BASTION_OPENSSH, + BASTION_SESSION, + BastionConfig, + SessionStore, +) class BastionResolutionError(Exception): diff --git a/app/controllers/broadcast_manager.py b/app/controllers/broadcast_manager.py index 1830626..2a16b9e 100644 --- a/app/controllers/broadcast_manager.py +++ b/app/controllers/broadcast_manager.py @@ -2,7 +2,7 @@ aussi vers tous les autres onglets terminal ouverts, quand le mode est actif.""" from __future__ import annotations -from typing import Callable, Iterable +from collections.abc import Callable, Iterable from PySide6.QtCore import QObject from PySide6.QtWidgets import QToolButton, QWidget diff --git a/app/controllers/connection_controller.py b/app/controllers/connection_controller.py index e83399e..d672ef8 100644 --- a/app/controllers/connection_controller.py +++ b/app/controllers/connection_controller.py @@ -2,21 +2,21 @@ ouverture, connexion rapide, renommage, fermeture.""" from __future__ import annotations -from PySide6.QtCore import Qt, Signal, QObject -from PySide6.QtWidgets import QTabWidget, QWidget, QMenu, QMessageBox, QInputDialog +from PySide6.QtCore import QObject, Qt, Signal +from PySide6.QtWidgets import QInputDialog, QMenu, QMessageBox, QTabWidget, QWidget -from app.session_manager import SessionStore, Session, AUTH_PASSWORD, PASSWORD_STORAGE_SECURE +from app.bastion import BastionResolutionError, BastionTarget, resolve_bastion_target +from app.controllers.broadcast_manager import BroadcastManager +from app.credentials import CredentialStore, NullCredentialStore, session_credential_key +from app.dialogs.connect_prompt import ask_secret +from app.dialogs.session_dialog import SessionDialog from app.key_manager import KeyManager from app.local_terminal import DEFAULT_SHELL, SHELL_LABELS -from app.dialogs.session_dialog import SessionDialog -from app.dialogs.connect_prompt import ask_secret -from app.services.ssh_target import detect_ssh_target from app.openssh.config import OpenSSHHost, session_from_openssh_host -from app.bastion import BastionTarget, resolve_bastion_target, BastionResolutionError -from app.credentials import CredentialStore, NullCredentialStore, session_credential_key -from app.controllers.broadcast_manager import BroadcastManager -from app.ui.tabs import BaseTerminalTab, SessionTabWidget, SFTPTabWidget, LocalTerminalTabWidget -from app.ui.split_pane import SplitContainer, PaneSlot, LAYOUT_LABELS +from app.services.ssh_target import detect_ssh_target +from app.session_manager import AUTH_PASSWORD, PASSWORD_STORAGE_SECURE, Session, SessionStore +from app.ui.split_pane import LAYOUT_LABELS, PaneSlot, SplitContainer +from app.ui.tabs import BaseTerminalTab, LocalTerminalTabWidget, SessionTabWidget, SFTPTabWidget class ConnectionController(QObject): diff --git a/app/controllers/session_controller.py b/app/controllers/session_controller.py index 4289fe4..7cffa0c 100644 --- a/app/controllers/session_controller.py +++ b/app/controllers/session_controller.py @@ -6,22 +6,33 @@ from __future__ import annotations import copy import uuid -from PySide6.QtCore import Qt, Signal, QObject +from PySide6.QtCore import QObject, Qt, Signal from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QTreeWidget, QTreeWidgetItem, - QMenu, QMessageBox, QComboBox, QLabel, + QComboBox, + QHBoxLayout, + QLabel, + QMenu, + QMessageBox, + QToolButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, ) -from app.session_manager import SessionStore, Session -from app.key_manager import KeyManager -from app.groups import GroupSettingsStore from app.credentials import CredentialStore, NullCredentialStore, session_credential_key -from app.local_terminal import SHELL_LABELS -from app.dialogs.session_dialog import SessionDialog from app.dialogs.group_settings_dialog import GroupSettingsDialog +from app.dialogs.session_dialog import SessionDialog +from app.groups import GroupSettingsStore +from app.key_manager import KeyManager +from app.local_terminal import SHELL_LABELS from app.openssh.config import ( - OpenSSHHost, default_config_path, load_openssh_hosts, session_from_openssh_host, + OpenSSHHost, + default_config_path, + load_openssh_hosts, + session_from_openssh_host, ) +from app.session_manager import Session, SessionStore SESSION_ROLE = Qt.UserRole + 10 LOCAL_SHELL_ROLE = Qt.UserRole + 11 diff --git a/app/controllers/workspace_manager.py b/app/controllers/workspace_manager.py index 6aed671..fdcc3a8 100644 --- a/app/controllers/workspace_manager.py +++ b/app/controllers/workspace_manager.py @@ -7,15 +7,20 @@ from PySide6.QtCore import QObject, Signal from PySide6.QtWidgets import QWidget from app.app_settings import get_setting, set_setting +from app.controllers.connection_controller import ConnectionController from app.local_terminal import DEFAULT_SHELL from app.session_manager import SessionStore +from app.ui.split_pane import LAYOUT_SINGLE, SplitContainer +from app.ui.tabs import LocalTerminalTabWidget, SessionTabWidget, SFTPTabWidget from app.workspace import ( - Workspace, WorkspaceStore, WorkspaceTab, WorkspaceItem, - ITEM_SESSION_SSH, ITEM_SESSION_SFTP, ITEM_LOCAL_SHELL, + ITEM_LOCAL_SHELL, + ITEM_SESSION_SFTP, + ITEM_SESSION_SSH, + Workspace, + WorkspaceItem, + WorkspaceStore, + WorkspaceTab, ) -from app.ui.tabs import SessionTabWidget, SFTPTabWidget, LocalTerminalTabWidget -from app.ui.split_pane import SplitContainer, LAYOUT_SINGLE -from app.controllers.connection_controller import ConnectionController SETTING_RESTORE_ON_STARTUP = "restore_last_workspace" SETTING_LAST_WORKSPACE_ID = "last_workspace_id" @@ -152,7 +157,12 @@ class WorkspaceManager(QObject): self.cc.split_tab(index, wtab.layout) container = self.cc.tabs.widget(index) skipped = 0 - for pane, item in zip(container.panes[1:], wtab.items[1:]): + # strict=True est volontairement evite : un workspace legerement perime + # (enregistre par une version anterieure, ou modifie a la main) peut avoir + # une liste d'items plus courte que le nombre de panneaux - on restaure + # alors ce qui correspond et on laisse les panneaux excedentaires a leur + # terminal local par defaut, plutot que d'abandonner toute la restauration. + for pane, item in zip(container.panes[1:], wtab.items[1:]): # noqa: B905 widget = self._widget_for_item(item) if widget is None: skipped += 1 diff --git a/app/dialogs/ai_command_dialog.py b/app/dialogs/ai_command_dialog.py index 4a0fbac..7ef5043 100644 --- a/app/dialogs/ai_command_dialog.py +++ b/app/dialogs/ai_command_dialog.py @@ -6,7 +6,13 @@ from __future__ import annotations import re from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QTextEdit, QPushButton, QLabel, QApplication, + QApplication, + QDialog, + QHBoxLayout, + QLabel, + QPushButton, + QTextEdit, + QVBoxLayout, ) _CODE_BLOCK_RE = re.compile(r"```(?:[a-zA-Z0-9_+-]*\n)?(.*?)```", re.DOTALL) diff --git a/app/dialogs/ai_prompts.py b/app/dialogs/ai_prompts.py index e0b5caf..a87a443 100644 --- a/app/dialogs/ai_prompts.py +++ b/app/dialogs/ai_prompts.py @@ -6,8 +6,8 @@ from __future__ import annotations from PySide6.QtWidgets import QMessageBox -from app.app_settings import get_setting, set_setting from app.ai.redaction import detect_secrets +from app.app_settings import get_setting, set_setting _NOTICE_ACK_KEY = "ai_notice_acknowledged" diff --git a/app/dialogs/ai_settings_dialog.py b/app/dialogs/ai_settings_dialog.py index 13474cf..a65d13f 100644 --- a/app/dialogs/ai_settings_dialog.py +++ b/app/dialogs/ai_settings_dialog.py @@ -4,13 +4,21 @@ compatibles avec l'API OpenAI (OpenAI, OpenRouter, proxy interne, LLM local...). from __future__ import annotations from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QFormLayout, QLineEdit, QCheckBox, QDoubleSpinBox, - QPushButton, QDialogButtonBox, QMessageBox, QLabel, + QCheckBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QVBoxLayout, ) -from app.ai.settings import AISettings, load_ai_settings, save_ai_settings, load_api_key, save_api_key from app.ai.provider import OpenAICompatibleProvider from app.ai.service import KIND_TEST, AIRequestWorker +from app.ai.settings import AISettings, load_ai_settings, load_api_key, save_ai_settings, save_api_key from app.credentials import CredentialStore, NullCredentialStore diff --git a/app/dialogs/group_settings_dialog.py b/app/dialogs/group_settings_dialog.py index 693599e..1155620 100644 --- a/app/dialogs/group_settings_dialog.py +++ b/app/dialogs/group_settings_dialog.py @@ -3,17 +3,36 @@ d'une nouvelle session dans ce dossier (voir app.groups).""" from __future__ import annotations from PySide6.QtWidgets import ( - QDialog, QFormLayout, QLineEdit, QSpinBox, QComboBox, QDialogButtonBox, - QHBoxLayout, QPushButton, QWidget, QVBoxLayout, QCheckBox, QStackedWidget, QLabel, + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSpinBox, + QStackedWidget, + QVBoxLayout, + QWidget, ) -from app.session_manager import ( - SessionStore, BastionConfig, AUTH_AUTO, AUTH_KEY, AUTH_PASSWORD, AUTH_AGENT, - BASTION_NONE, BASTION_SESSION, BASTION_OPENSSH, BASTION_MANUAL, -) -from app.key_manager import KeyManager from app.groups import GroupSettings, GroupSettingsStore +from app.key_manager import KeyManager from app.openssh.config import load_openssh_hosts +from app.session_manager import ( + AUTH_AGENT, + AUTH_AUTO, + AUTH_KEY, + AUTH_PASSWORD, + BASTION_MANUAL, + BASTION_NONE, + BASTION_OPENSSH, + BASTION_SESSION, + BastionConfig, + SessionStore, +) class GroupSettingsDialog(QDialog): diff --git a/app/dialogs/key_dialog.py b/app/dialogs/key_dialog.py index 06af4c1..12a9625 100644 --- a/app/dialogs/key_dialog.py +++ b/app/dialogs/key_dialog.py @@ -5,13 +5,28 @@ from pathlib import Path from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, - QMessageBox, QFileDialog, QFormLayout, QLineEdit, QComboBox, QSpinBox, - QDialogButtonBox, QApplication, QLabel, + QApplication, + QComboBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMessageBox, + QPushButton, + QSpinBox, + QVBoxLayout, ) from app.key_manager import ( - KeyManager, KEY_TYPE_ED25519, KEY_TYPE_RSA, KEY_TYPE_LABELS, KEY_TYPE_DESCRIPTIONS, + KEY_TYPE_DESCRIPTIONS, + KEY_TYPE_LABELS, + KEY_TYPE_RSA, + KeyManager, ) diff --git a/app/dialogs/known_hosts_dialog.py b/app/dialogs/known_hosts_dialog.py index fd4b652..6db3b70 100644 --- a/app/dialogs/known_hosts_dialog.py +++ b/app/dialogs/known_hosts_dialog.py @@ -3,8 +3,15 @@ from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem, QPushButton, - QMessageBox, QLabel, QAbstractItemView, + QAbstractItemView, + QDialog, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, ) from app.known_hosts_manager import KnownHostsStore diff --git a/app/dialogs/quick_connect_dialog.py b/app/dialogs/quick_connect_dialog.py index e73af6d..2ffeb1a 100644 --- a/app/dialogs/quick_connect_dialog.py +++ b/app/dialogs/quick_connect_dialog.py @@ -2,12 +2,17 @@ snippets et actions, avec recherche floue et navigation entierement au clavier.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Callable -from PySide6.QtCore import Qt, QEvent +from PySide6.QtCore import QEvent, Qt from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, + QDialog, + QLineEdit, + QListWidget, + QListWidgetItem, + QMessageBox, + QVBoxLayout, ) from app.openssh.config import load_openssh_hosts @@ -111,9 +116,9 @@ class QuickConnectDialog(QDialog): return results def _quick_actions(self) -> list[tuple[str, Callable[[], None]]]: - from app.dialogs.workspace_dialog import WorkspaceDialog - from app.dialogs.snippet_dialog import SnippetManagerDialog from app.dialogs.key_dialog import KeyManagerDialog + from app.dialogs.snippet_dialog import SnippetManagerDialog + from app.dialogs.workspace_dialog import WorkspaceDialog return [ ("Nouvelle session...", self.session_controller.new_session), diff --git a/app/dialogs/session_dialog.py b/app/dialogs/session_dialog.py index 36aef25..a214940 100644 --- a/app/dialogs/session_dialog.py +++ b/app/dialogs/session_dialog.py @@ -3,20 +3,44 @@ from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QDialog, QFormLayout, QLineEdit, QSpinBox, QComboBox, QDialogButtonBox, - QHBoxLayout, QPushButton, QTextEdit, QFileDialog, QWidget, QVBoxLayout, - QCheckBox, QMessageBox, QGroupBox, QStackedWidget, QCompleter, + QCheckBox, + QComboBox, + QCompleter, + QDialog, + QDialogButtonBox, + QFileDialog, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLineEdit, + QMessageBox, + QPushButton, + QSpinBox, + QStackedWidget, + QTextEdit, + QVBoxLayout, + QWidget, ) -from app.session_manager import ( - Session, SessionStore, BastionConfig, AUTH_AUTO, AUTH_KEY, AUTH_PASSWORD, AUTH_AGENT, - BASTION_NONE, BASTION_SESSION, BASTION_OPENSSH, BASTION_MANUAL, - PASSWORD_STORAGE_NONE, PASSWORD_STORAGE_SECURE, -) +from app.credentials import CredentialStore, NullCredentialStore, session_credential_key +from app.groups import GroupSettingsStore from app.key_manager import KeyManager from app.openssh.config import load_openssh_hosts -from app.groups import GroupSettingsStore -from app.credentials import CredentialStore, NullCredentialStore, session_credential_key +from app.session_manager import ( + AUTH_AGENT, + AUTH_AUTO, + AUTH_KEY, + AUTH_PASSWORD, + BASTION_MANUAL, + BASTION_NONE, + BASTION_OPENSSH, + BASTION_SESSION, + PASSWORD_STORAGE_NONE, + PASSWORD_STORAGE_SECURE, + BastionConfig, + Session, + SessionStore, +) class SessionDialog(QDialog): diff --git a/app/dialogs/snippet_dialog.py b/app/dialogs/snippet_dialog.py index dbd3961..31014be 100644 --- a/app/dialogs/snippet_dialog.py +++ b/app/dialogs/snippet_dialog.py @@ -4,8 +4,19 @@ from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem, QPushButton, - QMessageBox, QLineEdit, QLabel, QFormLayout, QTextEdit, QDialogButtonBox, QComboBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QTextEdit, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, ) from app.snippets import Snippet, SnippetStore diff --git a/app/dialogs/tunnel_dialog.py b/app/dialogs/tunnel_dialog.py index e816555..8440179 100644 --- a/app/dialogs/tunnel_dialog.py +++ b/app/dialogs/tunnel_dialog.py @@ -3,8 +3,19 @@ from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QTreeWidget, QTreeWidgetItem, - QPushButton, QComboBox, QSpinBox, QLineEdit, QLabel, QMessageBox, QAbstractItemView, + QAbstractItemView, + QComboBox, + QDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QSpinBox, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, ) from app.tunnels import DIRECTION_DYNAMIC, DIRECTION_LABELS, TunnelManager, describe diff --git a/app/dialogs/workspace_dialog.py b/app/dialogs/workspace_dialog.py index 1cfd45b..c2fbc82 100644 --- a/app/dialogs/workspace_dialog.py +++ b/app/dialogs/workspace_dialog.py @@ -3,12 +3,20 @@ from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, - QMessageBox, QInputDialog, QLabel, QCheckBox, + QCheckBox, + QDialog, + QHBoxLayout, + QInputDialog, + QLabel, + QListWidget, + QListWidgetItem, + QMessageBox, + QPushButton, + QVBoxLayout, ) from app.app_settings import get_setting, set_setting -from app.controllers.workspace_manager import WorkspaceManager, SETTING_RESTORE_ON_STARTUP +from app.controllers.workspace_manager import SETTING_RESTORE_ON_STARTUP, WorkspaceManager class WorkspaceDialog(QDialog): diff --git a/app/groups.py b/app/groups.py index c00d154..64c8e3b 100644 --- a/app/groups.py +++ b/app/groups.py @@ -9,12 +9,12 @@ librement modifiables, sans indirection a suivre dans tout le reste du code (ConnectionController, SSHConnection, resolve_bastion_target...).""" from __future__ import annotations -from dataclasses import dataclass, field, asdict +from dataclasses import asdict, dataclass, field from pathlib import Path from app.paths import DATA_DIR +from app.session_manager import AUTH_AUTO, BastionConfig from app.storage.json_store import load_json_document, save_json_document -from app.session_manager import BastionConfig, AUTH_AUTO GROUPS_FILE = DATA_DIR / "groups.json" SCHEMA_VERSION = 1 @@ -36,7 +36,7 @@ class GroupSettings: return asdict(self) @staticmethod - def from_dict(data: dict) -> "GroupSettings": + def from_dict(data: dict) -> GroupSettings: g = GroupSettings() for k in GroupSettings.__dataclass_fields__: if k not in data: diff --git a/app/key_manager.py b/app/key_manager.py index 422f549..711c20a 100644 --- a/app/key_manager.py +++ b/app/key_manager.py @@ -5,16 +5,16 @@ import base64 import hashlib import shutil import uuid -from dataclasses import dataclass, field, asdict +from dataclasses import asdict, dataclass, field from pathlib import Path import paramiko from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa, ed25519, ec +from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe) -from app.storage.json_store import load_json_document, save_json_document from app.security.key_protection import protect_private_key +from app.storage.json_store import load_json_document, save_json_document KEYS_FILE = DATA_DIR / "keys.json" DEFAULT_KEY_DIR = DATA_DIR / "keys" @@ -116,7 +116,7 @@ class KeyEntry: return asdict(self) @staticmethod - def from_dict(data: dict) -> "KeyEntry": + def from_dict(data: dict) -> KeyEntry: e = KeyEntry() for k in KeyEntry.__dataclass_fields__: if k in data: diff --git a/app/main_window.py b/app/main_window.py index 50c99ca..1fc1821 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -9,33 +9,37 @@ from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtGui import QAction, QActionGroup, QKeySequence from PySide6.QtWidgets import ( - QMainWindow, QDockWidget, QMessageBox, QStatusBar, QApplication, + QApplication, + QDockWidget, + QMainWindow, + QMessageBox, + QStatusBar, ) -from app.session_manager import SessionStore -from app.key_manager import KeyManager -from app.local_terminal import SHELL_LABELS, DEFAULT_SHELL -from app.terminal_widget import TerminalWidget +from app.ai.settings import build_provider_from_settings +from app.controllers.broadcast_manager import BroadcastManager +from app.controllers.connection_controller import ConnectionController +from app.controllers.session_controller import SessionController +from app.controllers.workspace_manager import WorkspaceManager +from app.credentials import default_credential_store +from app.dialogs.ai_settings_dialog import AISettingsDialog from app.dialogs.key_dialog import KeyManagerDialog from app.dialogs.known_hosts_dialog import KnownHostsDialog -from app.dialogs.workspace_dialog import WorkspaceDialog -from app.dialogs.snippet_dialog import SnippetManagerDialog from app.dialogs.quick_connect_dialog import QuickConnectDialog -from app.dialogs.ai_settings_dialog import AISettingsDialog -from app.ai.settings import build_provider_from_settings -from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme -from app.workspace import WorkspaceStore -from app.snippets import SnippetStore +from app.dialogs.snippet_dialog import SnippetManagerDialog +from app.dialogs.workspace_dialog import WorkspaceDialog from app.groups import GroupSettingsStore -from app.credentials import default_credential_store -from app.controllers.broadcast_manager import BroadcastManager -from app.controllers.session_controller import SessionController -from app.controllers.connection_controller import ConnectionController -from app.controllers.workspace_manager import WorkspaceManager -from app.ui.split_pane import SplitContainer +from app.key_manager import KeyManager +from app.local_terminal import DEFAULT_SHELL, SHELL_LABELS # re-exporte pour compatibilite (utilise par tests/test_detect.py et du code existant) from app.services.ssh_target import detect_ssh_target # noqa: F401 +from app.session_manager import SessionStore +from app.snippets import SnippetStore +from app.terminal_widget import TerminalWidget +from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme +from app.ui.split_pane import SplitContainer +from app.workspace import WorkspaceStore class MainWindow(QMainWindow): diff --git a/app/openssh/config.py b/app/openssh/config.py index f4d370a..94bb456 100644 --- a/app/openssh/config.py +++ b/app/openssh/config.py @@ -14,7 +14,7 @@ from pathlib import Path import paramiko -from app.session_manager import Session, BastionConfig, AUTH_AUTO, AUTH_KEY, BASTION_OPENSSH +from app.session_manager import AUTH_AUTO, AUTH_KEY, BASTION_OPENSSH, BastionConfig, Session @dataclass @@ -57,7 +57,7 @@ def load_openssh_hosts(path: Path | None = None) -> list[OpenSSHHost]: parser = paramiko.SSHConfig() try: - with open(path, "r", encoding="utf-8", errors="replace") as f: + with open(path, encoding="utf-8", errors="replace") as f: parser.parse(f) except OSError: return [] diff --git a/app/session_manager.py b/app/session_manager.py index bb3d642..f156976 100644 --- a/app/session_manager.py +++ b/app/session_manager.py @@ -2,7 +2,7 @@ from __future__ import annotations import uuid -from dataclasses import dataclass, field, asdict +from dataclasses import asdict, dataclass, field from pathlib import Path from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe) @@ -50,7 +50,7 @@ class BastionConfig: return asdict(self) @staticmethod - def from_dict(data: dict) -> "BastionConfig": + def from_dict(data: dict) -> BastionConfig: b = BastionConfig() for k in BastionConfig.__dataclass_fields__: if k in data: @@ -95,7 +95,7 @@ class Session: return asdict(self) @staticmethod - def from_dict(data: dict) -> "Session": + def from_dict(data: dict) -> Session: known = {f: data.get(f) for f in Session.__dataclass_fields__ if f in data} s = Session() for k, v in known.items(): diff --git a/app/sftp/worker.py b/app/sftp/worker.py index c84ae1e..bf78a76 100644 --- a/app/sftp/worker.py +++ b/app/sftp/worker.py @@ -11,8 +11,9 @@ from __future__ import annotations import queue import threading +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable +from typing import Any from PySide6.QtCore import QThread, Signal diff --git a/app/sftp_panel.py b/app/sftp_panel.py index a77dc28..928eb70 100644 --- a/app/sftp_panel.py +++ b/app/sftp_panel.py @@ -5,18 +5,30 @@ from __future__ import annotations import json import os import posixpath -import tempfile import stat +import tempfile from datetime import datetime from pathlib import Path import paramiko -from PySide6.QtCore import Qt, QThread, Signal, QMimeData, QPoint, QFileSystemWatcher +from PySide6.QtCore import QFileSystemWatcher, QMimeData, QPoint, Qt, QThread, Signal from PySide6.QtGui import QDrag, QKeySequence, QShortcut from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QToolButton, QTreeWidget, - QTreeWidgetItem, QSplitter, QLabel, QMessageBox, QInputDialog, QProgressDialog, - QAbstractItemView, QMenu, QHeaderView, + QAbstractItemView, + QHBoxLayout, + QHeaderView, + QInputDialog, + QLabel, + QLineEdit, + QMenu, + QMessageBox, + QProgressDialog, + QSplitter, + QToolButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, ) from app.sftp.worker import SftpWorker @@ -125,7 +137,7 @@ class TransferWorker(QThread): def _upload_dir(self, local_dir: str, remote_dir: str) -> None: try: self.sftp.mkdir(remote_dir) - except IOError: + except OSError: pass for entry in os.listdir(local_dir): if self._cancel: @@ -615,7 +627,7 @@ class RemoteFilePanel(FilePanel): self._rmdir_recursive(path) else: self.sftp.remove(path) - except IOError as exc: + except OSError as exc: errors.append(f"{name} : {exc}") return errors @@ -745,7 +757,7 @@ class RemoteFilePanel(FilePanel): for entry_name, _ in entries: try: self.sftp.chmod(self.join(current_path, entry_name), mode) - except IOError as exc: + except OSError as exc: errors.append(f"{entry_name} : {exc}") return errors diff --git a/app/snippets.py b/app/snippets.py index 2f329dd..17507aa 100644 --- a/app/snippets.py +++ b/app/snippets.py @@ -3,7 +3,7 @@ depuis un terminal (inserer sans executer, executer, executer avec Multi-Send)." from __future__ import annotations import uuid -from dataclasses import dataclass, field, asdict +from dataclasses import asdict, dataclass, field from pathlib import Path from app.paths import DATA_DIR @@ -25,7 +25,7 @@ class Snippet: return asdict(self) @staticmethod - def from_dict(data: dict) -> "Snippet": + def from_dict(data: dict) -> Snippet: s = Snippet() for k in Snippet.__dataclass_fields__: if k in data: diff --git a/app/ssh_connection.py b/app/ssh_connection.py index 2cc05dd..9fb3790 100644 --- a/app/ssh_connection.py +++ b/app/ssh_connection.py @@ -7,17 +7,17 @@ import threading import paramiko from PySide6.QtCore import QThread, Signal -from app.known_hosts_manager import KnownHostsStore from app.bastion import BastionTarget - +from app.known_hosts_manager import KnownHostsStore from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe) + KNOWN_HOSTS_FILE = DATA_DIR / "known_hosts" class TrustPrompt(paramiko.MissingHostKeyPolicy): """Politique qui demande confirmation (via signal Qt) avant de faire confiance a une nouvelle cle d'hote.""" - def __init__(self, connection: "SSHConnection"): + def __init__(self, connection: SSHConnection): self.connection = connection def missing_host_key(self, client, hostname, key): @@ -379,7 +379,10 @@ class SSHConnection(QThread): except paramiko.PasswordRequiredException: pwd = passphrase or self._ask_passphrase(key_path) if not pwd: - raise paramiko.SSHException("Passphrase requise pour deverrouiller la cle") + # l'utilisateur a annule la demande de passphrase : ce n'est pas un + # echec a diagnostiquer, la PasswordRequiredException d'origine n'a + # rien a apporter au message affiche. + raise paramiko.SSHException("Passphrase requise pour deverrouiller la cle") from None try: return loader.from_private_key_file(key_path, password=pwd) except paramiko.SSHException as exc: diff --git a/app/storage/json_store.py b/app/storage/json_store.py index 43c6382..10ccded 100644 --- a/app/storage/json_store.py +++ b/app/storage/json_store.py @@ -13,8 +13,8 @@ plutot que de perdre les donnees de l'utilisateur. from __future__ import annotations import json +from collections.abc import Callable from pathlib import Path -from typing import Callable from app.storage.atomic_file import atomic_write_bytes diff --git a/app/terminal_widget.py b/app/terminal_widget.py index 229a7c5..1af23a9 100644 --- a/app/terminal_widget.py +++ b/app/terminal_widget.py @@ -11,9 +11,14 @@ from __future__ import annotations import pyte from PySide6.QtCore import QRect, Qt, Signal from PySide6.QtGui import ( - QColor, QFont, QFontMetrics, QPainter, QKeyEvent, QWheelEvent, + QColor, + QFont, + QFontMetrics, + QKeyEvent, + QPainter, + QWheelEvent, ) -from PySide6.QtWidgets import QWidget, QApplication, QScrollBar +from PySide6.QtWidgets import QApplication, QScrollBar, QWidget from app import theme as app_theme diff --git a/app/theme.py b/app/theme.py index 627a9e6..8e56aa1 100644 --- a/app/theme.py +++ b/app/theme.py @@ -9,11 +9,11 @@ from __future__ import annotations from string import Template from PySide6.QtCore import Qt -from PySide6.QtGui import QIcon, QPixmap, QPainter, QColor, QFont, QLinearGradient +from PySide6.QtGui import QColor, QFont, QIcon, QLinearGradient, QPainter, QPixmap from PySide6.QtWidgets import QApplication -from app.paths import RESOURCES_DIR # noqa: E402 (chemins valables aussi en .exe) from app.app_settings import get_setting, set_setting +from app.paths import RESOURCES_DIR # noqa: E402 (chemins valables aussi en .exe) STYLESHEET_PATH = RESOURCES_DIR / "theme.qss" diff --git a/app/tunnels.py b/app/tunnels.py index 7a34ce6..b77c8a1 100644 --- a/app/tunnels.py +++ b/app/tunnels.py @@ -60,7 +60,7 @@ def _relay(sock: socket.socket, chan: paramiko.Channel) -> None: if not data: break sock.sendall(data) - except (OSError, socket.error): + except OSError: pass finally: for closeable in (chan, sock): diff --git a/app/ui/ai_panel.py b/app/ui/ai_panel.py index 2e7a913..e8ad97e 100644 --- a/app/ui/ai_panel.py +++ b/app/ui/ai_panel.py @@ -10,12 +10,19 @@ from __future__ import annotations import html from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QPushButton, QLabel, QLineEdit, QApplication, + QApplication, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QTextEdit, + QVBoxLayout, + QWidget, ) -from app.ai.service import AIRequestWorker, KIND_ASK from app.ai.redaction import redact -from app.dialogs.ai_prompts import ensure_ai_notice_acknowledged, confirm_sensitive_content, MASK, CANCEL +from app.ai.service import KIND_ASK, AIRequestWorker +from app.dialogs.ai_prompts import CANCEL, MASK, confirm_sensitive_content, ensure_ai_notice_acknowledged _KIND_LABELS = { "explain": "Expliquer cette sortie", diff --git a/app/ui/split_pane.py b/app/ui/split_pane.py index 390db79..bab45c8 100644 --- a/app/ui/split_pane.py +++ b/app/ui/split_pane.py @@ -7,11 +7,16 @@ existant (SessionTabWidget, LocalTerminalTabWidget, ...) sans le modifier. """ from __future__ import annotations -from typing import Callable +from collections.abc import Callable from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QToolButton, QFrame, + QFrame, + QHBoxLayout, + QSplitter, + QToolButton, + QVBoxLayout, + QWidget, ) LAYOUT_SINGLE = "1" diff --git a/app/ui/tabs.py b/app/ui/tabs.py index a43998f..1ed7877 100644 --- a/app/ui/tabs.py +++ b/app/ui/tabs.py @@ -11,31 +11,41 @@ import re from datetime import datetime from pathlib import Path -from PySide6.QtCore import Qt, Signal, QTimer +from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMessageBox, QSplitter, - QFrame, QLineEdit, QFileDialog, QMenu, QInputDialog, + QFileDialog, + QFrame, + QHBoxLayout, + QInputDialog, + QLabel, + QLineEdit, + QMenu, + QMessageBox, + QSplitter, + QToolButton, + QVBoxLayout, + QWidget, ) -from app.session_manager import Session, AUTH_KEY, AUTH_PASSWORD -from app.ssh_connection import SSHConnection -from app.local_terminal import LocalTerminalConnection, SHELL_POWERSHELL, SHELL_LABELS -from app.terminal_widget import TerminalWidget -from app.sftp_panel import SFTPPanel -from app.dialogs.tunnel_dialog import TunnelDialog -from app.dialogs.snippet_dialog import SnippetManagerDialog -from app.tunnels import TunnelManager -from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_host_key_change -from app.services.ssh_target import detect_ssh_target -from app.services.reconnect import compute_backoff_delay -from app.snippets import Snippet, SnippetStore -from app.ai.service import KIND_EXPLAIN, KIND_DIAGNOSE, KIND_SOLUTION, KIND_GENERATE, AIRequestWorker from app.ai.danger import check_dangerous from app.ai.redaction import redact -from app.ui.ai_panel import AIPanel +from app.ai.service import KIND_DIAGNOSE, KIND_EXPLAIN, KIND_GENERATE, KIND_SOLUTION, AIRequestWorker from app.dialogs.ai_command_dialog import AICommandDialog -from app.dialogs.ai_prompts import ensure_ai_notice_acknowledged, confirm_sensitive_content, MASK, CANCEL +from app.dialogs.ai_prompts import CANCEL, MASK, confirm_sensitive_content, ensure_ai_notice_acknowledged +from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_host_key_change +from app.dialogs.snippet_dialog import SnippetManagerDialog +from app.dialogs.tunnel_dialog import TunnelDialog +from app.local_terminal import SHELL_LABELS, SHELL_POWERSHELL, LocalTerminalConnection +from app.services.reconnect import compute_backoff_delay +from app.services.ssh_target import detect_ssh_target +from app.session_manager import AUTH_KEY, AUTH_PASSWORD, Session +from app.sftp_panel import SFTPPanel +from app.snippets import Snippet, SnippetStore +from app.ssh_connection import SSHConnection +from app.terminal_widget import TerminalWidget +from app.tunnels import TunnelManager +from app.ui.ai_panel import AIPanel # sequences d'echappement ANSI, retirees des journaux pour qu'ils restent lisibles ANSI_ESCAPE_RE = re.compile( diff --git a/app/workspace.py b/app/workspace.py index 91eca07..1b170e7 100644 --- a/app/workspace.py +++ b/app/workspace.py @@ -7,7 +7,7 @@ mot de passe/passphrase exactement comme une ouverture normale).""" from __future__ import annotations import uuid -from dataclasses import dataclass, field, asdict +from dataclasses import asdict, dataclass, field from pathlib import Path from app.paths import DATA_DIR @@ -32,7 +32,7 @@ class WorkspaceItem: return asdict(self) @staticmethod - def from_dict(data: dict) -> "WorkspaceItem": + def from_dict(data: dict) -> WorkspaceItem: item = WorkspaceItem() for k in WorkspaceItem.__dataclass_fields__: if k in data: @@ -56,7 +56,7 @@ class WorkspaceTab: } @staticmethod - def from_dict(data: dict) -> "WorkspaceTab": + def from_dict(data: dict) -> WorkspaceTab: tab = WorkspaceTab(title=data.get("title", ""), layout=data.get("layout", "1")) tab.items = [WorkspaceItem.from_dict(i) for i in data.get("items", [])] tab.splitter_sizes = [list(s) for s in data.get("splitter_sizes", [])] @@ -73,7 +73,7 @@ class Workspace: return {"id": self.id, "name": self.name, "tabs": [t.to_dict() for t in self.tabs]} @staticmethod - def from_dict(data: dict) -> "Workspace": + def from_dict(data: dict) -> Workspace: ws = Workspace(id=data.get("id") or str(uuid.uuid4()), name=data.get("name", "")) ws.tabs = [WorkspaceTab.from_dict(t) for t in data.get("tabs", [])] return ws diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..66ff65f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[project] +name = "pyssh-client" +version = "0.1.0" +description = "Client SSH/SFTP local (PySide6) : terminaux, tunnels, snippets, workspaces, assistant IA optionnel" +readme = "README.md" +requires-python = ">=3.10" + +# Les dependances (runtime et dev) restent dans requirements.txt / +# requirements-dev.txt, deja utilises par setup.bat et la CI : les dupliquer +# ici cree un risque de desynchronisation pour aucun benefice reel, puisque +# le projet se distribue par PyInstaller (voir PySSHClient.spec), pas par +# "pip install". Ce fichier sert de point d'entree unique pour la +# configuration des outils (ruff, pytest, pyright). + +[tool.ruff] +line-length = 110 +target-version = "py310" +extend-exclude = ["venv", "build", "dist"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +ignore = [ + "E501", # ligne trop longue : laisse au formateur, pas bloquant en l'etat + "E702", # construction compacte "widget = X(); widget.setY(...)" deliberee + # dans les panneaux SFTP/tunnels (un widget + sa config tiennent sur + # une ligne) : reecrire l'existant pour ca serait pur churn. + "E402", # plusieurs tests importent un symbole juste avant la section qui + # l'utilise pour la premiere fois, plutot qu'en haut du fichier - + # lisible dans des scripts sequentiels longs, et sans risque tant + # que l'import n'a pas d'effet de bord ordonne. +] + +[tool.ruff.lint.isort] +known-first-party = ["app"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_all_scripts.py"] + +[tool.pyright] +include = ["app", "main.py"] +exclude = ["venv", "build", "dist", "tests"] +venvPath = "." +venv = "venv" +# "basic" plutot que "strict" : le code n'a jamais ete type jusqu'ici, un mode +# strict noierait les vrais signaux sous des milliers d'avertissements de +# typage progressif. A resserrer progressivement, fichier par fichier. +typeCheckingMode = "basic" +pythonVersion = "3.10" +reportMissingImports = true +# Desactive : plus de 200 occurrences sont l'acces "plat" aux enums Qt +# (Qt.UserRole, QToolButton.InstantPopup...) - un style PyQt5 historique que +# PySide6 continue de supporter a l'execution, mais que ses stubs ne +# reconnaissent qu'a travers leur forme imbriquee (Qt.ItemDataRole.UserRole). +# Utilise partout dans ce projet, sans exception, et sans le moindre risque +# reel : le desactiver evite de noyer les vrais signaux. Le reste (une +# vingtaine de cas ou pyright perd le fil sur des patterns dynamiques - le +# monkey-patching de pyte dans terminal_widget.py, les QWidget generiques de +# split_pane.py) reste dans le rapport, a regarder au cas par cas plutot que +# supprime en bloc. +reportAttributeAccessIssue = false diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index b9f1401..0000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -testpaths = tests -python_files = test_all_scripts.py diff --git a/requirements-dev.txt b/requirements-dev.txt index f9937f2..e870259 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,5 @@ -r requirements.txt pytest>=8.0 pytest-qt>=4.4 +ruff>=0.6 +pyright>=1.1 diff --git a/tests/test_ai_command_dialog.py b/tests/test_ai_command_dialog.py index 654a14e..159784a 100644 --- a/tests/test_ai_command_dialog.py +++ b/tests/test_ai_command_dialog.py @@ -1,7 +1,9 @@ """AICommandDialog : extraction de la commande depuis la reponse de l'IA (bloc de code s'il y en a un), et les 3 actions possibles (Copier / Inserer dans le terminal / Annuler) - jamais d'execution automatique.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) from PySide6.QtWidgets import QApplication diff --git a/tests/test_ai_danger.py b/tests/test_ai_danger.py index af465cb..6123ee8 100644 --- a/tests/test_ai_danger.py +++ b/tests/test_ai_danger.py @@ -1,9 +1,11 @@ """Detection locale de commandes potentiellement destructives proposees par l'assistant IA (jamais un blocage, seulement un avertissement).""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) -from app.ai.danger import is_dangerous, check_dangerous +from app.ai.danger import check_dangerous, is_dangerous print("=== 1. Commandes destructrices detectees ===") dangerous_examples = [ diff --git a/tests/test_ai_panel.py b/tests/test_ai_panel.py index 75d1ab1..2bac669 100644 --- a/tests/test_ai_panel.py +++ b/tests/test_ai_panel.py @@ -2,7 +2,9 @@ la verification de securite (avis + secrets) est bien invoquee avant tout envoi, que les reponses/erreurs mettent a jour la conversation, et qu'une reponse perimee (generation obsolete) est ignoree.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) from unittest.mock import patch @@ -10,9 +12,9 @@ from unittest.mock import patch from PySide6.QtCore import QCoreApplication from PySide6.QtWidgets import QApplication -from app.ai.provider import AIProvider, AIError -from app.ai.service import KIND_EXPLAIN, KIND_ASK -from app.dialogs.ai_prompts import MASK, SEND, CANCEL +from app.ai.provider import AIError, AIProvider +from app.ai.service import KIND_EXPLAIN +from app.dialogs.ai_prompts import CANCEL, MASK, SEND from app.ui.ai_panel import AIPanel app = QApplication.instance() or QApplication(sys.argv) diff --git a/tests/test_ai_prompts.py b/tests/test_ai_prompts.py index 93e83a1..3b65914 100644 --- a/tests/test_ai_prompts.py +++ b/tests/test_ai_prompts.py @@ -3,7 +3,9 @@ affiche une seule fois (persist dans app_settings, redirige vers un fichier temporaire), et verification des secrets a chaque envoi. QMessageBox est simule (exec()/clickedButton() patches) : aucune boite modale reelle ne doit bloquer les tests.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import tempfile @@ -13,7 +15,13 @@ from unittest.mock import patch from PySide6.QtWidgets import QApplication, QMessageBox import app.app_settings as app_settings -from app.dialogs.ai_prompts import ensure_ai_notice_acknowledged, confirm_sensitive_content, MASK, SEND, CANCEL +from app.dialogs.ai_prompts import ( + CANCEL, + MASK, + SEND, + confirm_sensitive_content, + ensure_ai_notice_acknowledged, +) app = QApplication.instance() or QApplication(sys.argv) diff --git a/tests/test_ai_provider.py b/tests/test_ai_provider.py index 26ba0d7..4e94677 100644 --- a/tests/test_ai_provider.py +++ b/tests/test_ai_provider.py @@ -1,15 +1,17 @@ """OpenAICompatibleProvider : requete HTTP construite correctement, et gestion des erreurs (401, 429, timeout, reseau, reponse invalide) - urllib.request.urlopen est entierement mocke, aucun appel reseau reel.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import io import json import urllib.error -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch -from app.ai.provider import AIProvider, AIError, OpenAICompatibleProvider +from app.ai.provider import AIError, AIProvider, OpenAICompatibleProvider def fake_response(payload: dict): diff --git a/tests/test_ai_redaction.py b/tests/test_ai_redaction.py index 7d66d81..e33cf65 100644 --- a/tests/test_ai_redaction.py +++ b/tests/test_ai_redaction.py @@ -1,5 +1,7 @@ """Detection/redaction de secrets avant envoi a l'assistant IA.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) from app.ai.redaction import detect_secrets, redact @@ -16,8 +18,8 @@ print("=== 2. Jeton Bearer detecte ===") found = detect_secrets("Authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz123456") labels = [f.label for f in found] print(" detecte:", labels) -assert any("bearer" in l.lower() for l in labels) -assert any("authorization" in l.lower() for l in labels) +assert any("bearer" in label.lower() for label in labels) +assert any("authorization" in label.lower() for label in labels) print(" OK") print() diff --git a/tests/test_ai_service.py b/tests/test_ai_service.py index a4fed32..2c0143a 100644 --- a/tests/test_ai_service.py +++ b/tests/test_ai_service.py @@ -1,7 +1,9 @@ """AIRequestWorker : execution d'une requete IA dans un vrai QThread en arriere-plan, jamais sur le thread Qt principal - avec dispatch correct selon le type de requete et remontee des erreurs via signal plutot qu'exception.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import threading @@ -9,9 +11,15 @@ import threading from PySide6.QtCore import QCoreApplication from PySide6.QtWidgets import QApplication -from app.ai.provider import AIProvider, AIError +from app.ai.provider import AIError, AIProvider from app.ai.service import ( - AIRequestWorker, KIND_EXPLAIN, KIND_DIAGNOSE, KIND_SOLUTION, KIND_GENERATE, KIND_ASK, KIND_TEST, + KIND_ASK, + KIND_DIAGNOSE, + KIND_EXPLAIN, + KIND_GENERATE, + KIND_SOLUTION, + KIND_TEST, + AIRequestWorker, ) app = QApplication.instance() or QApplication(sys.argv) diff --git a/tests/test_ai_settings.py b/tests/test_ai_settings.py index 91cf604..ba99e52 100644 --- a/tests/test_ai_settings.py +++ b/tests/test_ai_settings.py @@ -2,20 +2,28 @@ app_settings (redirige vers un fichier temporaire pour ne jamais toucher les vrais reglages), cle API exclusivement via CredentialStore, et construction du fournisseur a partir des deux.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import tempfile from pathlib import Path from unittest.mock import patch -from app.credentials import CredentialStore -from app.ai.settings import ( - AISettings, load_ai_settings, save_ai_settings, load_api_key, save_api_key, - build_provider_from_settings, AI_API_KEY_CREDENTIAL, DEFAULT_BASE_URL, -) -from app.ai.provider import OpenAICompatibleProvider import app.app_settings as app_settings +from app.ai.provider import OpenAICompatibleProvider +from app.ai.settings import ( + AI_API_KEY_CREDENTIAL, + DEFAULT_BASE_URL, + AISettings, + build_provider_from_settings, + load_ai_settings, + load_api_key, + save_ai_settings, + save_api_key, +) +from app.credentials import CredentialStore class FakeCredentialStore(CredentialStore): diff --git a/tests/test_ai_tab_integration.py b/tests/test_ai_tab_integration.py index ecff5ff..dee0b53 100644 --- a/tests/test_ai_tab_integration.py +++ b/tests/test_ai_tab_integration.py @@ -5,7 +5,9 @@ automatiquement, avec avertissement si dangereuse). LocalTerminalConnection.start() est neutralisee comme dans test_snippets.py : aucun sous-processus reel, send() depose seulement les octets dans une file.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) from unittest.mock import patch @@ -13,9 +15,9 @@ from unittest.mock import patch from PySide6.QtCore import QCoreApplication from PySide6.QtWidgets import QApplication -from app.local_terminal import LocalTerminalConnection from app.ai.provider import AIProvider from app.dialogs.ai_prompts import SEND +from app.local_terminal import LocalTerminalConnection from app.ui.ai_panel import AIPanel from app.ui.tabs import LocalTerminalTabWidget diff --git a/tests/test_atomic_storage.py b/tests/test_atomic_storage.py index abe5228..ac88c0e 100644 --- a/tests/test_atomic_storage.py +++ b/tests/test_atomic_storage.py @@ -1,16 +1,18 @@ """Stockage JSON : ecriture atomique, schema_version, migration, recuperation automatique depuis .bak quand le fichier principal est corrompu.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import json import tempfile from pathlib import Path +from app.key_manager import KeyManager, compute_fingerprint +from app.session_manager import Session, SessionStore from app.storage.atomic_file import atomic_write_text from app.storage.json_store import load_json_document, save_json_document -from app.session_manager import SessionStore, Session -from app.key_manager import KeyManager, compute_fingerprint tmp = Path(tempfile.mkdtemp()) diff --git a/tests/test_auto_reconnect.py b/tests/test_auto_reconnect.py index f92f138..ccb9ddc 100644 --- a/tests/test_auto_reconnect.py +++ b/tests/test_auto_reconnect.py @@ -9,7 +9,9 @@ de programmation du cote de l'onglet, qui n'a besoin d'aucune connexion reseau reelle - cela evite aussi qu'un thread reste bloque dans un connect() lent et ne bloque la fin du script. """ -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import time diff --git a/tests/test_bastion.py b/tests/test_bastion.py index 812573f..de6ab4a 100644 --- a/tests/test_bastion.py +++ b/tests/test_bastion.py @@ -1,7 +1,9 @@ """ProxyJump / Bastion : resolution (app.bastion) pour les 3 sources (session, OpenSSH, manuel), et etablissement du canal direct-tcpip par SSHConnection via un bastion entierement mocke (aucun reseau reel).""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import tempfile @@ -10,12 +12,18 @@ from unittest.mock import MagicMock, patch from PySide6.QtWidgets import QApplication +from app.bastion import BastionResolutionError, BastionTarget, resolve_bastion_target from app.session_manager import ( - SessionStore, Session, BastionConfig, - AUTH_AUTO, AUTH_KEY, - BASTION_NONE, BASTION_SESSION, BASTION_OPENSSH, BASTION_MANUAL, + AUTH_AUTO, + AUTH_KEY, + BASTION_MANUAL, + BASTION_NONE, + BASTION_OPENSSH, + BASTION_SESSION, + BastionConfig, + Session, + SessionStore, ) -from app.bastion import resolve_bastion_target, BastionResolutionError, BastionTarget app = QApplication.instance() or QApplication([]) diff --git a/tests/test_clipboard.py b/tests/test_clipboard.py index 6771d28..44c48f5 100644 --- a/tests/test_clipboard.py +++ b/tests/test_clipboard.py @@ -1,9 +1,11 @@ """Copier/coller du terminal : selection, presse-papiers, raccourcis.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) -from PySide6.QtWidgets import QApplication +from PySide6.QtCore import QEvent, QPoint, Qt from PySide6.QtGui import QKeyEvent -from PySide6.QtCore import QEvent, Qt, QPoint +from PySide6.QtWidgets import QApplication from app.terminal_widget import TerminalWidget @@ -90,8 +92,8 @@ print(" OK") print() print("=== 9. Souris : selection = copie, clic droit = collage (comme sous Unix) ===") -from PySide6.QtGui import QMouseEvent, QContextMenuEvent from PySide6.QtCore import QPointF +from PySide6.QtGui import QContextMenuEvent, QMouseEvent def clic(bouton, col, row, type_evt=QEvent.MouseButtonPress): diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 0ca84f5..97fcdab 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -4,7 +4,9 @@ passe (ConnectionController, bastion, suppression de session). SSHConnection.start() est neutralisee pour les tests d'integration qui construisent un onglet reel (aucune connexion reseau necessaire).""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import json @@ -12,18 +14,21 @@ import tempfile from pathlib import Path from unittest.mock import patch -from PySide6.QtWidgets import QApplication, QWidget, QMessageBox +from PySide6.QtWidgets import QApplication, QMessageBox, QWidget -from app.credentials import ( - CredentialStore, NullCredentialStore, WindowsCredentialStore, session_credential_key, - default_credential_store, -) -from app.session_manager import Session, SessionStore, AUTH_PASSWORD, PASSWORD_STORAGE_SECURE -from app.ssh_connection import SSHConnection +from app.bastion import BastionTarget from app.controllers.broadcast_manager import BroadcastManager from app.controllers.connection_controller import ConnectionController from app.controllers.session_controller import SessionController -from app.bastion import BastionTarget +from app.credentials import ( + CredentialStore, + NullCredentialStore, + WindowsCredentialStore, + default_credential_store, + session_credential_key, +) +from app.session_manager import AUTH_PASSWORD, PASSWORD_STORAGE_SECURE, Session, SessionStore +from app.ssh_connection import SSHConnection app = QApplication.instance() or QApplication([]) @@ -103,6 +108,7 @@ session_store = SessionStore(path=tmp / "sessions.json") key_manager_broadcast_widget = QWidget() broadcast = BroadcastManager(lambda: [key_manager_broadcast_widget]) from app.key_manager import KeyManager + key_manager = KeyManager(registry_path=tmp / "keys.json") fake_store = FakeCredentialStore() cc = ConnectionController(session_store, key_manager, broadcast, credential_store=fake_store, diff --git a/tests/test_detect.py b/tests/test_detect.py index 9c0b1e6..a4ce76e 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -1,4 +1,6 @@ -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) from app.main_window import detect_ssh_target diff --git a/tests/test_full.py b/tests/test_full.py index f7b3352..8416357 100644 --- a/tests/test_full.py +++ b/tests/test_full.py @@ -1,6 +1,9 @@ -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) from PySide6.QtWidgets import QApplication + from app.main_window import MainWindow from app.theme import apply_theme, build_app_icon diff --git a/tests/test_fuzzy.py b/tests/test_fuzzy.py index 73830a8..58c39c1 100644 --- a/tests/test_fuzzy.py +++ b/tests/test_fuzzy.py @@ -1,5 +1,7 @@ """Recherche floue (app.services.fuzzy) : sous-sequence, score, tri.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) from app.services.fuzzy import fuzzy_score diff --git a/tests/test_keepalive.py b/tests/test_keepalive.py index 7656c23..c53ac0a 100644 --- a/tests/test_keepalive.py +++ b/tests/test_keepalive.py @@ -1,7 +1,9 @@ """Keepalive de SSHConnection : verifie l'envoi periodique et l'abandon apres `keepalive_count_max` echecs consecutifs, avec un transport paramiko mocke (aucune connexion reseau reelle).""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import threading diff --git a/tests/test_key_protection.py b/tests/test_key_protection.py index f827035..032a0d4 100644 --- a/tests/test_key_protection.py +++ b/tests/test_key_protection.py @@ -2,7 +2,9 @@ (sans dependre des ACL reelles du systeme de fichiers, pour rester fiable quelle que soit la machine qui execute le test) et le bon aiguillage vers le repli chmod ailleurs que sous Windows.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import os @@ -11,7 +13,7 @@ import tempfile from pathlib import Path from unittest.mock import patch -from app.security.key_protection import protect_private_key, _protect_windows +from app.security.key_protection import _protect_windows, protect_private_key tmp = Path(tempfile.mkdtemp()) key_file = tmp / "id_test" diff --git a/tests/test_keys.py b/tests/test_keys.py index 72213a2..07aacd4 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -1,6 +1,8 @@ """Generation de cles SSH : tous les types proposes doivent produire une cle relisible par paramiko, donc utilisable pour se connecter.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import tempfile @@ -9,7 +11,11 @@ from pathlib import Path import paramiko from app.key_manager import ( - KeyManager, KEY_TYPE_LABELS, KEY_TYPE_RSA, KEY_TYPE_ED25519, ECDSA_CURVES, + ECDSA_CURVES, + KEY_TYPE_ED25519, + KEY_TYPE_LABELS, + KEY_TYPE_RSA, + KeyManager, ) tmp = Path(tempfile.mkdtemp()) diff --git a/tests/test_no_secrets_in_errors.py b/tests/test_no_secrets_in_errors.py index adf3882..8030984 100644 --- a/tests/test_no_secrets_in_errors.py +++ b/tests/test_no_secrets_in_errors.py @@ -4,14 +4,16 @@ sont les seuls endroits, en dehors des fichiers de stockage deja couverts par test_credentials.py/test_ai_settings.py, ou un secret pourrait fuiter sans etre ecrit intentionnellement (une boite de dialogue d'erreur, une trace affichee a l'utilisateur ou capturee dans un rapport de bug).""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import io import urllib.error -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch -from app.ai.provider import OpenAICompatibleProvider, AIError +from app.ai.provider import AIError, OpenAICompatibleProvider SECRET_API_KEY = "sk-tres-secret-ne-jamais-fuiter-1234567890" diff --git a/tests/test_openssh_config.py b/tests/test_openssh_config.py index c9ce106..534121f 100644 --- a/tests/test_openssh_config.py +++ b/tests/test_openssh_config.py @@ -1,5 +1,7 @@ """Lecture de ~/.ssh/config : hosts detectes, champs minimum, conversion en Session.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import tempfile diff --git a/tests/test_quick_connect.py b/tests/test_quick_connect.py index 9e96fee..1c4bfb9 100644 --- a/tests/test_quick_connect.py +++ b/tests/test_quick_connect.py @@ -5,29 +5,31 @@ activation. SSHConnection.start()/LocalTerminalConnection.start() sont neutralisees, et ~/.ssh/config est redirige vers un fichier absent (pas de dependance au poste qui execute le test).""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import tempfile from pathlib import Path from unittest.mock import patch -from PySide6.QtCore import Qt, QEvent +from PySide6.QtCore import QEvent, Qt from PySide6.QtGui import QKeyEvent from PySide6.QtWidgets import QApplication, QWidget -from app.session_manager import Session, SessionStore -from app.key_manager import KeyManager -from app.ssh_connection import SSHConnection -from app.local_terminal import LocalTerminalConnection +import app.app_settings as app_settings from app.controllers.broadcast_manager import BroadcastManager from app.controllers.connection_controller import ConnectionController from app.controllers.session_controller import SessionController from app.controllers.workspace_manager import WorkspaceManager -from app.workspace import WorkspaceStore -from app.snippets import Snippet, SnippetStore from app.dialogs.quick_connect_dialog import QuickConnectDialog -import app.app_settings as app_settings +from app.key_manager import KeyManager +from app.local_terminal import LocalTerminalConnection +from app.session_manager import Session, SessionStore +from app.snippets import Snippet, SnippetStore +from app.ssh_connection import SSHConnection +from app.workspace import WorkspaceStore app = QApplication.instance() or QApplication([]) @@ -118,6 +120,7 @@ dialog2._activate_current() print(f" onglets avant/apres activation: {before}/{cc2.tabs.count()}") assert cc2.tabs.count() == before + 1 from app.ui.tabs import SessionTabWidget + assert isinstance(cc2.tabs.widget(cc2.tabs.count() - 1), SessionTabWidget) print(" OK") diff --git a/tests/test_reconnect_backoff.py b/tests/test_reconnect_backoff.py index 70c8c28..d16a0f9 100644 --- a/tests/test_reconnect_backoff.py +++ b/tests/test_reconnect_backoff.py @@ -1,5 +1,7 @@ """Calcul du delai de reconnexion automatique (backoff exponentiel plafonne).""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) from app.services.reconnect import compute_backoff_delay diff --git a/tests/test_session_dialog_bastion.py b/tests/test_session_dialog_bastion.py index 0c8f10a..a0e424b 100644 --- a/tests/test_session_dialog_bastion.py +++ b/tests/test_session_dialog_bastion.py @@ -1,6 +1,8 @@ """Editeur de session : section Bastion / Jump Host (construction, pre-remplissage a l'edition, et resultat renvoye pour chacun des 4 modes).""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import tempfile @@ -9,12 +11,18 @@ from unittest.mock import patch from PySide6.QtWidgets import QApplication -from app.session_manager import ( - SessionStore, Session, BastionConfig, - BASTION_NONE, BASTION_SESSION, BASTION_OPENSSH, BASTION_MANUAL, AUTH_KEY, -) -from app.key_manager import KeyManager from app.dialogs.session_dialog import SessionDialog +from app.key_manager import KeyManager +from app.session_manager import ( + AUTH_KEY, + BASTION_MANUAL, + BASTION_NONE, + BASTION_OPENSSH, + BASTION_SESSION, + BastionConfig, + Session, + SessionStore, +) app = QApplication.instance() or QApplication([]) diff --git a/tests/test_sftp.py b/tests/test_sftp.py index 16032ac..12fa374 100644 --- a/tests/test_sftp.py +++ b/tests/test_sftp.py @@ -1,8 +1,9 @@ """Test de la logique SFTP avec un client SFTP simule (adosse a un dossier temporaire).""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import os -import posixpath import shutil import stat as statmod import tempfile @@ -10,9 +11,8 @@ import time from pathlib import Path from PySide6.QtWidgets import QApplication -from PySide6.QtCore import Qt -from app.sftp_panel import SFTPPanel, TransferJob, NAME_ROLE, ISDIR_ROLE, PARENT_ROLE +from app.sftp_panel import NAME_ROLE, PARENT_ROLE, SFTPPanel class FakeAttr: diff --git a/tests/test_snippets.py b/tests/test_snippets.py index d08edce..0f72e43 100644 --- a/tests/test_snippets.py +++ b/tests/test_snippets.py @@ -4,18 +4,20 @@ LocalTerminalConnection.start() est neutralisee : send() ne fait que deposer les octets dans une file interne (aucun sous-processus reel), ce qui suffit a verifier ce qui a ete envoye sans avoir besoin d'un vrai terminal.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import tempfile from pathlib import Path from unittest.mock import patch -from PySide6.QtWidgets import QApplication, QWidget +from PySide6.QtWidgets import QApplication -from app.snippets import Snippet, SnippetStore, DEFAULT_SNIPPETS -from app.local_terminal import LocalTerminalConnection from app.controllers.broadcast_manager import BroadcastManager +from app.local_terminal import LocalTerminalConnection +from app.snippets import DEFAULT_SNIPPETS, Snippet, SnippetStore from app.ui.tabs import LocalTerminalTabWidget app = QApplication.instance() or QApplication([]) diff --git a/tests/test_socks_tunnel.py b/tests/test_socks_tunnel.py index 75dbafe..6775bde 100644 --- a/tests/test_socks_tunnel.py +++ b/tests/test_socks_tunnel.py @@ -2,14 +2,16 @@ correctement la destination demandee par le client puis relaie le trafic a travers un (faux) transport SSH -- sans connexion SSH reelle, comme le fait deja test_tunnels.py pour la redirection locale.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import socket import threading import time -from app.tunnels import TunnelManager, DIRECTION_DYNAMIC +from app.tunnels import DIRECTION_DYNAMIC, TunnelManager class EchoServer(threading.Thread): diff --git a/tests/test_split_terminal.py b/tests/test_split_terminal.py index ed55ff0..be77a3b 100644 --- a/tests/test_split_terminal.py +++ b/tests/test_split_terminal.py @@ -5,7 +5,9 @@ fermeture globale d'un onglet divise. SSHConnection.start() et LocalTerminalConnection.start() sont neutralisees : ce module teste l'agencement des panneaux et le cycle de vie des onglets, pas une vraie connexion (deja couverte par test_keepalive.py / test_bastion.py).""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import itertools @@ -15,14 +17,13 @@ from unittest.mock import patch from PySide6.QtWidgets import QApplication, QWidget -from app.session_manager import SessionStore -from app.key_manager import KeyManager -from app.ssh_connection import SSHConnection -from app.local_terminal import LocalTerminalConnection from app.controllers.broadcast_manager import BroadcastManager from app.controllers.connection_controller import ConnectionController -from app.ui.tabs import LocalTerminalTabWidget -from app.ui.split_pane import SplitContainer, LAYOUT_2V, LAYOUT_4 +from app.key_manager import KeyManager +from app.local_terminal import LocalTerminalConnection +from app.session_manager import SessionStore +from app.ssh_connection import SSHConnection +from app.ui.split_pane import LAYOUT_2V, LAYOUT_4, SplitContainer app = QApplication.instance() or QApplication([]) diff --git a/tests/test_tags_and_groups.py b/tests/test_tags_and_groups.py index f20a8c8..aa67d63 100644 --- a/tests/test_tags_and_groups.py +++ b/tests/test_tags_and_groups.py @@ -1,7 +1,9 @@ """Tags de session et parametres par defaut d'un groupe : persistance, saisie/ normalisation des tags dans l'editeur, heritage explicite, filtrage par tag dans le panneau lateral.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import tempfile @@ -10,12 +12,12 @@ from unittest.mock import patch from PySide6.QtWidgets import QApplication, QWidget -from app.session_manager import Session, SessionStore, AUTH_KEY -from app.key_manager import KeyManager -from app.groups import GroupSettings, GroupSettingsStore -from app.dialogs.session_dialog import SessionDialog from app.controllers.broadcast_manager import BroadcastManager from app.controllers.session_controller import SessionController +from app.dialogs.session_dialog import SessionDialog +from app.groups import GroupSettings, GroupSettingsStore +from app.key_manager import KeyManager +from app.session_manager import AUTH_KEY, Session, SessionStore app = QApplication.instance() or QApplication([]) diff --git a/tests/test_terminal_comfort.py b/tests/test_terminal_comfort.py index bc4d85f..cc38274 100644 --- a/tests/test_terminal_comfort.py +++ b/tests/test_terminal_comfort.py @@ -1,10 +1,12 @@ """Confort du terminal : zoom de police, barre de defilement, recherche dans l'historique.""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) -from PySide6.QtWidgets import QApplication -from PySide6.QtGui import QKeyEvent from PySide6.QtCore import QEvent, Qt +from PySide6.QtGui import QKeyEvent +from PySide6.QtWidgets import QApplication from app.terminal_widget import TerminalWidget diff --git a/tests/test_tunnels.py b/tests/test_tunnels.py index 62bb9ff..5d191fc 100644 --- a/tests/test_tunnels.py +++ b/tests/test_tunnels.py @@ -4,14 +4,16 @@ Le test monte un vrai serveur TCP local et un faux transport SSH dont le canal 'direct-tcpip' est un simple socket vers ce serveur : cela valide toute la mecanique de relais sans avoir besoin d'un serveur SSH. """ -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import socket import threading import time -from app.tunnels import TunnelManager, DIRECTION_LOCAL, describe +from app.tunnels import DIRECTION_LOCAL, TunnelManager, describe class EchoServer(threading.Thread): diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 9ec087d..a3ab884 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -6,7 +6,9 @@ SSHConnection.start()/LocalTerminalConnection.start() sont neutralisees (pas de connexion reelle necessaire). app.app_settings.SETTINGS_FILE est redirige vers un fichier temporaire pour ne jamais toucher les vrais reglages de l'utilisateur (restore() met a jour last_workspace_id a chaque restauration).""" -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) import itertools @@ -16,18 +18,20 @@ from unittest.mock import patch from PySide6.QtWidgets import QApplication, QWidget -from app.session_manager import Session, SessionStore -from app.key_manager import KeyManager -from app.ssh_connection import SSHConnection -from app.local_terminal import LocalTerminalConnection +import app.app_settings as app_settings from app.controllers.broadcast_manager import BroadcastManager from app.controllers.connection_controller import ConnectionController from app.controllers.workspace_manager import ( - WorkspaceManager, SETTING_RESTORE_ON_STARTUP, SETTING_LAST_WORKSPACE_ID, + SETTING_LAST_WORKSPACE_ID, + SETTING_RESTORE_ON_STARTUP, + WorkspaceManager, ) -from app.workspace import WorkspaceStore, ITEM_SESSION_SSH, ITEM_LOCAL_SHELL +from app.key_manager import KeyManager +from app.local_terminal import LocalTerminalConnection +from app.session_manager import Session, SessionStore +from app.ssh_connection import SSHConnection from app.ui.split_pane import LAYOUT_2V -import app.app_settings as app_settings +from app.workspace import ITEM_LOCAL_SHELL, ITEM_SESSION_SSH, WorkspaceStore app = QApplication.instance() or QApplication([]) @@ -95,7 +99,8 @@ print(f" onglets ouverts: {cc2.tabs.count()}, elements ignores: {skipped}") assert cc2.tabs.count() == 2 assert skipped == 0 from app.ui.split_pane import SplitContainer -from app.ui.tabs import SessionTabWidget, LocalTerminalTabWidget +from app.ui.tabs import LocalTerminalTabWidget, SessionTabWidget + assert isinstance(cc2.tabs.widget(0), LocalTerminalTabWidget) container = cc2.tabs.widget(1) assert isinstance(container, SplitContainer)