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 app.ai.provider import AIProvider, AIError
from app.ai.provider import AIError, AIProvider
KIND_EXPLAIN = "explain"
KIND_DIAGNOSE = "diagnose"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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():

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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
pytest>=8.0
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
(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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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([])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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([])

View File

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

View File

@@ -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([])

View File

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

View File

@@ -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([])

View File

@@ -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([])

View File

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

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
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):

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