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>
This commit is contained in:
Florian
2026-09-05 07:46:50 +02:00
parent 09b7ed092c
commit c39ef9c038
72 changed files with 697 additions and 268 deletions

76
.gitea/workflows/ci.yml Normal file
View File

@@ -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

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from PySide6.QtCore import QThread, Signal from PySide6.QtCore import QThread, Signal
from app.ai.provider import AIProvider, AIError from app.ai.provider import AIError, AIProvider
KIND_EXPLAIN = "explain" KIND_EXPLAIN = "explain"
KIND_DIAGNOSE = "diagnose" KIND_DIAGNOSE = "diagnose"

View File

@@ -8,9 +8,9 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from app.ai.provider import AIProvider, OpenAICompatibleProvider
from app.app_settings import get_setting, set_setting from app.app_settings import get_setting, set_setting
from app.credentials import CredentialStore from app.credentials import CredentialStore
from app.ai.provider import AIProvider, OpenAICompatibleProvider
AI_API_KEY_CREDENTIAL = "ai:api_key" AI_API_KEY_CREDENTIAL = "ai:api_key"

View File

@@ -9,12 +9,17 @@ from __future__ import annotations
from dataclasses import dataclass 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.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): class BastionResolutionError(Exception):

View File

@@ -2,7 +2,7 @@
aussi vers tous les autres onglets terminal ouverts, quand le mode est actif.""" aussi vers tous les autres onglets terminal ouverts, quand le mode est actif."""
from __future__ import annotations from __future__ import annotations
from typing import Callable, Iterable from collections.abc import Callable, Iterable
from PySide6.QtCore import QObject from PySide6.QtCore import QObject
from PySide6.QtWidgets import QToolButton, QWidget from PySide6.QtWidgets import QToolButton, QWidget

View File

@@ -2,21 +2,21 @@
ouverture, connexion rapide, renommage, fermeture.""" ouverture, connexion rapide, renommage, fermeture."""
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Qt, Signal, QObject from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtWidgets import QTabWidget, QWidget, QMenu, QMessageBox, QInputDialog 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.key_manager import KeyManager
from app.local_terminal import DEFAULT_SHELL, SHELL_LABELS 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.openssh.config import OpenSSHHost, session_from_openssh_host
from app.bastion import BastionTarget, resolve_bastion_target, BastionResolutionError from app.services.ssh_target import detect_ssh_target
from app.credentials import CredentialStore, NullCredentialStore, session_credential_key from app.session_manager import AUTH_PASSWORD, PASSWORD_STORAGE_SECURE, Session, SessionStore
from app.controllers.broadcast_manager import BroadcastManager from app.ui.split_pane import LAYOUT_LABELS, PaneSlot, SplitContainer
from app.ui.tabs import BaseTerminalTab, SessionTabWidget, SFTPTabWidget, LocalTerminalTabWidget from app.ui.tabs import BaseTerminalTab, LocalTerminalTabWidget, SessionTabWidget, SFTPTabWidget
from app.ui.split_pane import SplitContainer, PaneSlot, LAYOUT_LABELS
class ConnectionController(QObject): class ConnectionController(QObject):

View File

@@ -6,22 +6,33 @@ from __future__ import annotations
import copy import copy
import uuid import uuid
from PySide6.QtCore import Qt, Signal, QObject from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QTreeWidget, QTreeWidgetItem, QComboBox,
QMenu, QMessageBox, QComboBox, QLabel, 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.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.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 ( 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 SESSION_ROLE = Qt.UserRole + 10
LOCAL_SHELL_ROLE = Qt.UserRole + 11 LOCAL_SHELL_ROLE = Qt.UserRole + 11

View File

@@ -7,15 +7,20 @@ from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QWidget from PySide6.QtWidgets import QWidget
from app.app_settings import get_setting, set_setting 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.local_terminal import DEFAULT_SHELL
from app.session_manager import SessionStore 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 ( from app.workspace import (
Workspace, WorkspaceStore, WorkspaceTab, WorkspaceItem, ITEM_LOCAL_SHELL,
ITEM_SESSION_SSH, ITEM_SESSION_SFTP, 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_RESTORE_ON_STARTUP = "restore_last_workspace"
SETTING_LAST_WORKSPACE_ID = "last_workspace_id" SETTING_LAST_WORKSPACE_ID = "last_workspace_id"
@@ -152,7 +157,12 @@ class WorkspaceManager(QObject):
self.cc.split_tab(index, wtab.layout) self.cc.split_tab(index, wtab.layout)
container = self.cc.tabs.widget(index) container = self.cc.tabs.widget(index)
skipped = 0 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) widget = self._widget_for_item(item)
if widget is None: if widget is None:
skipped += 1 skipped += 1

View File

@@ -6,7 +6,13 @@ from __future__ import annotations
import re import re
from PySide6.QtWidgets import ( 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) _CODE_BLOCK_RE = re.compile(r"```(?:[a-zA-Z0-9_+-]*\n)?(.*?)```", re.DOTALL)

View File

@@ -6,8 +6,8 @@ from __future__ import annotations
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
from app.app_settings import get_setting, set_setting
from app.ai.redaction import detect_secrets from app.ai.redaction import detect_secrets
from app.app_settings import get_setting, set_setting
_NOTICE_ACK_KEY = "ai_notice_acknowledged" _NOTICE_ACK_KEY = "ai_notice_acknowledged"

View File

@@ -4,13 +4,21 @@ compatibles avec l'API OpenAI (OpenAI, OpenRouter, proxy interne, LLM local...).
from __future__ import annotations from __future__ import annotations
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QFormLayout, QLineEdit, QCheckBox, QDoubleSpinBox, QCheckBox,
QPushButton, QDialogButtonBox, QMessageBox, QLabel, 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.provider import OpenAICompatibleProvider
from app.ai.service import KIND_TEST, AIRequestWorker 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 from app.credentials import CredentialStore, NullCredentialStore

View File

@@ -3,17 +3,36 @@ d'une nouvelle session dans ce dossier (voir app.groups)."""
from __future__ import annotations from __future__ import annotations
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QFormLayout, QLineEdit, QSpinBox, QComboBox, QDialogButtonBox, QCheckBox,
QHBoxLayout, QPushButton, QWidget, QVBoxLayout, QCheckBox, QStackedWidget, QLabel, 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.groups import GroupSettings, GroupSettingsStore
from app.key_manager import KeyManager
from app.openssh.config import load_openssh_hosts 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): class GroupSettingsDialog(QDialog):

View File

@@ -5,13 +5,28 @@ from pathlib import Path
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QApplication,
QMessageBox, QFileDialog, QFormLayout, QLineEdit, QComboBox, QSpinBox, QComboBox,
QDialogButtonBox, QApplication, QLabel, QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QSpinBox,
QVBoxLayout,
) )
from app.key_manager import ( 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,
) )

View File

@@ -3,8 +3,15 @@ from __future__ import annotations
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem, QPushButton, QAbstractItemView,
QMessageBox, QLabel, QAbstractItemView, QDialog,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
) )
from app.known_hosts_manager import KnownHostsStore from app.known_hosts_manager import KnownHostsStore

View File

@@ -2,12 +2,17 @@
snippets et actions, avec recherche floue et navigation entierement au clavier.""" snippets et actions, avec recherche floue et navigation entierement au clavier."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field 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 ( from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QDialog,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QVBoxLayout,
) )
from app.openssh.config import load_openssh_hosts from app.openssh.config import load_openssh_hosts
@@ -111,9 +116,9 @@ class QuickConnectDialog(QDialog):
return results return results
def _quick_actions(self) -> list[tuple[str, Callable[[], None]]]: 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.key_dialog import KeyManagerDialog
from app.dialogs.snippet_dialog import SnippetManagerDialog
from app.dialogs.workspace_dialog import WorkspaceDialog
return [ return [
("Nouvelle session...", self.session_controller.new_session), ("Nouvelle session...", self.session_controller.new_session),

View File

@@ -3,20 +3,44 @@ from __future__ import annotations
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QFormLayout, QLineEdit, QSpinBox, QComboBox, QDialogButtonBox, QCheckBox,
QHBoxLayout, QPushButton, QTextEdit, QFileDialog, QWidget, QVBoxLayout, QComboBox,
QCheckBox, QMessageBox, QGroupBox, QStackedWidget, QCompleter, QCompleter,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLineEdit,
QMessageBox,
QPushButton,
QSpinBox,
QStackedWidget,
QTextEdit,
QVBoxLayout,
QWidget,
) )
from app.session_manager import ( from app.credentials import CredentialStore, NullCredentialStore, session_credential_key
Session, SessionStore, BastionConfig, AUTH_AUTO, AUTH_KEY, AUTH_PASSWORD, AUTH_AGENT, from app.groups import GroupSettingsStore
BASTION_NONE, BASTION_SESSION, BASTION_OPENSSH, BASTION_MANUAL,
PASSWORD_STORAGE_NONE, PASSWORD_STORAGE_SECURE,
)
from app.key_manager import KeyManager from app.key_manager import KeyManager
from app.openssh.config import load_openssh_hosts from app.openssh.config import load_openssh_hosts
from app.groups import GroupSettingsStore from app.session_manager import (
from app.credentials import CredentialStore, NullCredentialStore, session_credential_key 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): class SessionDialog(QDialog):

View File

@@ -4,8 +4,19 @@ from __future__ import annotations
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem, QPushButton, QComboBox,
QMessageBox, QLineEdit, QLabel, QFormLayout, QTextEdit, QDialogButtonBox, QComboBox, QDialog,
QDialogButtonBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QTextEdit,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
) )
from app.snippets import Snippet, SnippetStore from app.snippets import Snippet, SnippetStore

View File

@@ -3,8 +3,19 @@ from __future__ import annotations
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QTreeWidget, QTreeWidgetItem, QAbstractItemView,
QPushButton, QComboBox, QSpinBox, QLineEdit, QLabel, QMessageBox, QAbstractItemView, QComboBox,
QDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSpinBox,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
) )
from app.tunnels import DIRECTION_DYNAMIC, DIRECTION_LABELS, TunnelManager, describe from app.tunnels import DIRECTION_DYNAMIC, DIRECTION_LABELS, TunnelManager, describe

View File

@@ -3,12 +3,20 @@ from __future__ import annotations
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QCheckBox,
QMessageBox, QInputDialog, QLabel, QCheckBox, QDialog,
QHBoxLayout,
QInputDialog,
QLabel,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QVBoxLayout,
) )
from app.app_settings import get_setting, set_setting 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): class WorkspaceDialog(QDialog):

View File

@@ -9,12 +9,12 @@ librement modifiables, sans indirection a suivre dans tout le reste du code
(ConnectionController, SSHConnection, resolve_bastion_target...).""" (ConnectionController, SSHConnection, resolve_bastion_target...)."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field, asdict from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from app.paths import DATA_DIR 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.storage.json_store import load_json_document, save_json_document
from app.session_manager import BastionConfig, AUTH_AUTO
GROUPS_FILE = DATA_DIR / "groups.json" GROUPS_FILE = DATA_DIR / "groups.json"
SCHEMA_VERSION = 1 SCHEMA_VERSION = 1
@@ -36,7 +36,7 @@ class GroupSettings:
return asdict(self) return asdict(self)
@staticmethod @staticmethod
def from_dict(data: dict) -> "GroupSettings": def from_dict(data: dict) -> GroupSettings:
g = GroupSettings() g = GroupSettings()
for k in GroupSettings.__dataclass_fields__: for k in GroupSettings.__dataclass_fields__:
if k not in data: if k not in data:

View File

@@ -5,16 +5,16 @@ import base64
import hashlib import hashlib
import shutil import shutil
import uuid import uuid
from dataclasses import dataclass, field, asdict from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
import paramiko import paramiko
from cryptography.hazmat.primitives import serialization 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.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.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" KEYS_FILE = DATA_DIR / "keys.json"
DEFAULT_KEY_DIR = DATA_DIR / "keys" DEFAULT_KEY_DIR = DATA_DIR / "keys"
@@ -116,7 +116,7 @@ class KeyEntry:
return asdict(self) return asdict(self)
@staticmethod @staticmethod
def from_dict(data: dict) -> "KeyEntry": def from_dict(data: dict) -> KeyEntry:
e = KeyEntry() e = KeyEntry()
for k in KeyEntry.__dataclass_fields__: for k in KeyEntry.__dataclass_fields__:
if k in data: if k in data:

View File

@@ -9,33 +9,37 @@ from __future__ import annotations
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QAction, QActionGroup, QKeySequence from PySide6.QtGui import QAction, QActionGroup, QKeySequence
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QMainWindow, QDockWidget, QMessageBox, QStatusBar, QApplication, QApplication,
QDockWidget,
QMainWindow,
QMessageBox,
QStatusBar,
) )
from app.session_manager import SessionStore from app.ai.settings import build_provider_from_settings
from app.key_manager import KeyManager from app.controllers.broadcast_manager import BroadcastManager
from app.local_terminal import SHELL_LABELS, DEFAULT_SHELL from app.controllers.connection_controller import ConnectionController
from app.terminal_widget import TerminalWidget 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.key_dialog import KeyManagerDialog
from app.dialogs.known_hosts_dialog import KnownHostsDialog 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.quick_connect_dialog import QuickConnectDialog
from app.dialogs.ai_settings_dialog import AISettingsDialog from app.dialogs.snippet_dialog import SnippetManagerDialog
from app.ai.settings import build_provider_from_settings from app.dialogs.workspace_dialog import WorkspaceDialog
from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme
from app.workspace import WorkspaceStore
from app.snippets import SnippetStore
from app.groups import GroupSettingsStore from app.groups import GroupSettingsStore
from app.credentials import default_credential_store from app.key_manager import KeyManager
from app.controllers.broadcast_manager import BroadcastManager from app.local_terminal import DEFAULT_SHELL, SHELL_LABELS
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
# re-exporte pour compatibilite (utilise par tests/test_detect.py et du code existant) # 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.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): class MainWindow(QMainWindow):

View File

@@ -14,7 +14,7 @@ from pathlib import Path
import paramiko 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 @dataclass
@@ -57,7 +57,7 @@ def load_openssh_hosts(path: Path | None = None) -> list[OpenSSHHost]:
parser = paramiko.SSHConfig() parser = paramiko.SSHConfig()
try: 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) parser.parse(f)
except OSError: except OSError:
return [] return []

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass, field, asdict from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe) from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
@@ -50,7 +50,7 @@ class BastionConfig:
return asdict(self) return asdict(self)
@staticmethod @staticmethod
def from_dict(data: dict) -> "BastionConfig": def from_dict(data: dict) -> BastionConfig:
b = BastionConfig() b = BastionConfig()
for k in BastionConfig.__dataclass_fields__: for k in BastionConfig.__dataclass_fields__:
if k in data: if k in data:
@@ -95,7 +95,7 @@ class Session:
return asdict(self) return asdict(self)
@staticmethod @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} known = {f: data.get(f) for f in Session.__dataclass_fields__ if f in data}
s = Session() s = Session()
for k, v in known.items(): for k, v in known.items():

View File

@@ -11,8 +11,9 @@ from __future__ import annotations
import queue import queue
import threading import threading
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable from typing import Any
from PySide6.QtCore import QThread, Signal from PySide6.QtCore import QThread, Signal

View File

@@ -5,18 +5,30 @@ from __future__ import annotations
import json import json
import os import os
import posixpath import posixpath
import tempfile
import stat import stat
import tempfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import paramiko 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.QtGui import QDrag, QKeySequence, QShortcut
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QToolButton, QTreeWidget, QAbstractItemView,
QTreeWidgetItem, QSplitter, QLabel, QMessageBox, QInputDialog, QProgressDialog, QHBoxLayout,
QAbstractItemView, QMenu, QHeaderView, QHeaderView,
QInputDialog,
QLabel,
QLineEdit,
QMenu,
QMessageBox,
QProgressDialog,
QSplitter,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
) )
from app.sftp.worker import SftpWorker from app.sftp.worker import SftpWorker
@@ -125,7 +137,7 @@ class TransferWorker(QThread):
def _upload_dir(self, local_dir: str, remote_dir: str) -> None: def _upload_dir(self, local_dir: str, remote_dir: str) -> None:
try: try:
self.sftp.mkdir(remote_dir) self.sftp.mkdir(remote_dir)
except IOError: except OSError:
pass pass
for entry in os.listdir(local_dir): for entry in os.listdir(local_dir):
if self._cancel: if self._cancel:
@@ -615,7 +627,7 @@ class RemoteFilePanel(FilePanel):
self._rmdir_recursive(path) self._rmdir_recursive(path)
else: else:
self.sftp.remove(path) self.sftp.remove(path)
except IOError as exc: except OSError as exc:
errors.append(f"{name} : {exc}") errors.append(f"{name} : {exc}")
return errors return errors
@@ -745,7 +757,7 @@ class RemoteFilePanel(FilePanel):
for entry_name, _ in entries: for entry_name, _ in entries:
try: try:
self.sftp.chmod(self.join(current_path, entry_name), mode) self.sftp.chmod(self.join(current_path, entry_name), mode)
except IOError as exc: except OSError as exc:
errors.append(f"{entry_name} : {exc}") errors.append(f"{entry_name} : {exc}")
return errors return errors

View File

@@ -3,7 +3,7 @@ depuis un terminal (inserer sans executer, executer, executer avec Multi-Send)."
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass, field, asdict from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from app.paths import DATA_DIR from app.paths import DATA_DIR
@@ -25,7 +25,7 @@ class Snippet:
return asdict(self) return asdict(self)
@staticmethod @staticmethod
def from_dict(data: dict) -> "Snippet": def from_dict(data: dict) -> Snippet:
s = Snippet() s = Snippet()
for k in Snippet.__dataclass_fields__: for k in Snippet.__dataclass_fields__:
if k in data: if k in data:

View File

@@ -7,17 +7,17 @@ import threading
import paramiko import paramiko
from PySide6.QtCore import QThread, Signal from PySide6.QtCore import QThread, Signal
from app.known_hosts_manager import KnownHostsStore
from app.bastion import BastionTarget 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) from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
KNOWN_HOSTS_FILE = DATA_DIR / "known_hosts" KNOWN_HOSTS_FILE = DATA_DIR / "known_hosts"
class TrustPrompt(paramiko.MissingHostKeyPolicy): class TrustPrompt(paramiko.MissingHostKeyPolicy):
"""Politique qui demande confirmation (via signal Qt) avant de faire confiance a une nouvelle cle d'hote.""" """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 self.connection = connection
def missing_host_key(self, client, hostname, key): def missing_host_key(self, client, hostname, key):
@@ -379,7 +379,10 @@ class SSHConnection(QThread):
except paramiko.PasswordRequiredException: except paramiko.PasswordRequiredException:
pwd = passphrase or self._ask_passphrase(key_path) pwd = passphrase or self._ask_passphrase(key_path)
if not pwd: 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: try:
return loader.from_private_key_file(key_path, password=pwd) return loader.from_private_key_file(key_path, password=pwd)
except paramiko.SSHException as exc: except paramiko.SSHException as exc:

View File

@@ -13,8 +13,8 @@ plutot que de perdre les donnees de l'utilisateur.
from __future__ import annotations from __future__ import annotations
import json import json
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Callable
from app.storage.atomic_file import atomic_write_bytes from app.storage.atomic_file import atomic_write_bytes

View File

@@ -11,9 +11,14 @@ from __future__ import annotations
import pyte import pyte
from PySide6.QtCore import QRect, Qt, Signal from PySide6.QtCore import QRect, Qt, Signal
from PySide6.QtGui import ( 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 from app import theme as app_theme

View File

@@ -9,11 +9,11 @@ from __future__ import annotations
from string import Template from string import Template
from PySide6.QtCore import Qt 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 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.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" STYLESHEET_PATH = RESOURCES_DIR / "theme.qss"

View File

@@ -60,7 +60,7 @@ def _relay(sock: socket.socket, chan: paramiko.Channel) -> None:
if not data: if not data:
break break
sock.sendall(data) sock.sendall(data)
except (OSError, socket.error): except OSError:
pass pass
finally: finally:
for closeable in (chan, sock): for closeable in (chan, sock):

View File

@@ -10,12 +10,19 @@ from __future__ import annotations
import html import html
from PySide6.QtWidgets import ( 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.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 = { _KIND_LABELS = {
"explain": "Expliquer cette sortie", "explain": "Expliquer cette sortie",

View File

@@ -7,11 +7,16 @@ existant (SessionTabWidget, LocalTerminalTabWidget, ...) sans le modifier.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Callable from collections.abc import Callable
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QToolButton, QFrame, QFrame,
QHBoxLayout,
QSplitter,
QToolButton,
QVBoxLayout,
QWidget,
) )
LAYOUT_SINGLE = "1" LAYOUT_SINGLE = "1"

View File

@@ -11,31 +11,41 @@ import re
from datetime import datetime from datetime import datetime
from pathlib import Path 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.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMessageBox, QSplitter, QFileDialog,
QFrame, QLineEdit, QFileDialog, QMenu, QInputDialog, 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.danger import check_dangerous
from app.ai.redaction import redact 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_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 # sequences d'echappement ANSI, retirees des journaux pour qu'ils restent lisibles
ANSI_ESCAPE_RE = re.compile( ANSI_ESCAPE_RE = re.compile(

View File

@@ -7,7 +7,7 @@ mot de passe/passphrase exactement comme une ouverture normale)."""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass, field, asdict from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from app.paths import DATA_DIR from app.paths import DATA_DIR
@@ -32,7 +32,7 @@ class WorkspaceItem:
return asdict(self) return asdict(self)
@staticmethod @staticmethod
def from_dict(data: dict) -> "WorkspaceItem": def from_dict(data: dict) -> WorkspaceItem:
item = WorkspaceItem() item = WorkspaceItem()
for k in WorkspaceItem.__dataclass_fields__: for k in WorkspaceItem.__dataclass_fields__:
if k in data: if k in data:
@@ -56,7 +56,7 @@ class WorkspaceTab:
} }
@staticmethod @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 = WorkspaceTab(title=data.get("title", ""), layout=data.get("layout", "1"))
tab.items = [WorkspaceItem.from_dict(i) for i in data.get("items", [])] tab.items = [WorkspaceItem.from_dict(i) for i in data.get("items", [])]
tab.splitter_sizes = [list(s) for s in data.get("splitter_sizes", [])] 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]} return {"id": self.id, "name": self.name, "tabs": [t.to_dict() for t in self.tabs]}
@staticmethod @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 = 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", [])] ws.tabs = [WorkspaceTab.from_dict(t) for t in data.get("tabs", [])]
return ws return ws

61
pyproject.toml Normal file
View File

@@ -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

View File

@@ -1,3 +0,0 @@
[pytest]
testpaths = tests
python_files = test_all_scripts.py

View File

@@ -1,3 +1,5 @@
-r requirements.txt -r requirements.txt
pytest>=8.0 pytest>=8.0
pytest-qt>=4.4 pytest-qt>=4.4
ruff>=0.6
pyright>=1.1

View File

@@ -1,7 +1,9 @@
"""AICommandDialog : extraction de la commande depuis la reponse de l'IA """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 (bloc de code s'il y en a un), et les 3 actions possibles (Copier / Inserer
dans le terminal / Annuler) - jamais d'execution automatique.""" 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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication

View File

@@ -1,9 +1,11 @@
"""Detection locale de commandes potentiellement destructives proposees par """Detection locale de commandes potentiellement destructives proposees par
l'assistant IA (jamais un blocage, seulement un avertissement).""" 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)) 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 ===") print("=== 1. Commandes destructrices detectees ===")
dangerous_examples = [ dangerous_examples = [

View File

@@ -2,7 +2,9 @@
la verification de securite (avis + secrets) est bien invoquee avant tout la verification de securite (avis + secrets) est bien invoquee avant tout
envoi, que les reponses/erreurs mettent a jour la conversation, et qu'une envoi, que les reponses/erreurs mettent a jour la conversation, et qu'une
reponse perimee (generation obsolete) est ignoree.""" reponse perimee (generation obsolete) est ignoree."""
import sys, pathlib import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from unittest.mock import patch from unittest.mock import patch
@@ -10,9 +12,9 @@ from unittest.mock import patch
from PySide6.QtCore import QCoreApplication from PySide6.QtCore import QCoreApplication
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from app.ai.provider import AIProvider, AIError from app.ai.provider import AIError, AIProvider
from app.ai.service import KIND_EXPLAIN, KIND_ASK from app.ai.service import KIND_EXPLAIN
from app.dialogs.ai_prompts import MASK, SEND, CANCEL from app.dialogs.ai_prompts import CANCEL, MASK, SEND
from app.ui.ai_panel import AIPanel from app.ui.ai_panel import AIPanel
app = QApplication.instance() or QApplication(sys.argv) app = QApplication.instance() or QApplication(sys.argv)

View File

@@ -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 temporaire), et verification des secrets a chaque envoi. QMessageBox est
simule (exec()/clickedButton() patches) : aucune boite modale reelle ne doit simule (exec()/clickedButton() patches) : aucune boite modale reelle ne doit
bloquer les tests.""" bloquer les tests."""
import sys, pathlib import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile import tempfile
@@ -13,7 +15,13 @@ from unittest.mock import patch
from PySide6.QtWidgets import QApplication, QMessageBox from PySide6.QtWidgets import QApplication, QMessageBox
import app.app_settings as app_settings 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) app = QApplication.instance() or QApplication(sys.argv)

View File

@@ -1,15 +1,17 @@
"""OpenAICompatibleProvider : requete HTTP construite correctement, et gestion """OpenAICompatibleProvider : requete HTTP construite correctement, et gestion
des erreurs (401, 429, timeout, reseau, reponse invalide) - urllib.request.urlopen des erreurs (401, 429, timeout, reseau, reponse invalide) - urllib.request.urlopen
est entierement mocke, aucun appel reseau reel.""" 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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import io import io
import json import json
import urllib.error 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): def fake_response(payload: dict):

View File

@@ -1,5 +1,7 @@
"""Detection/redaction de secrets avant envoi a l'assistant IA.""" """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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from app.ai.redaction import detect_secrets, redact from app.ai.redaction import detect_secrets, redact
@@ -16,8 +18,8 @@ print("=== 2. Jeton Bearer detecte ===")
found = detect_secrets("Authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz123456") found = detect_secrets("Authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz123456")
labels = [f.label for f in found] labels = [f.label for f in found]
print(" detecte:", labels) print(" detecte:", labels)
assert any("bearer" in l.lower() for l in labels) assert any("bearer" in label.lower() for label in labels)
assert any("authorization" in l.lower() for l in labels) assert any("authorization" in label.lower() for label in labels)
print(" OK") print(" OK")
print() print()

View File

@@ -1,7 +1,9 @@
"""AIRequestWorker : execution d'une requete IA dans un vrai QThread en """AIRequestWorker : execution d'une requete IA dans un vrai QThread en
arriere-plan, jamais sur le thread Qt principal - avec dispatch correct selon 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.""" 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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import threading import threading
@@ -9,9 +11,15 @@ import threading
from PySide6.QtCore import QCoreApplication from PySide6.QtCore import QCoreApplication
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from app.ai.provider import AIProvider, AIError from app.ai.provider import AIError, AIProvider
from app.ai.service import ( 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) app = QApplication.instance() or QApplication(sys.argv)

View File

@@ -2,20 +2,28 @@
app_settings (redirige vers un fichier temporaire pour ne jamais toucher les app_settings (redirige vers un fichier temporaire pour ne jamais toucher les
vrais reglages), cle API exclusivement via CredentialStore, et construction du vrais reglages), cle API exclusivement via CredentialStore, et construction du
fournisseur a partir des deux.""" fournisseur a partir des deux."""
import sys, pathlib import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch 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 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): class FakeCredentialStore(CredentialStore):

View File

@@ -5,7 +5,9 @@ automatiquement, avec avertissement si dangereuse).
LocalTerminalConnection.start() est neutralisee comme dans test_snippets.py : LocalTerminalConnection.start() est neutralisee comme dans test_snippets.py :
aucun sous-processus reel, send() depose seulement les octets dans une file.""" 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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from unittest.mock import patch from unittest.mock import patch
@@ -13,9 +15,9 @@ from unittest.mock import patch
from PySide6.QtCore import QCoreApplication from PySide6.QtCore import QCoreApplication
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from app.local_terminal import LocalTerminalConnection
from app.ai.provider import AIProvider from app.ai.provider import AIProvider
from app.dialogs.ai_prompts import SEND from app.dialogs.ai_prompts import SEND
from app.local_terminal import LocalTerminalConnection
from app.ui.ai_panel import AIPanel from app.ui.ai_panel import AIPanel
from app.ui.tabs import LocalTerminalTabWidget from app.ui.tabs import LocalTerminalTabWidget

View File

@@ -1,16 +1,18 @@
"""Stockage JSON : ecriture atomique, schema_version, migration, recuperation """Stockage JSON : ecriture atomique, schema_version, migration, recuperation
automatique depuis .bak quand le fichier principal est corrompu.""" 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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import json import json
import tempfile import tempfile
from pathlib import Path 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.atomic_file import atomic_write_text
from app.storage.json_store import load_json_document, save_json_document 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()) tmp = Path(tempfile.mkdtemp())

View File

@@ -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 reelle - cela evite aussi qu'un thread reste bloque dans un connect() lent et
ne bloque la fin du script. ne bloque la fin du script.
""" """
import sys, pathlib import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import time import time

View File

@@ -1,7 +1,9 @@
"""ProxyJump / Bastion : resolution (app.bastion) pour les 3 sources (session, """ProxyJump / Bastion : resolution (app.bastion) pour les 3 sources (session,
OpenSSH, manuel), et etablissement du canal direct-tcpip par SSHConnection via OpenSSH, manuel), et etablissement du canal direct-tcpip par SSHConnection via
un bastion entierement mocke (aucun reseau reel).""" 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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile import tempfile
@@ -10,12 +12,18 @@ from unittest.mock import MagicMock, patch
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from app.bastion import BastionResolutionError, BastionTarget, resolve_bastion_target
from app.session_manager import ( from app.session_manager import (
SessionStore, Session, BastionConfig, AUTH_AUTO,
AUTH_AUTO, AUTH_KEY, AUTH_KEY,
BASTION_NONE, BASTION_SESSION, BASTION_OPENSSH, BASTION_MANUAL, 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([]) app = QApplication.instance() or QApplication([])

View File

@@ -1,9 +1,11 @@
"""Copier/coller du terminal : selection, presse-papiers, raccourcis.""" """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)) 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.QtGui import QKeyEvent
from PySide6.QtCore import QEvent, Qt, QPoint from PySide6.QtWidgets import QApplication
from app.terminal_widget import TerminalWidget from app.terminal_widget import TerminalWidget
@@ -90,8 +92,8 @@ print(" OK")
print() print()
print("=== 9. Souris : selection = copie, clic droit = collage (comme sous Unix) ===") print("=== 9. Souris : selection = copie, clic droit = collage (comme sous Unix) ===")
from PySide6.QtGui import QMouseEvent, QContextMenuEvent
from PySide6.QtCore import QPointF from PySide6.QtCore import QPointF
from PySide6.QtGui import QContextMenuEvent, QMouseEvent
def clic(bouton, col, row, type_evt=QEvent.MouseButtonPress): def clic(bouton, col, row, type_evt=QEvent.MouseButtonPress):

View File

@@ -4,7 +4,9 @@ passe (ConnectionController, bastion, suppression de session).
SSHConnection.start() est neutralisee pour les tests d'integration qui SSHConnection.start() est neutralisee pour les tests d'integration qui
construisent un onglet reel (aucune connexion reseau necessaire).""" 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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import json import json
@@ -12,18 +14,21 @@ import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from PySide6.QtWidgets import QApplication, QWidget, QMessageBox from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
from app.credentials import ( from app.bastion import BastionTarget
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.controllers.broadcast_manager import BroadcastManager from app.controllers.broadcast_manager import BroadcastManager
from app.controllers.connection_controller import ConnectionController from app.controllers.connection_controller import ConnectionController
from app.controllers.session_controller import SessionController 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([]) app = QApplication.instance() or QApplication([])
@@ -103,6 +108,7 @@ session_store = SessionStore(path=tmp / "sessions.json")
key_manager_broadcast_widget = QWidget() key_manager_broadcast_widget = QWidget()
broadcast = BroadcastManager(lambda: [key_manager_broadcast_widget]) broadcast = BroadcastManager(lambda: [key_manager_broadcast_widget])
from app.key_manager import KeyManager from app.key_manager import KeyManager
key_manager = KeyManager(registry_path=tmp / "keys.json") key_manager = KeyManager(registry_path=tmp / "keys.json")
fake_store = FakeCredentialStore() fake_store = FakeCredentialStore()
cc = ConnectionController(session_store, key_manager, broadcast, credential_store=fake_store, cc = ConnectionController(session_store, key_manager, broadcast, credential_store=fake_store,

View File

@@ -1,4 +1,6 @@
import sys, pathlib import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from app.main_window import detect_ssh_target from app.main_window import detect_ssh_target

View File

@@ -1,6 +1,9 @@
import sys, pathlib import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from app.main_window import MainWindow from app.main_window import MainWindow
from app.theme import apply_theme, build_app_icon from app.theme import apply_theme, build_app_icon

View File

@@ -1,5 +1,7 @@
"""Recherche floue (app.services.fuzzy) : sous-sequence, score, tri.""" """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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from app.services.fuzzy import fuzzy_score from app.services.fuzzy import fuzzy_score

View File

@@ -1,7 +1,9 @@
"""Keepalive de SSHConnection : verifie l'envoi periodique et l'abandon apres """Keepalive de SSHConnection : verifie l'envoi periodique et l'abandon apres
`keepalive_count_max` echecs consecutifs, avec un transport paramiko mocke `keepalive_count_max` echecs consecutifs, avec un transport paramiko mocke
(aucune connexion reseau reelle).""" (aucune connexion reseau reelle)."""
import sys, pathlib import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import threading import threading

View File

@@ -2,7 +2,9 @@
(sans dependre des ACL reelles du systeme de fichiers, pour rester fiable quelle (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 que soit la machine qui execute le test) et le bon aiguillage vers le repli chmod
ailleurs que sous Windows.""" ailleurs que sous Windows."""
import sys, pathlib import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import os import os
@@ -11,7 +13,7 @@ import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch 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()) tmp = Path(tempfile.mkdtemp())
key_file = tmp / "id_test" key_file = tmp / "id_test"

View File

@@ -1,6 +1,8 @@
"""Generation de cles SSH : tous les types proposes doivent produire une cle """Generation de cles SSH : tous les types proposes doivent produire une cle
relisible par paramiko, donc utilisable pour se connecter.""" 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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile import tempfile
@@ -9,7 +11,11 @@ from pathlib import Path
import paramiko import paramiko
from app.key_manager import ( 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()) tmp = Path(tempfile.mkdtemp())

View File

@@ -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 test_credentials.py/test_ai_settings.py, ou un secret pourrait fuiter sans
etre ecrit intentionnellement (une boite de dialogue d'erreur, une trace etre ecrit intentionnellement (une boite de dialogue d'erreur, une trace
affichee a l'utilisateur ou capturee dans un rapport de bug).""" 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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import io import io
import urllib.error 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" SECRET_API_KEY = "sk-tres-secret-ne-jamais-fuiter-1234567890"

View File

@@ -1,5 +1,7 @@
"""Lecture de ~/.ssh/config : hosts detectes, champs minimum, conversion en Session.""" """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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile import tempfile

View File

@@ -5,29 +5,31 @@ activation.
SSHConnection.start()/LocalTerminalConnection.start() sont neutralisees, et SSHConnection.start()/LocalTerminalConnection.start() sont neutralisees, et
~/.ssh/config est redirige vers un fichier absent (pas de dependance au poste ~/.ssh/config est redirige vers un fichier absent (pas de dependance au poste
qui execute le test).""" qui execute le test)."""
import sys, pathlib import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from PySide6.QtCore import Qt, QEvent from PySide6.QtCore import QEvent, Qt
from PySide6.QtGui import QKeyEvent from PySide6.QtGui import QKeyEvent
from PySide6.QtWidgets import QApplication, QWidget from PySide6.QtWidgets import QApplication, QWidget
from app.session_manager import Session, SessionStore import app.app_settings as app_settings
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.broadcast_manager import BroadcastManager
from app.controllers.connection_controller import ConnectionController from app.controllers.connection_controller import ConnectionController
from app.controllers.session_controller import SessionController from app.controllers.session_controller import SessionController
from app.controllers.workspace_manager import WorkspaceManager 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 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([]) app = QApplication.instance() or QApplication([])
@@ -118,6 +120,7 @@ dialog2._activate_current()
print(f" onglets avant/apres activation: {before}/{cc2.tabs.count()}") print(f" onglets avant/apres activation: {before}/{cc2.tabs.count()}")
assert cc2.tabs.count() == before + 1 assert cc2.tabs.count() == before + 1
from app.ui.tabs import SessionTabWidget from app.ui.tabs import SessionTabWidget
assert isinstance(cc2.tabs.widget(cc2.tabs.count() - 1), SessionTabWidget) assert isinstance(cc2.tabs.widget(cc2.tabs.count() - 1), SessionTabWidget)
print(" OK") print(" OK")

View File

@@ -1,5 +1,7 @@
"""Calcul du delai de reconnexion automatique (backoff exponentiel plafonne).""" """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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from app.services.reconnect import compute_backoff_delay from app.services.reconnect import compute_backoff_delay

View File

@@ -1,6 +1,8 @@
"""Editeur de session : section Bastion / Jump Host (construction, pre-remplissage """Editeur de session : section Bastion / Jump Host (construction, pre-remplissage
a l'edition, et resultat renvoye pour chacun des 4 modes).""" 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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile import tempfile
@@ -9,12 +11,18 @@ from unittest.mock import patch
from PySide6.QtWidgets import QApplication 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.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([]) app = QApplication.instance() or QApplication([])

View File

@@ -1,8 +1,9 @@
"""Test de la logique SFTP avec un client SFTP simule (adosse a un dossier temporaire).""" """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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import os import os
import posixpath
import shutil import shutil
import stat as statmod import stat as statmod
import tempfile import tempfile
@@ -10,9 +11,8 @@ import time
from pathlib import Path from pathlib import Path
from PySide6.QtWidgets import QApplication 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: class FakeAttr:

View File

@@ -4,18 +4,20 @@
LocalTerminalConnection.start() est neutralisee : send() ne fait que deposer LocalTerminalConnection.start() est neutralisee : send() ne fait que deposer
les octets dans une file interne (aucun sous-processus reel), ce qui suffit a 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.""" 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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch 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.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 from app.ui.tabs import LocalTerminalTabWidget
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])

View File

@@ -2,14 +2,16 @@
correctement la destination demandee par le client puis relaie le trafic a correctement la destination demandee par le client puis relaie le trafic a
travers un (faux) transport SSH -- sans connexion SSH reelle, comme le fait travers un (faux) transport SSH -- sans connexion SSH reelle, comme le fait
deja test_tunnels.py pour la redirection locale.""" 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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import socket import socket
import threading import threading
import time import time
from app.tunnels import TunnelManager, DIRECTION_DYNAMIC from app.tunnels import DIRECTION_DYNAMIC, TunnelManager
class EchoServer(threading.Thread): class EchoServer(threading.Thread):

View File

@@ -5,7 +5,9 @@ fermeture globale d'un onglet divise.
SSHConnection.start() et LocalTerminalConnection.start() sont neutralisees : SSHConnection.start() et LocalTerminalConnection.start() sont neutralisees :
ce module teste l'agencement des panneaux et le cycle de vie des onglets, pas 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).""" 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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import itertools import itertools
@@ -15,14 +17,13 @@ from unittest.mock import patch
from PySide6.QtWidgets import QApplication, QWidget 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.broadcast_manager import BroadcastManager
from app.controllers.connection_controller import ConnectionController from app.controllers.connection_controller import ConnectionController
from app.ui.tabs import LocalTerminalTabWidget from app.key_manager import KeyManager
from app.ui.split_pane import SplitContainer, LAYOUT_2V, LAYOUT_4 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([]) app = QApplication.instance() or QApplication([])

View File

@@ -1,7 +1,9 @@
"""Tags de session et parametres par defaut d'un groupe : persistance, saisie/ """Tags de session et parametres par defaut d'un groupe : persistance, saisie/
normalisation des tags dans l'editeur, heritage explicite, filtrage par tag normalisation des tags dans l'editeur, heritage explicite, filtrage par tag
dans le panneau lateral.""" dans le panneau lateral."""
import sys, pathlib import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile import tempfile
@@ -10,12 +12,12 @@ from unittest.mock import patch
from PySide6.QtWidgets import QApplication, QWidget 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.broadcast_manager import BroadcastManager
from app.controllers.session_controller import SessionController 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([]) app = QApplication.instance() or QApplication([])

View File

@@ -1,10 +1,12 @@
"""Confort du terminal : zoom de police, barre de defilement, recherche dans l'historique.""" """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)) 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.QtCore import QEvent, Qt
from PySide6.QtGui import QKeyEvent
from PySide6.QtWidgets import QApplication
from app.terminal_widget import TerminalWidget from app.terminal_widget import TerminalWidget

View File

@@ -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 'direct-tcpip' est un simple socket vers ce serveur : cela valide toute la
mecanique de relais sans avoir besoin d'un serveur SSH. 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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import socket import socket
import threading import threading
import time import time
from app.tunnels import TunnelManager, DIRECTION_LOCAL, describe from app.tunnels import DIRECTION_LOCAL, TunnelManager, describe
class EchoServer(threading.Thread): class EchoServer(threading.Thread):

View File

@@ -6,7 +6,9 @@ SSHConnection.start()/LocalTerminalConnection.start() sont neutralisees (pas de
connexion reelle necessaire). app.app_settings.SETTINGS_FILE est redirige vers connexion reelle necessaire). app.app_settings.SETTINGS_FILE est redirige vers
un fichier temporaire pour ne jamais toucher les vrais reglages de l'utilisateur un fichier temporaire pour ne jamais toucher les vrais reglages de l'utilisateur
(restore() met a jour last_workspace_id a chaque restauration).""" (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)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import itertools import itertools
@@ -16,18 +18,20 @@ from unittest.mock import patch
from PySide6.QtWidgets import QApplication, QWidget from PySide6.QtWidgets import QApplication, QWidget
from app.session_manager import Session, SessionStore import app.app_settings as app_settings
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.broadcast_manager import BroadcastManager
from app.controllers.connection_controller import ConnectionController from app.controllers.connection_controller import ConnectionController
from app.controllers.workspace_manager import ( 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 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([]) 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 cc2.tabs.count() == 2
assert skipped == 0 assert skipped == 0
from app.ui.split_pane import SplitContainer 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) assert isinstance(cc2.tabs.widget(0), LocalTerminalTabWidget)
container = cc2.tabs.widget(1) container = cc2.tabs.widget(1)
assert isinstance(container, SplitContainer) assert isinstance(container, SplitContainer)