From 9a8b4e72bcc119c58cd0e1d345e82b081c27a7fc Mon Sep 17 00:00:00 2001 From: Maxteabag <18162810+Maxteabag@users.noreply.github.com> Date: Fri, 22 May 2026 17:50:02 +0200 Subject: [PATCH] Detect paramiko 4 incompatibility and route through installer prompt --- sqlit/domains/connections/app/tunnel.py | 13 ++++++- tests/unit/test_tunnel_paramiko_version.py | 43 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_tunnel_paramiko_version.py diff --git a/sqlit/domains/connections/app/tunnel.py b/sqlit/domains/connections/app/tunnel.py index 81fd7d01..a8e95107 100644 --- a/sqlit/domains/connections/app/tunnel.py +++ b/sqlit/domains/connections/app/tunnel.py @@ -11,9 +11,20 @@ def ensure_ssh_tunnel_available() -> None: - """Ensure SSH tunnel dependencies are installed.""" + """Ensure SSH tunnel dependencies are installed and compatible.""" try: + import paramiko import sshtunnel # noqa: F401 + + # paramiko 4 removed DSSKey, but sshtunnel still references it at + # runtime. Surface this as a MissingDriverError so the installer + # prompt suggests reinstalling with the [ssh] extra (pins paramiko<4). + if not hasattr(paramiko, "DSSKey"): + raise RuntimeError( + f"paramiko {getattr(paramiko, '__version__', '?')} is incompatible " + "with sshtunnel (DSSKey was removed in paramiko 4); reinstall with " + "the [ssh] extra to pin paramiko<4." + ) except Exception as e: from sqlit.domains.connections.providers.exceptions import MissingDriverError diff --git a/tests/unit/test_tunnel_paramiko_version.py b/tests/unit/test_tunnel_paramiko_version.py new file mode 100644 index 00000000..95d7dd6d --- /dev/null +++ b/tests/unit/test_tunnel_paramiko_version.py @@ -0,0 +1,43 @@ +"""Regression test for issue #186: paramiko 4 removed DSSKey, breaking sshtunnel.""" + +from __future__ import annotations + +import sys +import types + +import pytest + +from sqlit.domains.connections.app.tunnel import ensure_ssh_tunnel_available +from sqlit.domains.connections.providers.exceptions import MissingDriverError + + +def test_ensure_ssh_tunnel_raises_missing_driver_when_paramiko_lacks_dsskey(monkeypatch): + """A paramiko without DSSKey (i.e. 4.x) must surface as MissingDriverError, not AttributeError.""" + fake_paramiko = types.ModuleType("paramiko") + fake_paramiko.__version__ = "4.0.0" # type: ignore[attr-defined] + # Intentionally no DSSKey attribute — simulating paramiko 4. + + fake_sshtunnel = types.ModuleType("sshtunnel") + + monkeypatch.setitem(sys.modules, "paramiko", fake_paramiko) + monkeypatch.setitem(sys.modules, "sshtunnel", fake_sshtunnel) + + with pytest.raises(MissingDriverError) as excinfo: + ensure_ssh_tunnel_available() + + assert excinfo.value.extra_name == "ssh" + assert "paramiko" in str(excinfo.value.import_error or "").lower() + + +def test_ensure_ssh_tunnel_passes_when_paramiko_has_dsskey(monkeypatch): + """A paramiko 3.x with DSSKey must not raise.""" + fake_paramiko = types.ModuleType("paramiko") + fake_paramiko.__version__ = "3.5.0" # type: ignore[attr-defined] + fake_paramiko.DSSKey = object # type: ignore[attr-defined] + + fake_sshtunnel = types.ModuleType("sshtunnel") + + monkeypatch.setitem(sys.modules, "paramiko", fake_paramiko) + monkeypatch.setitem(sys.modules, "sshtunnel", fake_sshtunnel) + + ensure_ssh_tunnel_available() # must not raise