From e3397fe574c01d52e5c8100bda0a842627cfb5a0 Mon Sep 17 00:00:00 2001
From: Orinks <38449772+Orinks@users.noreply.github.com>
Date: Fri, 27 Feb 2026 02:01:24 +0000
Subject: [PATCH 01/10] feat: import connection profiles from FTP/SFTP clients
---
src/portkeydrop/app.py | 73 +++++
src/portkeydrop/dialogs/import_connections.py | 289 ++++++++++++++++++
src/portkeydrop/importers/__init__.py | 94 ++++++
src/portkeydrop/importers/cyberduck.py | 80 +++++
src/portkeydrop/importers/filezilla.py | 111 +++++++
src/portkeydrop/importers/models.py | 20 ++
src/portkeydrop/importers/winscp.py | 158 ++++++++++
tests/_wx_stub.py | 1 +
.../importers/cyberduck_bookmark.duck | 18 ++
.../importers/filezilla_sitemanager.xml | 24 ++
tests/fixtures/importers/winscp_sessions.ini | 17 ++
tests/test_app.py | 71 +++++
tests/test_importers_cyberduck.py | 15 +
tests/test_importers_filezilla.py | 25 ++
tests/test_importers_winscp.py | 25 ++
15 files changed, 1021 insertions(+)
create mode 100644 src/portkeydrop/dialogs/import_connections.py
create mode 100644 src/portkeydrop/importers/__init__.py
create mode 100644 src/portkeydrop/importers/cyberduck.py
create mode 100644 src/portkeydrop/importers/filezilla.py
create mode 100644 src/portkeydrop/importers/models.py
create mode 100644 src/portkeydrop/importers/winscp.py
create mode 100644 tests/fixtures/importers/cyberduck_bookmark.duck
create mode 100644 tests/fixtures/importers/filezilla_sitemanager.xml
create mode 100644 tests/fixtures/importers/winscp_sessions.ini
create mode 100644 tests/test_importers_cyberduck.py
create mode 100644 tests/test_importers_filezilla.py
create mode 100644 tests/test_importers_winscp.py
diff --git a/src/portkeydrop/app.py b/src/portkeydrop/app.py
index aa94877..6656c5a 100644
--- a/src/portkeydrop/app.py
+++ b/src/portkeydrop/app.py
@@ -12,6 +12,7 @@
from portkeydrop.dialogs.properties import PropertiesDialog
from portkeydrop.dialogs.quick_connect import QuickConnectDialog
from portkeydrop.dialogs.settings import SettingsDialog
+from portkeydrop.dialogs.import_connections import ImportConnectionsDialog
from portkeydrop.dialogs.site_manager import SiteManagerDialog
from portkeydrop.dialogs.transfer import (
TransferDirection,
@@ -62,6 +63,7 @@
ID_FILTER = wx.NewIdRef()
ID_SAVE_CONNECTION = wx.NewIdRef()
ID_SETTINGS = wx.NewIdRef()
+ID_IMPORT_CONNECTIONS = wx.NewIdRef()
class MainFrame(wx.Frame):
@@ -100,6 +102,12 @@ def _build_menu(self) -> None:
file_menu.Append(ID_CONNECT, "&Connect\tCtrl+Enter", "Connect to server")
file_menu.Append(ID_DISCONNECT, "&Disconnect\tCtrl+Q", "Disconnect from server")
file_menu.AppendSeparator()
+ file_menu.Append(
+ ID_IMPORT_CONNECTIONS,
+ "&Import Connections...",
+ "Import sites from other FTP/SFTP clients",
+ )
+ file_menu.AppendSeparator()
file_menu.Append(ID_SETTINGS, "Se&ttings...", "Application settings")
file_menu.AppendSeparator()
file_menu.Append(wx.ID_EXIT, "E&xit\tAlt+F4", "Exit application")
@@ -330,6 +338,7 @@ def _bind_events(self) -> None:
self.Bind(wx.EVT_MENU, self._on_properties, id=ID_PROPERTIES)
self.Bind(wx.EVT_MENU, self._on_transfer_queue, id=ID_TRANSFER_QUEUE)
self.Bind(wx.EVT_MENU, self._on_settings, id=ID_SETTINGS)
+ self.Bind(wx.EVT_MENU, self._on_import_connections, id=ID_IMPORT_CONNECTIONS)
self.Bind(wx.EVT_MENU, self._on_about, id=wx.ID_ABOUT)
self.Bind(get_transfer_event_binder(), self._on_transfer_update)
@@ -424,6 +433,70 @@ def _on_save_connection(self, event: wx.CommandEvent) -> None:
self._announce(f"Site '{name}' saved")
dlg.Destroy()
+ def _on_import_connections(self, event: wx.CommandEvent) -> None:
+ dlg = ImportConnectionsDialog(self)
+ result = dlg.ShowModal()
+ selected_sites = dlg.selected_sites if result == wx.ID_OK else []
+ dlg.Destroy()
+
+ if not selected_sites:
+ return
+
+ duplicate_names: list[str] = []
+ imported_count = 0
+
+ existing = {
+ (
+ site.host.strip().lower(),
+ self._effective_site_port(site.protocol, site.port),
+ site.username.strip().lower(),
+ )
+ for site in self._site_manager.sites
+ }
+
+ for imported in selected_sites:
+ key = (
+ imported.host.strip().lower(),
+ self._effective_site_port(imported.protocol, imported.port),
+ imported.username.strip().lower(),
+ )
+ if key in existing:
+ duplicate_names.append(imported.name or imported.host)
+ continue
+
+ site = Site(
+ name=imported.name or imported.host,
+ protocol=imported.protocol,
+ host=imported.host,
+ port=imported.port,
+ username=imported.username,
+ password=imported.password,
+ key_path=imported.key_path,
+ initial_dir=imported.initial_dir or "/",
+ notes=imported.notes,
+ )
+ self._site_manager.add(site)
+ existing.add(key)
+ imported_count += 1
+
+ message = f"Imported {imported_count} connection{'s' if imported_count != 1 else ''}."
+ if duplicate_names:
+ dup_preview = ", ".join(duplicate_names[:5])
+ if len(duplicate_names) > 5:
+ dup_preview += ", ..."
+ message += (
+ f"\nSkipped {len(duplicate_names)} duplicate"
+ f"{'s' if len(duplicate_names) != 1 else ''}: {dup_preview}"
+ )
+
+ wx.MessageBox(message, "Import Connections", wx.OK | wx.ICON_INFORMATION, self)
+
+ def _effective_site_port(self, protocol: str, port: int) -> int:
+ if port > 0:
+ return port
+ defaults = {"sftp": 22, "ftp": 21, "ftps": 990}
+ return defaults.get(protocol, 22)
+
def _host_key_policy(self) -> HostKeyPolicy:
"""Map the verify_host_keys setting string to a HostKeyPolicy enum value."""
mapping = {
diff --git a/src/portkeydrop/dialogs/import_connections.py b/src/portkeydrop/dialogs/import_connections.py
new file mode 100644
index 0000000..0dc8fdf
--- /dev/null
+++ b/src/portkeydrop/dialogs/import_connections.py
@@ -0,0 +1,289 @@
+"""Wizard dialog for importing saved connection profiles."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import wx
+
+from portkeydrop.importers import SOURCES, detect_default_path, load_from_source
+from portkeydrop.importers.models import ImportedSite
+
+
+class ImportConnectionsDialog(wx.Dialog):
+ """Wizard-style dialog for importing connection profiles."""
+
+ def __init__(self, parent: wx.Window | None) -> None:
+ super().__init__(
+ parent,
+ title="Import Connections",
+ size=(680, 480),
+ style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER,
+ )
+
+ self._step = 0
+ self._source = SOURCES[0].key
+ self._loaded_sites: list[ImportedSite] = []
+ self._selected_sites: list[ImportedSite] = []
+
+ self._build_ui()
+ self._update_step_ui()
+
+ def _build_ui(self) -> None:
+ root = wx.BoxSizer(wx.VERTICAL)
+
+ self.step_title = wx.StaticText(self, label="")
+ root.Add(self.step_title, 0, wx.ALL, 8)
+
+ self.pages = [self._build_source_page(), self._build_path_page(), self._build_preview_page()]
+ for page in self.pages:
+ root.Add(page, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 8)
+
+ nav = wx.BoxSizer(wx.HORIZONTAL)
+ self.back_btn = wx.Button(self, label="< &Back")
+ self.next_btn = wx.Button(self, label="&Next >")
+ self.import_btn = wx.Button(self, label="&Import")
+ cancel_btn = wx.Button(self, wx.ID_CANCEL)
+
+ nav.Add(self.back_btn, 0, wx.RIGHT, 6)
+ nav.Add(self.next_btn, 0, wx.RIGHT, 6)
+ nav.Add(self.import_btn, 0, wx.RIGHT, 6)
+ nav.AddStretchSpacer(1)
+ nav.Add(cancel_btn, 0)
+ root.Add(nav, 0, wx.EXPAND | wx.ALL, 8)
+
+ self.back_btn.Bind(wx.EVT_BUTTON, self._on_back)
+ self.next_btn.Bind(wx.EVT_BUTTON, self._on_next)
+ self.import_btn.Bind(wx.EVT_BUTTON, self._on_import)
+ self.source_radio.Bind(wx.EVT_RADIOBOX, self._on_source_change)
+ self.autodetect_btn.Bind(wx.EVT_BUTTON, self._on_autodetect)
+ self.browse_file_btn.Bind(wx.EVT_BUTTON, self._on_browse_file)
+ self.browse_folder_btn.Bind(wx.EVT_BUTTON, self._on_browse_folder)
+ self.select_all_btn.Bind(wx.EVT_BUTTON, self._on_select_all)
+ self.select_none_btn.Bind(wx.EVT_BUTTON, self._on_select_none)
+
+ self.SetSizer(root)
+
+ def _build_source_page(self) -> wx.Panel:
+ panel = wx.Panel(self)
+ sizer = wx.BoxSizer(wx.VERTICAL)
+
+ choices = [source.label for source in SOURCES]
+ self.source_radio = wx.RadioBox(
+ panel,
+ label="Choose source client",
+ choices=choices,
+ majorDimension=1,
+ style=wx.RA_SPECIFY_ROWS,
+ )
+ sizer.Add(self.source_radio, 0, wx.EXPAND | wx.ALL, 4)
+
+ panel.SetSizer(sizer)
+ return panel
+
+ def _build_path_page(self) -> wx.Panel:
+ panel = wx.Panel(self)
+ sizer = wx.BoxSizer(wx.VERTICAL)
+
+ description = wx.StaticText(
+ panel,
+ label=(
+ "Auto-detect the configuration path, or browse manually. "
+ "For Cyberduck you can select either a .duck file or Bookmarks folder."
+ ),
+ )
+ sizer.Add(description, 0, wx.EXPAND | wx.ALL, 4)
+
+ row = wx.BoxSizer(wx.HORIZONTAL)
+ self.path_text = wx.TextCtrl(panel)
+ row.Add(self.path_text, 1, wx.RIGHT | wx.EXPAND, 6)
+
+ self.autodetect_btn = wx.Button(panel, label="&Auto-Detect")
+ self.browse_file_btn = wx.Button(panel, label="Browse &File...")
+ self.browse_folder_btn = wx.Button(panel, label="Browse &Folder...")
+ row.Add(self.autodetect_btn, 0, wx.RIGHT, 4)
+ row.Add(self.browse_file_btn, 0, wx.RIGHT, 4)
+ row.Add(self.browse_folder_btn, 0)
+ sizer.Add(row, 0, wx.EXPAND | wx.ALL, 4)
+
+ panel.SetSizer(sizer)
+ return panel
+
+ def _build_preview_page(self) -> wx.Panel:
+ panel = wx.Panel(self)
+ sizer = wx.BoxSizer(wx.VERTICAL)
+
+ preview_note = wx.StaticText(panel, label="Select connections to import")
+ sizer.Add(preview_note, 0, wx.ALL, 4)
+
+ self.preview_list = wx.CheckListBox(panel)
+ sizer.Add(self.preview_list, 1, wx.EXPAND | wx.ALL, 4)
+
+ actions = wx.BoxSizer(wx.HORIZONTAL)
+ self.select_all_btn = wx.Button(panel, label="Select &All")
+ self.select_none_btn = wx.Button(panel, label="Select &None")
+ actions.Add(self.select_all_btn, 0, wx.RIGHT, 4)
+ actions.Add(self.select_none_btn, 0)
+ sizer.Add(actions, 0, wx.ALL, 4)
+
+ panel.SetSizer(sizer)
+ return panel
+
+ def _on_source_change(self, event: wx.CommandEvent) -> None:
+ self._source = SOURCES[self.source_radio.GetSelection()].key
+
+ def _on_autodetect(self, event: wx.CommandEvent) -> None:
+ source = SOURCES[self.source_radio.GetSelection()].key
+ default_path = detect_default_path(source)
+ if default_path is not None:
+ self.path_text.SetValue(str(default_path))
+
+ def _on_browse_file(self, event: wx.CommandEvent) -> None:
+ source = SOURCES[self.source_radio.GetSelection()].key
+ wildcard = self._file_wildcard_for_source(source)
+ with wx.FileDialog(
+ self,
+ "Select Configuration File",
+ wildcard=wildcard,
+ style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
+ ) as dlg:
+ if dlg.ShowModal() == wx.ID_OK:
+ self.path_text.SetValue(dlg.GetPath())
+
+ def _on_browse_folder(self, event: wx.CommandEvent) -> None:
+ with wx.DirDialog(self, "Select Configuration Folder", style=wx.DD_DIR_MUST_EXIST) as dlg:
+ if dlg.ShowModal() == wx.ID_OK:
+ self.path_text.SetValue(dlg.GetPath())
+
+ def _on_select_all(self, event: wx.CommandEvent) -> None:
+ for i in range(self.preview_list.GetCount()):
+ self.preview_list.Check(i, True)
+
+ def _on_select_none(self, event: wx.CommandEvent) -> None:
+ for i in range(self.preview_list.GetCount()):
+ self.preview_list.Check(i, False)
+
+ def _on_back(self, event: wx.CommandEvent) -> None:
+ if self._step > 0:
+ self._step -= 1
+ self._update_step_ui()
+
+ def _on_next(self, event: wx.CommandEvent) -> None:
+ if self._step == 0:
+ self._source = SOURCES[self.source_radio.GetSelection()].key
+ self._step = 1
+ self._update_step_ui()
+ return
+
+ if self._step == 1:
+ if not self._load_preview():
+ return
+ self._step = 2
+ self._update_step_ui()
+
+ def _on_import(self, event: wx.CommandEvent) -> None:
+ selected: list[ImportedSite] = []
+ for i, site in enumerate(self._loaded_sites):
+ if self.preview_list.IsChecked(i):
+ selected.append(site)
+
+ if not selected:
+ wx.MessageBox(
+ "Select at least one connection to import.",
+ "Import Connections",
+ wx.OK | wx.ICON_INFORMATION,
+ self,
+ )
+ return
+
+ self._selected_sites = selected
+ self.EndModal(wx.ID_OK)
+
+ def _load_preview(self) -> bool:
+ input_path = self.path_text.GetValue().strip()
+ path = Path(input_path).expanduser() if input_path else None
+
+ source = SOURCES[self.source_radio.GetSelection()].key
+ if source == "from_file" and not path:
+ wx.MessageBox(
+ "Choose a file or folder for 'From file...' import.",
+ "Import Connections",
+ wx.OK | wx.ICON_WARNING,
+ self,
+ )
+ return False
+
+ if source in {"filezilla", "cyberduck"} and path is None:
+ default_path = detect_default_path(source)
+ if default_path is not None:
+ path = default_path
+
+ if path and not path.exists():
+ wx.MessageBox(
+ f"Path does not exist:\n{path}",
+ "Import Connections",
+ wx.OK | wx.ICON_WARNING,
+ self,
+ )
+ return False
+
+ try:
+ self._loaded_sites = load_from_source(source, path)
+ except Exception as exc:
+ wx.MessageBox(
+ f"Failed to parse configuration: {exc}",
+ "Import Connections",
+ wx.OK | wx.ICON_ERROR,
+ self,
+ )
+ return False
+
+ if not self._loaded_sites:
+ wx.MessageBox(
+ "No connections were found in the selected source.",
+ "Import Connections",
+ wx.OK | wx.ICON_INFORMATION,
+ self,
+ )
+ return False
+
+ self._populate_preview(self._loaded_sites)
+ return True
+
+ def _populate_preview(self, sites: list[ImportedSite]) -> None:
+ self.preview_list.Clear()
+ for site in sites:
+ port = f":{site.port}" if site.port else ""
+ username = f" ({site.username})" if site.username else ""
+ label = f"{site.name} - {site.protocol}://{site.host}{port}{username}"
+ self.preview_list.Append(label)
+ self.preview_list.Check(self.preview_list.GetCount() - 1, True)
+
+ def _update_step_ui(self) -> None:
+ titles = [
+ "Step 1 of 3: Choose source client",
+ "Step 2 of 3: Detect or choose configuration path",
+ "Step 3 of 3: Select connections to import",
+ ]
+ self.step_title.SetLabel(titles[self._step])
+
+ for i, page in enumerate(self.pages):
+ page.Show(i == self._step)
+
+ self.back_btn.Enable(self._step > 0)
+ self.next_btn.Show(self._step < 2)
+ self.import_btn.Show(self._step == 2)
+ self.Layout()
+
+ def _file_wildcard_for_source(self, source: str) -> str:
+ if source == "filezilla":
+ return "FileZilla XML (*.xml)|*.xml|All files (*.*)|*.*"
+ if source == "winscp":
+ return "WinSCP INI (*.ini)|*.ini|All files (*.*)|*.*"
+ if source == "cyberduck":
+ return "Cyberduck bookmarks (*.duck)|*.duck|All files (*.*)|*.*"
+ return "Supported files (*.xml;*.ini;*.duck)|*.xml;*.ini;*.duck|All files (*.*)|*.*"
+
+ @property
+ def selected_sites(self) -> list[ImportedSite]:
+ return list(self._selected_sites)
diff --git a/src/portkeydrop/importers/__init__.py b/src/portkeydrop/importers/__init__.py
new file mode 100644
index 0000000..3a3a4f9
--- /dev/null
+++ b/src/portkeydrop/importers/__init__.py
@@ -0,0 +1,94 @@
+"""Connection profile importers for external FTP/SFTP clients."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+from . import cyberduck, filezilla, winscp
+from .models import ImportedSite
+
+
+@dataclass(frozen=True)
+class ImportSource:
+ key: str
+ label: str
+
+
+SOURCES = [
+ ImportSource("filezilla", "FileZilla"),
+ ImportSource("winscp", "WinSCP"),
+ ImportSource("cyberduck", "Cyberduck"),
+ ImportSource("from_file", "From file..."),
+]
+
+
+def detect_default_path(source: str) -> Path | None:
+ """Return the default path for the requested source client."""
+ if source == "filezilla":
+ return filezilla.detect_path()
+ if source == "winscp":
+ ini_path = winscp.detect_ini_path()
+ return ini_path
+ if source == "cyberduck":
+ return cyberduck.detect_bookmarks_dir()
+ return None
+
+
+def load_from_source(source: str, path: Path | None = None) -> list[ImportedSite]:
+ """Load imported profiles for a specific source and path."""
+ if source == "filezilla":
+ if not path:
+ path = filezilla.detect_path()
+ return filezilla.parse_file(path)
+
+ if source == "winscp":
+ if path:
+ return winscp.parse_ini_file(path)
+
+ ini_path = winscp.detect_ini_path()
+ if ini_path.exists():
+ return winscp.parse_ini_file(ini_path)
+ return winscp.parse_registry_sessions()
+
+ if source == "cyberduck":
+ if not path:
+ path = cyberduck.detect_bookmarks_dir()
+ if path.is_dir():
+ return cyberduck.parse_bookmarks_dir(path)
+ return [cyberduck.parse_bookmark_file(path)]
+
+ if source == "from_file":
+ if not path:
+ raise ValueError("Path is required for 'from_file' import")
+ return _load_from_unknown_path(path)
+
+ raise ValueError(f"Unknown import source: {source}")
+
+
+def _load_from_unknown_path(path: Path) -> list[ImportedSite]:
+ if path.is_dir():
+ sites = cyberduck.parse_bookmarks_dir(path)
+ if sites:
+ return sites
+
+ suffix = path.suffix.lower()
+ if suffix == ".ini":
+ return winscp.parse_ini_file(path)
+ if suffix == ".duck":
+ return [cyberduck.parse_bookmark_file(path)]
+
+ parse_attempts = (
+ lambda: filezilla.parse_file(path),
+ lambda: winscp.parse_ini_file(path),
+ lambda: [cyberduck.parse_bookmark_file(path)],
+ )
+ for parse_fn in parse_attempts:
+ try:
+ sites = parse_fn()
+ except Exception:
+ continue
+ if sites:
+ return sites
+
+ return []
diff --git a/src/portkeydrop/importers/cyberduck.py b/src/portkeydrop/importers/cyberduck.py
new file mode 100644
index 0000000..3328326
--- /dev/null
+++ b/src/portkeydrop/importers/cyberduck.py
@@ -0,0 +1,80 @@
+"""Cyberduck / Mountain Duck bookmark importer."""
+
+from __future__ import annotations
+
+import os
+import plistlib
+from pathlib import Path
+
+from .models import ImportedSite
+
+
+_PROTOCOL_MAP = {
+ "ftp": "ftp",
+ "ftps": "ftps",
+ "sftp": "sftp",
+ "ssh": "sftp",
+}
+
+
+def detect_bookmarks_dir() -> Path:
+ """Return default Cyberduck bookmarks directory for current platform."""
+ appdata = os.environ.get("APPDATA", "")
+ if appdata:
+ return Path(appdata) / "Cyberduck" / "Bookmarks"
+
+ mac_dir = Path.home() / "Library" / "Application Support" / "Cyberduck" / "Bookmarks"
+ if mac_dir.exists():
+ return mac_dir
+
+ return Path.home() / ".config" / "Cyberduck" / "Bookmarks"
+
+
+def parse_bookmark_file(path: Path) -> ImportedSite:
+ """Parse a single `.duck` bookmark file."""
+ with path.open("rb") as handle:
+ data = plistlib.load(handle)
+
+ host = str(data.get("Hostname", data.get("Host", ""))).strip()
+ protocol = _map_protocol(str(data.get("Protocol", "sftp")).strip())
+
+ raw_port = data.get("Port", 0)
+ try:
+ port = int(raw_port)
+ except (TypeError, ValueError):
+ port = 0
+
+ username = str(data.get("Username", "")).strip()
+ initial_dir = str(data.get("Path", "/")).strip() or "/"
+ nickname = str(data.get("Nickname", "")).strip()
+
+ name = nickname or (f"{username}@{host}" if username else host)
+ return ImportedSite(
+ name=name,
+ protocol=protocol,
+ host=host,
+ port=port,
+ username=username,
+ initial_dir=initial_dir,
+ )
+
+
+def parse_bookmarks_dir(path: Path) -> list[ImportedSite]:
+ """Parse all Cyberduck bookmarks in a directory."""
+ sites: list[ImportedSite] = []
+ if not path.exists():
+ return sites
+
+ for bookmark_path in sorted(path.glob("*.duck")):
+ try:
+ site = parse_bookmark_file(bookmark_path)
+ except Exception:
+ continue
+ if site.host:
+ sites.append(site)
+
+ return sites
+
+
+def _map_protocol(protocol: str) -> str:
+ return _PROTOCOL_MAP.get(protocol.lower(), "sftp")
diff --git a/src/portkeydrop/importers/filezilla.py b/src/portkeydrop/importers/filezilla.py
new file mode 100644
index 0000000..1a5930f
--- /dev/null
+++ b/src/portkeydrop/importers/filezilla.py
@@ -0,0 +1,111 @@
+"""FileZilla Site Manager importer."""
+
+from __future__ import annotations
+
+import base64
+import os
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from .models import ImportedSite
+
+_PROTOCOL_MAP = {
+ "0": "ftp",
+ "1": "sftp",
+ "3": "ftps",
+ "4": "ftps",
+}
+
+
+def detect_path() -> Path:
+ """Return default FileZilla Site Manager path for current platform."""
+ appdata = os.environ.get("APPDATA", "")
+ if appdata:
+ return Path(appdata) / "FileZilla" / "sitemanager.xml"
+ return Path.home() / ".config" / "filezilla" / "sitemanager.xml"
+
+
+def parse_file(path: Path) -> list[ImportedSite]:
+ """Parse a FileZilla `sitemanager.xml` file."""
+ root = ET.parse(path).getroot()
+ return _parse_root(root)
+
+
+def _parse_root(root: ET.Element) -> list[ImportedSite]:
+ sites: list[ImportedSite] = []
+ for server in root.findall(".//Server"):
+ host = (server.findtext("Host") or "").strip()
+ if not host:
+ continue
+
+ raw_protocol = (server.findtext("Protocol") or "1").strip()
+ protocol = _PROTOCOL_MAP.get(raw_protocol, "sftp")
+
+ raw_port = (server.findtext("Port") or "").strip()
+ port = int(raw_port) if raw_port.isdigit() else 0
+
+ username = (server.findtext("User") or "").strip()
+ password = _decode_password(server)
+
+ raw_remote_dir = (server.findtext("RemoteDir") or "").strip()
+ initial_dir = _parse_remote_dir(raw_remote_dir)
+
+ name = (server.findtext("Name") or f"{username}@{host}" or host).strip() or host
+ notes = (server.findtext("Comments") or "").strip()
+
+ sites.append(
+ ImportedSite(
+ name=name,
+ protocol=protocol,
+ host=host,
+ port=port,
+ username=username,
+ password=password,
+ initial_dir=initial_dir,
+ notes=notes,
+ )
+ )
+ return sites
+
+
+def _decode_password(server: ET.Element) -> str:
+ raw_password = (server.findtext("Pass") or "").strip()
+ if not raw_password:
+ return ""
+
+ pass_element = server.find("Pass")
+ encoding = pass_element.get("encoding", "") if pass_element is not None else ""
+ if encoding.lower() == "base64":
+ try:
+ return base64.b64decode(raw_password).decode("utf-8")
+ except Exception:
+ return ""
+ return raw_password
+
+
+def _parse_remote_dir(raw_remote_dir: str) -> str:
+ if not raw_remote_dir:
+ return "/"
+
+ # FileZilla stores path segments in an integer-prefixed format:
+ # e.g. "1 0 4 home 4 user" => "/home/user".
+ tokens = raw_remote_dir.split()
+ if len(tokens) >= 2 and tokens[0].isdigit() and tokens[1].isdigit():
+ segments: list[str] = []
+ i = 2
+ while i < len(tokens):
+ if not tokens[i].isdigit():
+ break
+ length = int(tokens[i])
+ i += 1
+ if i >= len(tokens):
+ break
+ segment = tokens[i]
+ i += 1
+ segments.append(segment[:length])
+ if segments:
+ return "/" + "/".join(segment.strip("/") for segment in segments if segment)
+
+ if raw_remote_dir.startswith("/"):
+ return raw_remote_dir
+ return "/" + raw_remote_dir.lstrip("/")
diff --git a/src/portkeydrop/importers/models.py b/src/portkeydrop/importers/models.py
new file mode 100644
index 0000000..3471012
--- /dev/null
+++ b/src/portkeydrop/importers/models.py
@@ -0,0 +1,20 @@
+"""Shared models for connection profile importers."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class ImportedSite:
+ """Normalized imported site profile."""
+
+ name: str
+ protocol: str
+ host: str
+ port: int
+ username: str = ""
+ password: str = ""
+ key_path: str = ""
+ initial_dir: str = "/"
+ notes: str = ""
diff --git a/src/portkeydrop/importers/winscp.py b/src/portkeydrop/importers/winscp.py
new file mode 100644
index 0000000..391001c
--- /dev/null
+++ b/src/portkeydrop/importers/winscp.py
@@ -0,0 +1,158 @@
+"""WinSCP profile importer (INI + optional Windows Registry)."""
+
+from __future__ import annotations
+
+import configparser
+import os
+import sys
+from pathlib import Path
+from urllib.parse import unquote
+
+from .models import ImportedSite
+
+
+_NUMERIC_PROTOCOL_MAP = {
+ "0": "sftp",
+ "1": "scp",
+ "5": "ftp",
+ "6": "ftps",
+}
+
+
+def detect_ini_path() -> Path:
+ """Return likely WinSCP INI path."""
+ appdata = os.environ.get("APPDATA", "")
+ if appdata:
+ return Path(appdata) / "WinSCP.ini"
+ return Path.home() / "WinSCP.ini"
+
+
+def parse_ini_file(path: Path) -> list[ImportedSite]:
+ """Parse WinSCP exported INI file."""
+ parser = configparser.RawConfigParser(interpolation=None)
+ parser.optionxform = str
+ parser.read(path, encoding="utf-8")
+
+ sites: list[ImportedSite] = []
+ for section in parser.sections():
+ if not section.startswith("Sessions\\"):
+ continue
+
+ cfg = parser[section]
+ host = cfg.get("HostName", "").strip()
+ if not host:
+ continue
+
+ raw_port = cfg.get("PortNumber", "").strip()
+ port = int(raw_port) if raw_port.isdigit() else 0
+
+ protocol = _detect_protocol(cfg)
+ if protocol == "scp":
+ protocol = "sftp"
+
+ username = cfg.get("UserName", "").strip()
+ initial_dir = cfg.get("RemoteDirectory", "").strip() or "/"
+ key_path = cfg.get("PublicKeyFile", "").strip()
+ name = _decode_name(section.removeprefix("Sessions\\"))
+
+ sites.append(
+ ImportedSite(
+ name=name,
+ protocol=protocol,
+ host=host,
+ port=port,
+ username=username,
+ key_path=key_path,
+ initial_dir=initial_dir,
+ )
+ )
+ return sites
+
+
+def parse_registry_sessions() -> list[ImportedSite]:
+ """Parse WinSCP session data from Windows Registry if available."""
+ if sys.platform != "win32":
+ return []
+
+ try:
+ import winreg
+ except Exception:
+ return []
+
+ key_path = r"Software\Martin Prikryl\WinSCP 2\Sessions"
+ sites: list[ImportedSite] = []
+
+ try:
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path) as sessions_key:
+ index = 0
+ while True:
+ try:
+ session_name = winreg.EnumKey(sessions_key, index)
+ except OSError:
+ break
+ index += 1
+
+ with winreg.OpenKey(sessions_key, session_name) as session_key:
+ values = _read_reg_values(winreg, session_key)
+ host = values.get("HostName", "").strip()
+ if not host:
+ continue
+
+ raw_port = values.get("PortNumber", "").strip()
+ port = int(raw_port) if raw_port.isdigit() else 0
+
+ protocol = _detect_protocol(values)
+ if protocol == "scp":
+ protocol = "sftp"
+
+ username = values.get("UserName", "").strip()
+ initial_dir = values.get("RemoteDirectory", "").strip() or "/"
+ key_path_value = values.get("PublicKeyFile", "").strip()
+
+ sites.append(
+ ImportedSite(
+ name=_decode_name(session_name),
+ protocol=protocol,
+ host=host,
+ port=port,
+ username=username,
+ key_path=key_path_value,
+ initial_dir=initial_dir,
+ )
+ )
+ except OSError:
+ return []
+
+ return sites
+
+
+def _read_reg_values(winreg, key) -> dict[str, str]:
+ values: dict[str, str] = {}
+ idx = 0
+ while True:
+ try:
+ value_name, value, _ = winreg.EnumValue(key, idx)
+ except OSError:
+ break
+ idx += 1
+ values[value_name] = str(value)
+ return values
+
+
+def _detect_protocol(cfg: configparser.SectionProxy | dict[str, str]) -> str:
+ protocol_value = str(cfg.get("FSProtocol", "")).strip()
+ if protocol_value in _NUMERIC_PROTOCOL_MAP:
+ return _NUMERIC_PROTOCOL_MAP[protocol_value]
+
+ file_protocol = str(cfg.get("FileProtocol", "")).strip().lower()
+ if file_protocol in {"ftp", "ftps", "sftp", "scp"}:
+ return file_protocol
+
+ if str(cfg.get("Ftps", "")).strip() in {"1", "true", "True"}:
+ return "ftps"
+
+ return "sftp"
+
+
+def _decode_name(raw_name: str) -> str:
+ return unquote(raw_name).replace("%5C", "\\")
diff --git a/tests/_wx_stub.py b/tests/_wx_stub.py
index 69deb01..126275b 100644
--- a/tests/_wx_stub.py
+++ b/tests/_wx_stub.py
@@ -126,6 +126,7 @@ def Close() -> None:
fake_wx.YES_NO = 102
fake_wx.ICON_WARNING = 103
fake_wx.ICON_ERROR = 104
+ fake_wx.ICON_INFORMATION = 105
fake_wx.ID_EXIT = 200
fake_wx.ID_ABOUT = 201
diff --git a/tests/fixtures/importers/cyberduck_bookmark.duck b/tests/fixtures/importers/cyberduck_bookmark.duck
new file mode 100644
index 0000000..5a2f669
--- /dev/null
+++ b/tests/fixtures/importers/cyberduck_bookmark.duck
@@ -0,0 +1,18 @@
+
+
+
+
+ Hostname
+ sftp.example.com
+ Port
+ 22
+ Protocol
+ sftp
+ Username
+ alice
+ Nickname
+ My SFTP
+ Path
+ /uploads
+
+
diff --git a/tests/fixtures/importers/filezilla_sitemanager.xml b/tests/fixtures/importers/filezilla_sitemanager.xml
new file mode 100644
index 0000000..d76f0d0
--- /dev/null
+++ b/tests/fixtures/importers/filezilla_sitemanager.xml
@@ -0,0 +1,24 @@
+
+
+
+
+ Prod SFTP
+ sftp.example.com
+ 2222
+ 1
+ alice
+ c2VjcmV0
+ 1 0 4 home 5 alice
+ Main production host
+
+
+ Legacy FTP
+ ftp.example.com
+ 21
+ 0
+ bob
+ plaintext
+ /incoming
+
+
+
diff --git a/tests/fixtures/importers/winscp_sessions.ini b/tests/fixtures/importers/winscp_sessions.ini
new file mode 100644
index 0000000..229d5fe
--- /dev/null
+++ b/tests/fixtures/importers/winscp_sessions.ini
@@ -0,0 +1,17 @@
+[Configuration\Interface]
+RandomValue=1
+
+[Sessions\Prod%20Server]
+HostName=sftp.example.com
+PortNumber=22
+FSProtocol=0
+UserName=alice
+RemoteDirectory=/home/alice
+PublicKeyFile=C:\keys\id_ed25519.ppk
+
+[Sessions\FTP%20Server]
+HostName=ftp.example.com
+PortNumber=21
+FileProtocol=ftp
+UserName=bob
+RemoteDirectory=/incoming
diff --git a/tests/test_app.py b/tests/test_app.py
index ca90bfb..478a317 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -260,6 +260,77 @@ def test_mkdir_remote_reports_error(app_module):
fake_wx.MessageBox.assert_called()
+def test_import_connections_adds_non_duplicates(app_module):
+ app, fake_wx = app_module
+ frame = _hydrate_frame(app_module)
+
+ existing = SimpleNamespace(host="dup.example.com", port=22, username="alice", protocol="sftp")
+ frame._site_manager = MagicMock(sites=[existing], add=MagicMock())
+
+ imported_site = SimpleNamespace(
+ name="New Site",
+ protocol="ftp",
+ host="new.example.com",
+ port=21,
+ username="bob",
+ password="pw",
+ key_path="",
+ initial_dir="/",
+ notes="",
+ )
+
+ dialog = MagicMock(
+ ShowModal=MagicMock(return_value=fake_wx.ID_OK),
+ selected_sites=[imported_site],
+ Destroy=MagicMock(),
+ )
+ fake_wx.MessageBox.reset_mock()
+
+ with patch.object(app, "ImportConnectionsDialog", return_value=dialog):
+ frame._on_import_connections(None)
+
+ frame._site_manager.add.assert_called_once()
+ fake_wx.MessageBox.assert_called_once()
+ message = fake_wx.MessageBox.call_args.args[0]
+ assert "Imported 1 connection" in message
+
+
+def test_import_connections_skips_duplicates(app_module):
+ app, fake_wx = app_module
+ frame = _hydrate_frame(app_module)
+
+ existing = SimpleNamespace(host="dup.example.com", port=22, username="alice", protocol="sftp")
+ frame._site_manager = MagicMock(sites=[existing], add=MagicMock())
+
+ duplicate = SimpleNamespace(
+ name="Duplicate Site",
+ protocol="sftp",
+ host="dup.example.com",
+ port=22,
+ username="alice",
+ password="pw",
+ key_path="",
+ initial_dir="/",
+ notes="",
+ )
+
+ dialog = MagicMock(
+ ShowModal=MagicMock(return_value=fake_wx.ID_OK),
+ selected_sites=[duplicate],
+ Destroy=MagicMock(),
+ )
+ fake_wx.MessageBox.reset_mock()
+
+ with patch.object(app, "ImportConnectionsDialog", return_value=dialog):
+ frame._on_import_connections(None)
+
+ frame._site_manager.add.assert_not_called()
+ fake_wx.MessageBox.assert_called_once()
+ message = fake_wx.MessageBox.call_args.args[0]
+ assert "Imported 0 connections" in message
+ assert "Skipped 1 duplicate" in message
+
+
def test_on_transfer_update_reports_latest_status(app_module):
import importlib
diff --git a/tests/test_importers_cyberduck.py b/tests/test_importers_cyberduck.py
new file mode 100644
index 0000000..cab1680
--- /dev/null
+++ b/tests/test_importers_cyberduck.py
@@ -0,0 +1,15 @@
+from pathlib import Path
+
+from portkeydrop.importers.cyberduck import parse_bookmark_file
+
+
+def test_parse_cyberduck_bookmark_fixture():
+ fixture = Path("tests/fixtures/importers/cyberduck_bookmark.duck")
+ site = parse_bookmark_file(fixture)
+
+ assert site.name == "My SFTP"
+ assert site.protocol == "sftp"
+ assert site.host == "sftp.example.com"
+ assert site.port == 22
+ assert site.username == "alice"
+ assert site.initial_dir == "/uploads"
diff --git a/tests/test_importers_filezilla.py b/tests/test_importers_filezilla.py
new file mode 100644
index 0000000..0c1fa59
--- /dev/null
+++ b/tests/test_importers_filezilla.py
@@ -0,0 +1,25 @@
+from pathlib import Path
+
+from portkeydrop.importers.filezilla import parse_file
+
+
+def test_parse_filezilla_sites_fixture():
+ fixture = Path("tests/fixtures/importers/filezilla_sitemanager.xml")
+ sites = parse_file(fixture)
+
+ assert len(sites) == 2
+
+ first = sites[0]
+ assert first.name == "Prod SFTP"
+ assert first.protocol == "sftp"
+ assert first.host == "sftp.example.com"
+ assert first.port == 2222
+ assert first.username == "alice"
+ assert first.password == "secret"
+ assert first.initial_dir == "/home/alice"
+ assert first.notes == "Main production host"
+
+ second = sites[1]
+ assert second.protocol == "ftp"
+ assert second.password == "plaintext"
+ assert second.initial_dir == "/incoming"
diff --git a/tests/test_importers_winscp.py b/tests/test_importers_winscp.py
new file mode 100644
index 0000000..fdf5e22
--- /dev/null
+++ b/tests/test_importers_winscp.py
@@ -0,0 +1,25 @@
+from pathlib import Path
+
+from portkeydrop.importers.winscp import parse_ini_file
+
+
+def test_parse_winscp_ini_fixture():
+ fixture = Path("tests/fixtures/importers/winscp_sessions.ini")
+ sites = parse_ini_file(fixture)
+
+ assert len(sites) == 2
+
+ first = sites[0]
+ assert first.name == "Prod Server"
+ assert first.protocol == "sftp"
+ assert first.host == "sftp.example.com"
+ assert first.port == 22
+ assert first.username == "alice"
+ assert first.initial_dir == "/home/alice"
+ assert first.key_path == "C:\\keys\\id_ed25519.ppk"
+
+ second = sites[1]
+ assert second.name == "FTP Server"
+ assert second.protocol == "ftp"
+ assert second.host == "ftp.example.com"
+ assert second.port == 21
From a8e8a34e8333328dcb71b42f49d25eca3d9f3213 Mon Sep 17 00:00:00 2001
From: Orinks <38449772+Orinks@users.noreply.github.com>
Date: Sat, 28 Feb 2026 14:33:15 +0000
Subject: [PATCH 02/10] fix(import): auto-detect on client select and expose
WinSCP registry
Auto-detect now fires immediately when the user selects a client from
the dropdown, pre-filling the path field. The Auto-Detect button remains
as a manual retry fallback.
For WinSCP, detect_default_path now checks the Windows Registry first
(HKCU\Software\Martin Prikryl\WinSCP 2\Sessions) and falls back to the
INI file. A sentinel string is displayed in the path field when registry
is used, and load_from_source handles it by calling parse_registry_sessions().
Co-Authored-By: Claude Opus 4.6
---
src/portkeydrop/dialogs/import_connections.py | 20 +-
src/portkeydrop/importers/__init__.py | 23 +-
tests/test_import_connections_dialog.py | 269 ++++++++++++++++++
tests/test_importers_winscp.py | 31 ++
4 files changed, 338 insertions(+), 5 deletions(-)
create mode 100644 tests/test_import_connections_dialog.py
diff --git a/src/portkeydrop/dialogs/import_connections.py b/src/portkeydrop/dialogs/import_connections.py
index 0dc8fdf..badae58 100644
--- a/src/portkeydrop/dialogs/import_connections.py
+++ b/src/portkeydrop/dialogs/import_connections.py
@@ -6,7 +6,12 @@
import wx
-from portkeydrop.importers import SOURCES, detect_default_path, load_from_source
+from portkeydrop.importers import (
+ SOURCES,
+ WINSCP_REGISTRY_SENTINEL,
+ detect_default_path,
+ load_from_source,
+)
from portkeydrop.importers.models import ImportedSite
@@ -35,7 +40,11 @@ def _build_ui(self) -> None:
self.step_title = wx.StaticText(self, label="")
root.Add(self.step_title, 0, wx.ALL, 8)
- self.pages = [self._build_source_page(), self._build_path_page(), self._build_preview_page()]
+ self.pages = [
+ self._build_source_page(),
+ self._build_path_page(),
+ self._build_preview_page(),
+ ]
for page in self.pages:
root.Add(page, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 8)
@@ -131,8 +140,12 @@ def _build_preview_page(self) -> wx.Panel:
def _on_source_change(self, event: wx.CommandEvent) -> None:
self._source = SOURCES[self.source_radio.GetSelection()].key
+ self._run_autodetect()
def _on_autodetect(self, event: wx.CommandEvent) -> None:
+ self._run_autodetect()
+
+ def _run_autodetect(self) -> None:
source = SOURCES[self.source_radio.GetSelection()].key
default_path = detect_default_path(source)
if default_path is not None:
@@ -201,7 +214,8 @@ def _on_import(self, event: wx.CommandEvent) -> None:
def _load_preview(self) -> bool:
input_path = self.path_text.GetValue().strip()
- path = Path(input_path).expanduser() if input_path else None
+ use_registry = input_path == WINSCP_REGISTRY_SENTINEL
+ path = None if use_registry else (Path(input_path).expanduser() if input_path else None)
source = SOURCES[self.source_radio.GetSelection()].key
if source == "from_file" and not path:
diff --git a/src/portkeydrop/importers/__init__.py b/src/portkeydrop/importers/__init__.py
index 3a3a4f9..7eaf922 100644
--- a/src/portkeydrop/importers/__init__.py
+++ b/src/portkeydrop/importers/__init__.py
@@ -2,12 +2,15 @@
from __future__ import annotations
+import sys
from dataclasses import dataclass
from pathlib import Path
from . import cyberduck, filezilla, winscp
from .models import ImportedSite
+WINSCP_REGISTRY_SENTINEL = r"Registry (HKCU\Software\Martin Prikryl\WinSCP 2\Sessions)"
+
@dataclass(frozen=True)
class ImportSource:
@@ -23,11 +26,27 @@ class ImportSource:
]
-def detect_default_path(source: str) -> Path | None:
- """Return the default path for the requested source client."""
+def _winscp_registry_available() -> bool:
+ """Check whether WinSCP sessions exist in the Windows Registry."""
+ if sys.platform != "win32":
+ return False
+ try:
+ import winreg
+
+ key_path = r"Software\Martin Prikryl\WinSCP 2\Sessions"
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path):
+ return True
+ except Exception:
+ return False
+
+
+def detect_default_path(source: str) -> Path | str | None:
+ """Return the default path (or sentinel) for the requested source client."""
if source == "filezilla":
return filezilla.detect_path()
if source == "winscp":
+ if _winscp_registry_available():
+ return WINSCP_REGISTRY_SENTINEL
ini_path = winscp.detect_ini_path()
return ini_path
if source == "cyberduck":
diff --git a/tests/test_import_connections_dialog.py b/tests/test_import_connections_dialog.py
new file mode 100644
index 0000000..b0e03dd
--- /dev/null
+++ b/tests/test_import_connections_dialog.py
@@ -0,0 +1,269 @@
+"""Tests for ImportConnectionsDialog (headless, wx-stubbed)."""
+
+from __future__ import annotations
+
+import importlib
+import sys
+import types
+from unittest.mock import patch
+
+
+# ---------------------------------------------------------------------------
+# Minimal wx stubs for the ImportConnectionsDialog
+# ---------------------------------------------------------------------------
+
+
+class _Window:
+ def __init__(self, parent=None, **_kw):
+ self.parent = parent
+ self.children: list[_Window] = []
+ self._bound: list[tuple] = []
+ if parent is not None and hasattr(parent, "children"):
+ parent.children.append(self)
+
+ def Bind(self, event, handler):
+ self._bound.append((event, handler))
+
+ def Show(self, show=True) -> None:
+ self._shown = show
+
+ def Enable(self, enable=True) -> None:
+ self._enabled = enable
+
+
+class _Dialog(_Window):
+ def __init__(self, parent=None, title: str = "", size=None, style: int = 0, **_kw):
+ super().__init__(parent)
+ self.title = title
+
+ def SetSizer(self, sizer) -> None:
+ pass
+
+ def Layout(self) -> None:
+ pass
+
+ def EndModal(self, result: int) -> None:
+ self._modal_result = result
+
+
+class _Panel(_Window):
+ def SetSizer(self, sizer) -> None:
+ pass
+
+
+class _StaticText(_Window):
+ def __init__(self, parent=None, label: str = "", **_kw):
+ super().__init__(parent)
+ self._label = label
+
+ def SetLabel(self, label: str) -> None:
+ self._label = label
+
+
+class _TextCtrl(_Window):
+ def __init__(self, parent=None, **_kw):
+ super().__init__(parent)
+ self._value = ""
+
+ def GetValue(self) -> str:
+ return self._value
+
+ def SetValue(self, value: str) -> None:
+ self._value = value
+
+
+class _RadioBox(_Window):
+ def __init__(self, parent=None, label: str = "", choices=None, **_kw):
+ super().__init__(parent)
+ self._choices = choices or []
+ self._selection = 0
+
+ def GetSelection(self) -> int:
+ return self._selection
+
+ def SetSelection(self, index: int) -> None:
+ self._selection = index
+
+
+class _CheckListBox(_Window):
+ def __init__(self, parent=None, **_kw):
+ super().__init__(parent)
+ self._items: list[str] = []
+ self._checked: dict[int, bool] = {}
+
+ def Clear(self) -> None:
+ self._items.clear()
+ self._checked.clear()
+
+ def Append(self, label: str) -> None:
+ self._items.append(label)
+
+ def GetCount(self) -> int:
+ return len(self._items)
+
+ def Check(self, index: int, check: bool = True) -> None:
+ self._checked[index] = check
+
+ def IsChecked(self, index: int) -> bool:
+ return self._checked.get(index, False)
+
+
+class _Button(_Window):
+ def __init__(self, parent=None, id: int = -1, label: str = "", **_kw):
+ super().__init__(parent)
+ self.label = label
+
+
+class _BoxSizer:
+ def __init__(self, orient=0):
+ pass
+
+ def Add(self, *args, **kwargs):
+ pass
+
+ def AddStretchSpacer(self, *args, **kwargs):
+ pass
+
+
+class _CommandEvent:
+ pass
+
+
+def _make_wx_module():
+ fake_wx = types.ModuleType("wx")
+ fake_wx.Dialog = _Dialog
+ fake_wx.Panel = _Panel
+ fake_wx.StaticText = _StaticText
+ fake_wx.TextCtrl = _TextCtrl
+ fake_wx.RadioBox = _RadioBox
+ fake_wx.CheckListBox = _CheckListBox
+ fake_wx.Button = _Button
+ fake_wx.BoxSizer = _BoxSizer
+ fake_wx.CommandEvent = _CommandEvent
+ fake_wx.VERTICAL = 0
+ fake_wx.HORIZONTAL = 1
+ fake_wx.DEFAULT_DIALOG_STYLE = 1
+ fake_wx.RESIZE_BORDER = 2
+ fake_wx.ALL = 0x10
+ fake_wx.LEFT = 0x20
+ fake_wx.RIGHT = 0x40
+ fake_wx.EXPAND = 0x80
+ fake_wx.RA_SPECIFY_ROWS = 0
+ fake_wx.ID_CANCEL = 5101
+ fake_wx.ID_OK = 5100
+ fake_wx.OK = 0x04
+ fake_wx.ICON_WARNING = 0x100
+ fake_wx.ICON_ERROR = 0x200
+ fake_wx.ICON_INFORMATION = 0x800
+ fake_wx.FD_OPEN = 0x01
+ fake_wx.FD_FILE_MUST_EXIST = 0x02
+ fake_wx.DD_DIR_MUST_EXIST = 0x04
+ fake_wx.EVT_RADIOBOX = object()
+ fake_wx.EVT_BUTTON = object()
+ fake_wx.MessageBox = lambda *a, **kw: None
+ return fake_wx
+
+
+def _load_dialog(monkeypatch):
+ """Import ImportConnectionsDialog with fake wx and return the class."""
+ fake_wx = _make_wx_module()
+ monkeypatch.setitem(sys.modules, "wx", fake_wx)
+
+ sys.modules.pop("portkeydrop.dialogs.import_connections", None)
+ mod = importlib.import_module("portkeydrop.dialogs.import_connections")
+ return mod.ImportConnectionsDialog
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+class TestAutoDetectOnSourceChange:
+ """Auto-detect should fire automatically when the client selection changes."""
+
+ def test_source_change_triggers_autodetect(self, monkeypatch):
+ """Selecting a client from the dropdown should auto-detect and pre-fill the path."""
+ DialogCls = _load_dialog(monkeypatch)
+ dlg = DialogCls(None)
+
+ # Select WinSCP (index 1)
+ dlg.source_radio.SetSelection(1)
+
+ with patch(
+ "portkeydrop.dialogs.import_connections.detect_default_path",
+ return_value="/fake/WinSCP.ini",
+ ):
+ dlg._on_source_change(_CommandEvent())
+
+ assert dlg.path_text.GetValue() == "/fake/WinSCP.ini"
+
+ def test_source_change_clears_path_when_no_default(self, monkeypatch):
+ """When no default is detected, the path should stay empty."""
+ DialogCls = _load_dialog(monkeypatch)
+ dlg = DialogCls(None)
+
+ dlg.source_radio.SetSelection(3) # "From file..." — no auto-detect
+
+ with patch(
+ "portkeydrop.dialogs.import_connections.detect_default_path",
+ return_value=None,
+ ):
+ dlg._on_source_change(_CommandEvent())
+
+ assert dlg.path_text.GetValue() == ""
+
+ def test_autodetect_button_still_works_as_manual_retry(self, monkeypatch):
+ """The Auto-Detect button should work as a manual retry."""
+ DialogCls = _load_dialog(monkeypatch)
+ dlg = DialogCls(None)
+
+ dlg.source_radio.SetSelection(0) # FileZilla
+
+ with patch(
+ "portkeydrop.dialogs.import_connections.detect_default_path",
+ return_value="/fake/filezilla.xml",
+ ):
+ dlg._on_autodetect(_CommandEvent())
+
+ assert dlg.path_text.GetValue() == "/fake/filezilla.xml"
+
+
+class TestWinSCPRegistrySentinel:
+ """The dialog should handle the WinSCP registry sentinel correctly."""
+
+ def test_source_change_to_winscp_shows_registry_sentinel(self, monkeypatch):
+ """When WinSCP is selected and registry is available, show registry sentinel."""
+ DialogCls = _load_dialog(monkeypatch)
+ from portkeydrop.importers import WINSCP_REGISTRY_SENTINEL
+
+ dlg = DialogCls(None)
+ dlg.source_radio.SetSelection(1) # WinSCP
+
+ with patch(
+ "portkeydrop.dialogs.import_connections.detect_default_path",
+ return_value=WINSCP_REGISTRY_SENTINEL,
+ ):
+ dlg._on_source_change(_CommandEvent())
+
+ assert dlg.path_text.GetValue() == WINSCP_REGISTRY_SENTINEL
+
+ def test_load_preview_with_registry_sentinel_passes_none_path(self, monkeypatch):
+ """When the path text contains the registry sentinel, load_from_source gets path=None."""
+ DialogCls = _load_dialog(monkeypatch)
+ from portkeydrop.importers import WINSCP_REGISTRY_SENTINEL
+ from portkeydrop.importers.models import ImportedSite
+
+ dlg = DialogCls(None)
+ dlg.source_radio.SetSelection(1) # WinSCP
+ dlg.path_text.SetValue(WINSCP_REGISTRY_SENTINEL)
+
+ fake_site = ImportedSite(name="test", protocol="sftp", host="example.com", port=22)
+ with patch(
+ "portkeydrop.dialogs.import_connections.load_from_source",
+ return_value=[fake_site],
+ ) as mock_load:
+ result = dlg._load_preview()
+
+ assert result is True
+ mock_load.assert_called_once_with("winscp", None)
diff --git a/tests/test_importers_winscp.py b/tests/test_importers_winscp.py
index fdf5e22..2b5d5e3 100644
--- a/tests/test_importers_winscp.py
+++ b/tests/test_importers_winscp.py
@@ -1,5 +1,11 @@
from pathlib import Path
+from unittest.mock import patch
+from portkeydrop.importers import (
+ WINSCP_REGISTRY_SENTINEL,
+ detect_default_path,
+ load_from_source,
+)
from portkeydrop.importers.winscp import parse_ini_file
@@ -23,3 +29,28 @@ def test_parse_winscp_ini_fixture():
assert second.protocol == "ftp"
assert second.host == "ftp.example.com"
assert second.port == 21
+
+
+def test_detect_default_path_returns_sentinel_when_registry_available():
+ """detect_default_path should return the registry sentinel when the registry is available."""
+ with patch("portkeydrop.importers._winscp_registry_available", return_value=True):
+ result = detect_default_path("winscp")
+ assert result == WINSCP_REGISTRY_SENTINEL
+
+
+def test_detect_default_path_falls_back_to_ini_when_no_registry():
+ """detect_default_path should return the INI path when registry is not available."""
+ with patch("portkeydrop.importers._winscp_registry_available", return_value=False):
+ result = detect_default_path("winscp")
+ assert isinstance(result, Path)
+ assert result.name == "WinSCP.ini"
+
+
+def test_load_from_source_winscp_none_path_tries_registry():
+ """load_from_source with path=None should try INI then fall back to registry."""
+ with patch("portkeydrop.importers.winscp.detect_ini_path") as mock_ini:
+ mock_ini.return_value = Path("/nonexistent/WinSCP.ini")
+ with patch("portkeydrop.importers.winscp.parse_registry_sessions") as mock_reg:
+ mock_reg.return_value = []
+ load_from_source("winscp", None)
+ mock_reg.assert_called_once()
From 1c5f202cd434ac3e107aedac7c8d5de8f07f615c Mon Sep 17 00:00:00 2001
From: Orinks <38449772+Orinks@users.noreply.github.com>
Date: Sat, 28 Feb 2026 15:00:18 +0000
Subject: [PATCH 03/10] test(importers): add coverage tests for importer gaps
to pass 80% gate
---
tests/test_importers_coverage.py | 322 +++++++++++++++++++++++++++++++
1 file changed, 322 insertions(+)
create mode 100644 tests/test_importers_coverage.py
diff --git a/tests/test_importers_coverage.py b/tests/test_importers_coverage.py
new file mode 100644
index 0000000..10d09b7
--- /dev/null
+++ b/tests/test_importers_coverage.py
@@ -0,0 +1,322 @@
+"""Additional tests to hit coverage gaps in importers."""
+
+from __future__ import annotations
+
+import os
+import plistlib
+import sys
+import xml.etree.ElementTree as ET
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from portkeydrop.importers import (
+ WINSCP_REGISTRY_SENTINEL,
+ _load_from_unknown_path,
+ _winscp_registry_available,
+ detect_default_path,
+ load_from_source,
+)
+from portkeydrop.importers.cyberduck import (
+ _map_protocol,
+ detect_bookmarks_dir,
+ parse_bookmark_file,
+ parse_bookmarks_dir,
+)
+from portkeydrop.importers.filezilla import (
+ _decode_password,
+ _parse_remote_dir,
+ detect_path,
+ parse_file,
+)
+from portkeydrop.importers.winscp import (
+ _decode_name,
+ _detect_protocol,
+ detect_ini_path,
+ parse_ini_file,
+ parse_registry_sessions,
+)
+
+
+# ---------------------------------------------------------------------------
+# __init__
+# ---------------------------------------------------------------------------
+
+def test_winscp_registry_available_non_windows():
+ with patch.object(sys, "platform", "linux"):
+ assert _winscp_registry_available() is False
+
+def test_detect_default_path_filezilla():
+ result = detect_default_path("filezilla")
+ assert "sitemanager.xml" in str(result)
+
+def test_detect_default_path_cyberduck():
+ result = detect_default_path("cyberduck")
+ assert "Cyberduck" in str(result) or "cyberduck" in str(result).lower()
+
+def test_detect_default_path_unknown():
+ assert detect_default_path("unknown_client") is None
+
+def test_load_from_source_filezilla(tmp_path):
+ xml = 'example.com122bobc2VjcmV0Test'
+ f = tmp_path / "sitemanager.xml"
+ f.write_text(xml)
+ sites = load_from_source("filezilla", f)
+ assert len(sites) == 1
+
+def test_load_from_source_filezilla_auto_detect(tmp_path):
+ xml = 'auto.com122uAuto'
+ f = tmp_path / "sitemanager.xml"
+ f.write_text(xml)
+ with patch("portkeydrop.importers.filezilla.detect_path", return_value=f):
+ sites = load_from_source("filezilla", None)
+ assert len(sites) == 1
+
+def test_load_from_source_winscp_with_path(tmp_path):
+ ini = tmp_path / "WinSCP.ini"
+ ini.write_text("[Sessions\\MyServer]\nHostName=sftp.example.com\nPortNumber=22\nUserName=alice\n")
+ sites = load_from_source("winscp", ini)
+ assert len(sites) == 1
+
+def test_load_from_source_winscp_ini_exists(tmp_path):
+ ini = tmp_path / "WinSCP.ini"
+ ini.write_text("[Sessions\\Server1]\nHostName=host1.com\nPortNumber=22\nUserName=user\n")
+ with patch("portkeydrop.importers.winscp.detect_ini_path", return_value=ini):
+ sites = load_from_source("winscp", None)
+ assert len(sites) >= 1
+
+def test_load_from_source_winscp_registry_fallback(tmp_path):
+ ini = tmp_path / "WinSCP.ini" # doesn't exist
+ with patch("portkeydrop.importers.winscp.detect_ini_path", return_value=ini):
+ with patch("portkeydrop.importers.winscp.parse_registry_sessions", return_value=[]) as mock_reg:
+ load_from_source("winscp", None)
+ mock_reg.assert_called_once()
+
+def test_load_from_source_cyberduck_dir(tmp_path):
+ duck = tmp_path / "test.duck"
+ data = {"Hostname": "sftp.example.com", "Protocol": "sftp", "Port": 22, "Username": "alice", "Path": "/uploads", "Nickname": "Test"}
+ duck.write_bytes(plistlib.dumps(data))
+ sites = load_from_source("cyberduck", tmp_path)
+ assert len(sites) == 1
+
+def test_load_from_source_cyberduck_single_file(tmp_path):
+ duck = tmp_path / "test.duck"
+ data = {"Hostname": "host.com", "Protocol": "sftp", "Port": 22, "Username": "u", "Path": "/", "Nickname": "X"}
+ duck.write_bytes(plistlib.dumps(data))
+ sites = load_from_source("cyberduck", duck)
+ assert len(sites) == 1
+
+def test_load_from_source_cyberduck_auto_detect(tmp_path):
+ duck = tmp_path / "b.duck"
+ data = {"Hostname": "cd.com", "Protocol": "sftp", "Port": 22, "Username": "u", "Path": "/", "Nickname": "B"}
+ duck.write_bytes(plistlib.dumps(data))
+ with patch("portkeydrop.importers.cyberduck.detect_bookmarks_dir", return_value=tmp_path):
+ sites = load_from_source("cyberduck", None)
+ assert len(sites) >= 1
+
+def test_load_from_source_from_file_no_path():
+ with pytest.raises(ValueError, match="Path is required"):
+ load_from_source("from_file", None)
+
+def test_load_from_source_unknown_source():
+ with pytest.raises(ValueError, match="Unknown import source"):
+ load_from_source("bogus", None)
+
+def test_load_from_unknown_path_ini(tmp_path):
+ ini = tmp_path / "WinSCP.ini"
+ ini.write_text("[Sessions\\S]\nHostName=h.com\nPortNumber=22\nUserName=u\n")
+ sites = _load_from_unknown_path(ini)
+ assert len(sites) >= 1
+
+def test_load_from_unknown_path_duck(tmp_path):
+ duck = tmp_path / "x.duck"
+ data = {"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Username": "u", "Path": "/", "Nickname": "X"}
+ duck.write_bytes(plistlib.dumps(data))
+ sites = _load_from_unknown_path(duck)
+ assert len(sites) == 1
+
+def test_load_from_unknown_path_xml(tmp_path):
+ xml = 'h.com122uX'
+ f = tmp_path / "sites.xml"
+ f.write_text(xml)
+ sites = _load_from_unknown_path(f)
+ assert len(sites) >= 1
+
+def test_load_from_unknown_path_dir_cyberduck(tmp_path):
+ duck = tmp_path / "x.duck"
+ data = {"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Username": "u", "Path": "/", "Nickname": "X"}
+ duck.write_bytes(plistlib.dumps(data))
+ sites = _load_from_unknown_path(tmp_path)
+ assert len(sites) == 1
+
+def test_load_from_unknown_path_empty_dir(tmp_path):
+ assert _load_from_unknown_path(tmp_path) == []
+
+
+# ---------------------------------------------------------------------------
+# winscp
+# ---------------------------------------------------------------------------
+
+def test_detect_ini_path_appdata(monkeypatch):
+ monkeypatch.setenv("APPDATA", "/fake/appdata")
+ assert str(detect_ini_path()) == "/fake/appdata/WinSCP.ini"
+
+def test_detect_ini_path_no_appdata(monkeypatch):
+ monkeypatch.delenv("APPDATA", raising=False)
+ assert detect_ini_path().name == "WinSCP.ini"
+
+def test_parse_ini_skips_non_session_sections(tmp_path):
+ ini = tmp_path / "w.ini"
+ ini.write_text("[Configuration]\nKey=Value\n[Sessions\\MyHost]\nHostName=h.com\nPortNumber=22\nUserName=u\n")
+ assert len(parse_ini_file(ini)) == 1
+
+def test_parse_ini_skips_missing_hostname(tmp_path):
+ ini = tmp_path / "w.ini"
+ ini.write_text("[Sessions\\NoHost]\nPortNumber=22\nUserName=u\n")
+ assert parse_ini_file(ini) == []
+
+def test_parse_ini_scp_mapped_to_sftp(tmp_path):
+ ini = tmp_path / "w.ini"
+ ini.write_text("[Sessions\\H]\nHostName=scp.example.com\nPortNumber=22\nUserName=u\nFSProtocol=1\n")
+ assert parse_ini_file(ini)[0].protocol == "sftp"
+
+def test_parse_ini_invalid_port(tmp_path):
+ ini = tmp_path / "w.ini"
+ ini.write_text("[Sessions\\H]\nHostName=h.com\nPortNumber=notanumber\nUserName=u\n")
+ assert parse_ini_file(ini)[0].port == 0
+
+def test_parse_registry_sessions_non_windows():
+ with patch.object(sys, "platform", "linux"):
+ assert parse_registry_sessions() == []
+
+def test_parse_registry_sessions_key_missing():
+ winreg_mock = MagicMock()
+ winreg_mock.OpenKey.side_effect = OSError
+ winreg_mock.HKEY_CURRENT_USER = 0
+ with patch.dict("sys.modules", {"winreg": winreg_mock}):
+ with patch.object(sys, "platform", "win32"):
+ result = parse_registry_sessions()
+ assert result == []
+
+def test_detect_protocol_ftps_flag():
+ assert _detect_protocol({"Ftps": "1", "FSProtocol": "", "FileProtocol": ""}) == "ftps"
+
+def test_detect_protocol_file_protocol_ftp():
+ assert _detect_protocol({"FileProtocol": "ftp", "FSProtocol": "", "Ftps": ""}) == "ftp"
+
+def test_detect_protocol_default_sftp():
+ assert _detect_protocol({"FSProtocol": "", "FileProtocol": "", "Ftps": ""}) == "sftp"
+
+def test_detect_protocol_numeric_ftp():
+ assert _detect_protocol({"FSProtocol": "5", "FileProtocol": "", "Ftps": ""}) == "ftp"
+
+def test_decode_name_url_encoded():
+ assert _decode_name("My%20Server") == "My Server"
+
+def test_decode_name_backslash():
+ assert _decode_name("path%5Cto") == "path\\to"
+
+
+# ---------------------------------------------------------------------------
+# cyberduck
+# ---------------------------------------------------------------------------
+
+def test_detect_bookmarks_dir_appdata(monkeypatch):
+ monkeypatch.setenv("APPDATA", "/fake/appdata")
+ assert "Cyberduck" in str(detect_bookmarks_dir())
+
+def test_detect_bookmarks_dir_no_appdata(monkeypatch):
+ monkeypatch.delenv("APPDATA", raising=False)
+ assert "Cyberduck" in str(detect_bookmarks_dir())
+
+def test_parse_bookmark_ssh_protocol(tmp_path):
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(plistlib.dumps({"Hostname": "h.com", "Protocol": "ssh", "Port": 22, "Username": "u", "Path": "/", "Nickname": "X"}))
+ assert parse_bookmark_file(duck).protocol == "sftp"
+
+def test_parse_bookmark_invalid_port(tmp_path):
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(plistlib.dumps({"Hostname": "h.com", "Protocol": "sftp", "Port": "bad", "Username": "u", "Path": "/", "Nickname": "X"}))
+ assert parse_bookmark_file(duck).port == 0
+
+def test_parse_bookmark_no_nickname_user_at_host(tmp_path):
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(plistlib.dumps({"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Username": "alice", "Path": "/"}))
+ assert parse_bookmark_file(duck).name == "alice@h.com"
+
+def test_parse_bookmark_no_nickname_no_user(tmp_path):
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(plistlib.dumps({"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Path": "/"}))
+ assert parse_bookmark_file(duck).name == "h.com"
+
+def test_parse_bookmarks_dir_skips_bad_files(tmp_path):
+ (tmp_path / "bad.duck").write_text("not a plist")
+ assert parse_bookmarks_dir(tmp_path) == []
+
+def test_parse_bookmarks_dir_nonexistent(tmp_path):
+ assert parse_bookmarks_dir(tmp_path / "nonexistent") == []
+
+def test_map_protocol_unknown():
+ assert _map_protocol("unknown") == "sftp"
+
+def test_map_protocol_ftps():
+ assert _map_protocol("ftps") == "ftps"
+
+
+# ---------------------------------------------------------------------------
+# filezilla
+# ---------------------------------------------------------------------------
+
+def test_detect_path_appdata(monkeypatch):
+ monkeypatch.setenv("APPDATA", "/fake/appdata")
+ assert "sitemanager.xml" in str(detect_path())
+
+def test_detect_path_no_appdata(monkeypatch):
+ monkeypatch.delenv("APPDATA", raising=False)
+ assert detect_path().name == "sitemanager.xml"
+
+def test_parse_file_skips_no_host(tmp_path):
+ xml = '122uX'
+ f = tmp_path / "sm.xml"
+ f.write_text(xml)
+ assert parse_file(f) == []
+
+def test_parse_file_ftps_protocol(tmp_path):
+ xml = 'h.com321uX'
+ f = tmp_path / "sm.xml"
+ f.write_text(xml)
+ assert parse_file(f)[0].protocol == "ftps"
+
+def test_parse_file_invalid_port(tmp_path):
+ xml = 'h.com1notanumberuX'
+ f = tmp_path / "sm.xml"
+ f.write_text(xml)
+ assert parse_file(f)[0].port == 0
+
+def test_decode_password_base64_invalid():
+ elem = ET.fromstring('!!!notbase64!!!')
+ server = ET.Element("Server")
+ server.append(elem)
+ assert _decode_password(server) == ""
+
+def test_decode_password_plaintext():
+ elem = ET.fromstring("mypassword")
+ server = ET.Element("Server")
+ server.append(elem)
+ assert _decode_password(server) == "mypassword"
+
+def test_decode_password_empty():
+ assert _decode_password(ET.Element("Server")) == ""
+
+def test_parse_remote_dir_absolute():
+ assert _parse_remote_dir("/home/user") == "/home/user"
+
+def test_parse_remote_dir_relative():
+ assert _parse_remote_dir("uploads") == "/uploads"
+
+def test_parse_remote_dir_empty():
+ assert _parse_remote_dir("") == "/"
+
+def test_parse_remote_dir_filezilla_format():
+ assert _parse_remote_dir("1 0 4 home 4 user") == "/home/user"
From f8602fd99c2deb0ec45eb237fe8541e98af2783e Mon Sep 17 00:00:00 2001
From: Orinks <38449772+Orinks@users.noreply.github.com>
Date: Sat, 28 Feb 2026 15:02:57 +0000
Subject: [PATCH 04/10] style: ruff format test_importers_coverage
---
tests/test_importers_coverage.py | 155 +++++++++++++++++++++++++++----
1 file changed, 139 insertions(+), 16 deletions(-)
diff --git a/tests/test_importers_coverage.py b/tests/test_importers_coverage.py
index 10d09b7..32c0ed2 100644
--- a/tests/test_importers_coverage.py
+++ b/tests/test_importers_coverage.py
@@ -2,17 +2,14 @@
from __future__ import annotations
-import os
import plistlib
import sys
import xml.etree.ElementTree as ET
-from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from portkeydrop.importers import (
- WINSCP_REGISTRY_SENTINEL,
_load_from_unknown_path,
_winscp_registry_available,
detect_default_path,
@@ -43,21 +40,26 @@
# __init__
# ---------------------------------------------------------------------------
+
def test_winscp_registry_available_non_windows():
with patch.object(sys, "platform", "linux"):
assert _winscp_registry_available() is False
+
def test_detect_default_path_filezilla():
result = detect_default_path("filezilla")
assert "sitemanager.xml" in str(result)
+
def test_detect_default_path_cyberduck():
result = detect_default_path("cyberduck")
assert "Cyberduck" in str(result) or "cyberduck" in str(result).lower()
+
def test_detect_default_path_unknown():
assert detect_default_path("unknown_client") is None
+
def test_load_from_source_filezilla(tmp_path):
xml = 'example.com122bobc2VjcmV0Test'
f = tmp_path / "sitemanager.xml"
@@ -65,6 +67,7 @@ def test_load_from_source_filezilla(tmp_path):
sites = load_from_source("filezilla", f)
assert len(sites) == 1
+
def test_load_from_source_filezilla_auto_detect(tmp_path):
xml = 'auto.com122uAuto'
f = tmp_path / "sitemanager.xml"
@@ -73,12 +76,16 @@ def test_load_from_source_filezilla_auto_detect(tmp_path):
sites = load_from_source("filezilla", None)
assert len(sites) == 1
+
def test_load_from_source_winscp_with_path(tmp_path):
ini = tmp_path / "WinSCP.ini"
- ini.write_text("[Sessions\\MyServer]\nHostName=sftp.example.com\nPortNumber=22\nUserName=alice\n")
+ ini.write_text(
+ "[Sessions\\MyServer]\nHostName=sftp.example.com\nPortNumber=22\nUserName=alice\n"
+ )
sites = load_from_source("winscp", ini)
assert len(sites) == 1
+
def test_load_from_source_winscp_ini_exists(tmp_path):
ini = tmp_path / "WinSCP.ini"
ini.write_text("[Sessions\\Server1]\nHostName=host1.com\nPortNumber=22\nUserName=user\n")
@@ -86,56 +93,95 @@ def test_load_from_source_winscp_ini_exists(tmp_path):
sites = load_from_source("winscp", None)
assert len(sites) >= 1
+
def test_load_from_source_winscp_registry_fallback(tmp_path):
ini = tmp_path / "WinSCP.ini" # doesn't exist
with patch("portkeydrop.importers.winscp.detect_ini_path", return_value=ini):
- with patch("portkeydrop.importers.winscp.parse_registry_sessions", return_value=[]) as mock_reg:
+ with patch(
+ "portkeydrop.importers.winscp.parse_registry_sessions", return_value=[]
+ ) as mock_reg:
load_from_source("winscp", None)
mock_reg.assert_called_once()
+
def test_load_from_source_cyberduck_dir(tmp_path):
duck = tmp_path / "test.duck"
- data = {"Hostname": "sftp.example.com", "Protocol": "sftp", "Port": 22, "Username": "alice", "Path": "/uploads", "Nickname": "Test"}
+ data = {
+ "Hostname": "sftp.example.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "alice",
+ "Path": "/uploads",
+ "Nickname": "Test",
+ }
duck.write_bytes(plistlib.dumps(data))
sites = load_from_source("cyberduck", tmp_path)
assert len(sites) == 1
+
def test_load_from_source_cyberduck_single_file(tmp_path):
duck = tmp_path / "test.duck"
- data = {"Hostname": "host.com", "Protocol": "sftp", "Port": 22, "Username": "u", "Path": "/", "Nickname": "X"}
+ data = {
+ "Hostname": "host.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
duck.write_bytes(plistlib.dumps(data))
sites = load_from_source("cyberduck", duck)
assert len(sites) == 1
+
def test_load_from_source_cyberduck_auto_detect(tmp_path):
duck = tmp_path / "b.duck"
- data = {"Hostname": "cd.com", "Protocol": "sftp", "Port": 22, "Username": "u", "Path": "/", "Nickname": "B"}
+ data = {
+ "Hostname": "cd.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "B",
+ }
duck.write_bytes(plistlib.dumps(data))
with patch("portkeydrop.importers.cyberduck.detect_bookmarks_dir", return_value=tmp_path):
sites = load_from_source("cyberduck", None)
assert len(sites) >= 1
+
def test_load_from_source_from_file_no_path():
with pytest.raises(ValueError, match="Path is required"):
load_from_source("from_file", None)
+
def test_load_from_source_unknown_source():
with pytest.raises(ValueError, match="Unknown import source"):
load_from_source("bogus", None)
+
def test_load_from_unknown_path_ini(tmp_path):
ini = tmp_path / "WinSCP.ini"
ini.write_text("[Sessions\\S]\nHostName=h.com\nPortNumber=22\nUserName=u\n")
sites = _load_from_unknown_path(ini)
assert len(sites) >= 1
+
def test_load_from_unknown_path_duck(tmp_path):
duck = tmp_path / "x.duck"
- data = {"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Username": "u", "Path": "/", "Nickname": "X"}
+ data = {
+ "Hostname": "h.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
duck.write_bytes(plistlib.dumps(data))
sites = _load_from_unknown_path(duck)
assert len(sites) == 1
+
def test_load_from_unknown_path_xml(tmp_path):
xml = 'h.com122uX'
f = tmp_path / "sites.xml"
@@ -143,13 +189,22 @@ def test_load_from_unknown_path_xml(tmp_path):
sites = _load_from_unknown_path(f)
assert len(sites) >= 1
+
def test_load_from_unknown_path_dir_cyberduck(tmp_path):
duck = tmp_path / "x.duck"
- data = {"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Username": "u", "Path": "/", "Nickname": "X"}
+ data = {
+ "Hostname": "h.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
duck.write_bytes(plistlib.dumps(data))
sites = _load_from_unknown_path(tmp_path)
assert len(sites) == 1
+
def test_load_from_unknown_path_empty_dir(tmp_path):
assert _load_from_unknown_path(tmp_path) == []
@@ -158,38 +213,50 @@ def test_load_from_unknown_path_empty_dir(tmp_path):
# winscp
# ---------------------------------------------------------------------------
+
def test_detect_ini_path_appdata(monkeypatch):
monkeypatch.setenv("APPDATA", "/fake/appdata")
assert str(detect_ini_path()) == "/fake/appdata/WinSCP.ini"
+
def test_detect_ini_path_no_appdata(monkeypatch):
monkeypatch.delenv("APPDATA", raising=False)
assert detect_ini_path().name == "WinSCP.ini"
+
def test_parse_ini_skips_non_session_sections(tmp_path):
ini = tmp_path / "w.ini"
- ini.write_text("[Configuration]\nKey=Value\n[Sessions\\MyHost]\nHostName=h.com\nPortNumber=22\nUserName=u\n")
+ ini.write_text(
+ "[Configuration]\nKey=Value\n[Sessions\\MyHost]\nHostName=h.com\nPortNumber=22\nUserName=u\n"
+ )
assert len(parse_ini_file(ini)) == 1
+
def test_parse_ini_skips_missing_hostname(tmp_path):
ini = tmp_path / "w.ini"
ini.write_text("[Sessions\\NoHost]\nPortNumber=22\nUserName=u\n")
assert parse_ini_file(ini) == []
+
def test_parse_ini_scp_mapped_to_sftp(tmp_path):
ini = tmp_path / "w.ini"
- ini.write_text("[Sessions\\H]\nHostName=scp.example.com\nPortNumber=22\nUserName=u\nFSProtocol=1\n")
+ ini.write_text(
+ "[Sessions\\H]\nHostName=scp.example.com\nPortNumber=22\nUserName=u\nFSProtocol=1\n"
+ )
assert parse_ini_file(ini)[0].protocol == "sftp"
+
def test_parse_ini_invalid_port(tmp_path):
ini = tmp_path / "w.ini"
ini.write_text("[Sessions\\H]\nHostName=h.com\nPortNumber=notanumber\nUserName=u\n")
assert parse_ini_file(ini)[0].port == 0
+
def test_parse_registry_sessions_non_windows():
with patch.object(sys, "platform", "linux"):
assert parse_registry_sessions() == []
+
def test_parse_registry_sessions_key_missing():
winreg_mock = MagicMock()
winreg_mock.OpenKey.side_effect = OSError
@@ -199,21 +266,27 @@ def test_parse_registry_sessions_key_missing():
result = parse_registry_sessions()
assert result == []
+
def test_detect_protocol_ftps_flag():
assert _detect_protocol({"Ftps": "1", "FSProtocol": "", "FileProtocol": ""}) == "ftps"
+
def test_detect_protocol_file_protocol_ftp():
assert _detect_protocol({"FileProtocol": "ftp", "FSProtocol": "", "Ftps": ""}) == "ftp"
+
def test_detect_protocol_default_sftp():
assert _detect_protocol({"FSProtocol": "", "FileProtocol": "", "Ftps": ""}) == "sftp"
+
def test_detect_protocol_numeric_ftp():
assert _detect_protocol({"FSProtocol": "5", "FileProtocol": "", "Ftps": ""}) == "ftp"
+
def test_decode_name_url_encoded():
assert _decode_name("My%20Server") == "My Server"
+
def test_decode_name_backslash():
assert _decode_name("path%5Cto") == "path\\to"
@@ -222,44 +295,82 @@ def test_decode_name_backslash():
# cyberduck
# ---------------------------------------------------------------------------
+
def test_detect_bookmarks_dir_appdata(monkeypatch):
monkeypatch.setenv("APPDATA", "/fake/appdata")
assert "Cyberduck" in str(detect_bookmarks_dir())
+
def test_detect_bookmarks_dir_no_appdata(monkeypatch):
monkeypatch.delenv("APPDATA", raising=False)
assert "Cyberduck" in str(detect_bookmarks_dir())
+
def test_parse_bookmark_ssh_protocol(tmp_path):
duck = tmp_path / "x.duck"
- duck.write_bytes(plistlib.dumps({"Hostname": "h.com", "Protocol": "ssh", "Port": 22, "Username": "u", "Path": "/", "Nickname": "X"}))
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "h.com",
+ "Protocol": "ssh",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
+ )
+ )
assert parse_bookmark_file(duck).protocol == "sftp"
+
def test_parse_bookmark_invalid_port(tmp_path):
duck = tmp_path / "x.duck"
- duck.write_bytes(plistlib.dumps({"Hostname": "h.com", "Protocol": "sftp", "Port": "bad", "Username": "u", "Path": "/", "Nickname": "X"}))
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "h.com",
+ "Protocol": "sftp",
+ "Port": "bad",
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
+ )
+ )
assert parse_bookmark_file(duck).port == 0
+
def test_parse_bookmark_no_nickname_user_at_host(tmp_path):
duck = tmp_path / "x.duck"
- duck.write_bytes(plistlib.dumps({"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Username": "alice", "Path": "/"}))
+ duck.write_bytes(
+ plistlib.dumps(
+ {"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Username": "alice", "Path": "/"}
+ )
+ )
assert parse_bookmark_file(duck).name == "alice@h.com"
+
def test_parse_bookmark_no_nickname_no_user(tmp_path):
duck = tmp_path / "x.duck"
- duck.write_bytes(plistlib.dumps({"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Path": "/"}))
+ duck.write_bytes(
+ plistlib.dumps({"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Path": "/"})
+ )
assert parse_bookmark_file(duck).name == "h.com"
+
def test_parse_bookmarks_dir_skips_bad_files(tmp_path):
(tmp_path / "bad.duck").write_text("not a plist")
assert parse_bookmarks_dir(tmp_path) == []
+
def test_parse_bookmarks_dir_nonexistent(tmp_path):
assert parse_bookmarks_dir(tmp_path / "nonexistent") == []
+
def test_map_protocol_unknown():
assert _map_protocol("unknown") == "sftp"
+
def test_map_protocol_ftps():
assert _map_protocol("ftps") == "ftps"
@@ -268,55 +379,67 @@ def test_map_protocol_ftps():
# filezilla
# ---------------------------------------------------------------------------
+
def test_detect_path_appdata(monkeypatch):
monkeypatch.setenv("APPDATA", "/fake/appdata")
assert "sitemanager.xml" in str(detect_path())
+
def test_detect_path_no_appdata(monkeypatch):
monkeypatch.delenv("APPDATA", raising=False)
assert detect_path().name == "sitemanager.xml"
+
def test_parse_file_skips_no_host(tmp_path):
xml = '122uX'
f = tmp_path / "sm.xml"
f.write_text(xml)
assert parse_file(f) == []
+
def test_parse_file_ftps_protocol(tmp_path):
xml = 'h.com321uX'
f = tmp_path / "sm.xml"
f.write_text(xml)
assert parse_file(f)[0].protocol == "ftps"
+
def test_parse_file_invalid_port(tmp_path):
xml = 'h.com1notanumberuX'
f = tmp_path / "sm.xml"
f.write_text(xml)
assert parse_file(f)[0].port == 0
+
def test_decode_password_base64_invalid():
elem = ET.fromstring('!!!notbase64!!!')
server = ET.Element("Server")
server.append(elem)
assert _decode_password(server) == ""
+
def test_decode_password_plaintext():
elem = ET.fromstring("mypassword")
server = ET.Element("Server")
server.append(elem)
assert _decode_password(server) == "mypassword"
+
def test_decode_password_empty():
assert _decode_password(ET.Element("Server")) == ""
+
def test_parse_remote_dir_absolute():
assert _parse_remote_dir("/home/user") == "/home/user"
+
def test_parse_remote_dir_relative():
assert _parse_remote_dir("uploads") == "/uploads"
+
def test_parse_remote_dir_empty():
assert _parse_remote_dir("") == "/"
+
def test_parse_remote_dir_filezilla_format():
assert _parse_remote_dir("1 0 4 home 4 user") == "/home/user"
From ab5a2fb211b17b122d5e8cba843f7ee1d4c07c97 Mon Sep 17 00:00:00 2001
From: Orinks <38449772+Orinks@users.noreply.github.com>
Date: Sat, 28 Feb 2026 15:06:10 +0000
Subject: [PATCH 05/10] test(importers): distribute edge-case tests into
per-module test files
---
tests/test_importers_coverage.py | 445 ------------------------------
tests/test_importers_cyberduck.py | 103 +++++++
tests/test_importers_filezilla.py | 92 ++++++
tests/test_importers_init.py | 205 ++++++++++++++
tests/test_importers_winscp.py | 108 ++++++++
5 files changed, 508 insertions(+), 445 deletions(-)
delete mode 100644 tests/test_importers_coverage.py
create mode 100644 tests/test_importers_init.py
diff --git a/tests/test_importers_coverage.py b/tests/test_importers_coverage.py
deleted file mode 100644
index 32c0ed2..0000000
--- a/tests/test_importers_coverage.py
+++ /dev/null
@@ -1,445 +0,0 @@
-"""Additional tests to hit coverage gaps in importers."""
-
-from __future__ import annotations
-
-import plistlib
-import sys
-import xml.etree.ElementTree as ET
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-from portkeydrop.importers import (
- _load_from_unknown_path,
- _winscp_registry_available,
- detect_default_path,
- load_from_source,
-)
-from portkeydrop.importers.cyberduck import (
- _map_protocol,
- detect_bookmarks_dir,
- parse_bookmark_file,
- parse_bookmarks_dir,
-)
-from portkeydrop.importers.filezilla import (
- _decode_password,
- _parse_remote_dir,
- detect_path,
- parse_file,
-)
-from portkeydrop.importers.winscp import (
- _decode_name,
- _detect_protocol,
- detect_ini_path,
- parse_ini_file,
- parse_registry_sessions,
-)
-
-
-# ---------------------------------------------------------------------------
-# __init__
-# ---------------------------------------------------------------------------
-
-
-def test_winscp_registry_available_non_windows():
- with patch.object(sys, "platform", "linux"):
- assert _winscp_registry_available() is False
-
-
-def test_detect_default_path_filezilla():
- result = detect_default_path("filezilla")
- assert "sitemanager.xml" in str(result)
-
-
-def test_detect_default_path_cyberduck():
- result = detect_default_path("cyberduck")
- assert "Cyberduck" in str(result) or "cyberduck" in str(result).lower()
-
-
-def test_detect_default_path_unknown():
- assert detect_default_path("unknown_client") is None
-
-
-def test_load_from_source_filezilla(tmp_path):
- xml = 'example.com122bobc2VjcmV0Test'
- f = tmp_path / "sitemanager.xml"
- f.write_text(xml)
- sites = load_from_source("filezilla", f)
- assert len(sites) == 1
-
-
-def test_load_from_source_filezilla_auto_detect(tmp_path):
- xml = 'auto.com122uAuto'
- f = tmp_path / "sitemanager.xml"
- f.write_text(xml)
- with patch("portkeydrop.importers.filezilla.detect_path", return_value=f):
- sites = load_from_source("filezilla", None)
- assert len(sites) == 1
-
-
-def test_load_from_source_winscp_with_path(tmp_path):
- ini = tmp_path / "WinSCP.ini"
- ini.write_text(
- "[Sessions\\MyServer]\nHostName=sftp.example.com\nPortNumber=22\nUserName=alice\n"
- )
- sites = load_from_source("winscp", ini)
- assert len(sites) == 1
-
-
-def test_load_from_source_winscp_ini_exists(tmp_path):
- ini = tmp_path / "WinSCP.ini"
- ini.write_text("[Sessions\\Server1]\nHostName=host1.com\nPortNumber=22\nUserName=user\n")
- with patch("portkeydrop.importers.winscp.detect_ini_path", return_value=ini):
- sites = load_from_source("winscp", None)
- assert len(sites) >= 1
-
-
-def test_load_from_source_winscp_registry_fallback(tmp_path):
- ini = tmp_path / "WinSCP.ini" # doesn't exist
- with patch("portkeydrop.importers.winscp.detect_ini_path", return_value=ini):
- with patch(
- "portkeydrop.importers.winscp.parse_registry_sessions", return_value=[]
- ) as mock_reg:
- load_from_source("winscp", None)
- mock_reg.assert_called_once()
-
-
-def test_load_from_source_cyberduck_dir(tmp_path):
- duck = tmp_path / "test.duck"
- data = {
- "Hostname": "sftp.example.com",
- "Protocol": "sftp",
- "Port": 22,
- "Username": "alice",
- "Path": "/uploads",
- "Nickname": "Test",
- }
- duck.write_bytes(plistlib.dumps(data))
- sites = load_from_source("cyberduck", tmp_path)
- assert len(sites) == 1
-
-
-def test_load_from_source_cyberduck_single_file(tmp_path):
- duck = tmp_path / "test.duck"
- data = {
- "Hostname": "host.com",
- "Protocol": "sftp",
- "Port": 22,
- "Username": "u",
- "Path": "/",
- "Nickname": "X",
- }
- duck.write_bytes(plistlib.dumps(data))
- sites = load_from_source("cyberduck", duck)
- assert len(sites) == 1
-
-
-def test_load_from_source_cyberduck_auto_detect(tmp_path):
- duck = tmp_path / "b.duck"
- data = {
- "Hostname": "cd.com",
- "Protocol": "sftp",
- "Port": 22,
- "Username": "u",
- "Path": "/",
- "Nickname": "B",
- }
- duck.write_bytes(plistlib.dumps(data))
- with patch("portkeydrop.importers.cyberduck.detect_bookmarks_dir", return_value=tmp_path):
- sites = load_from_source("cyberduck", None)
- assert len(sites) >= 1
-
-
-def test_load_from_source_from_file_no_path():
- with pytest.raises(ValueError, match="Path is required"):
- load_from_source("from_file", None)
-
-
-def test_load_from_source_unknown_source():
- with pytest.raises(ValueError, match="Unknown import source"):
- load_from_source("bogus", None)
-
-
-def test_load_from_unknown_path_ini(tmp_path):
- ini = tmp_path / "WinSCP.ini"
- ini.write_text("[Sessions\\S]\nHostName=h.com\nPortNumber=22\nUserName=u\n")
- sites = _load_from_unknown_path(ini)
- assert len(sites) >= 1
-
-
-def test_load_from_unknown_path_duck(tmp_path):
- duck = tmp_path / "x.duck"
- data = {
- "Hostname": "h.com",
- "Protocol": "sftp",
- "Port": 22,
- "Username": "u",
- "Path": "/",
- "Nickname": "X",
- }
- duck.write_bytes(plistlib.dumps(data))
- sites = _load_from_unknown_path(duck)
- assert len(sites) == 1
-
-
-def test_load_from_unknown_path_xml(tmp_path):
- xml = 'h.com122uX'
- f = tmp_path / "sites.xml"
- f.write_text(xml)
- sites = _load_from_unknown_path(f)
- assert len(sites) >= 1
-
-
-def test_load_from_unknown_path_dir_cyberduck(tmp_path):
- duck = tmp_path / "x.duck"
- data = {
- "Hostname": "h.com",
- "Protocol": "sftp",
- "Port": 22,
- "Username": "u",
- "Path": "/",
- "Nickname": "X",
- }
- duck.write_bytes(plistlib.dumps(data))
- sites = _load_from_unknown_path(tmp_path)
- assert len(sites) == 1
-
-
-def test_load_from_unknown_path_empty_dir(tmp_path):
- assert _load_from_unknown_path(tmp_path) == []
-
-
-# ---------------------------------------------------------------------------
-# winscp
-# ---------------------------------------------------------------------------
-
-
-def test_detect_ini_path_appdata(monkeypatch):
- monkeypatch.setenv("APPDATA", "/fake/appdata")
- assert str(detect_ini_path()) == "/fake/appdata/WinSCP.ini"
-
-
-def test_detect_ini_path_no_appdata(monkeypatch):
- monkeypatch.delenv("APPDATA", raising=False)
- assert detect_ini_path().name == "WinSCP.ini"
-
-
-def test_parse_ini_skips_non_session_sections(tmp_path):
- ini = tmp_path / "w.ini"
- ini.write_text(
- "[Configuration]\nKey=Value\n[Sessions\\MyHost]\nHostName=h.com\nPortNumber=22\nUserName=u\n"
- )
- assert len(parse_ini_file(ini)) == 1
-
-
-def test_parse_ini_skips_missing_hostname(tmp_path):
- ini = tmp_path / "w.ini"
- ini.write_text("[Sessions\\NoHost]\nPortNumber=22\nUserName=u\n")
- assert parse_ini_file(ini) == []
-
-
-def test_parse_ini_scp_mapped_to_sftp(tmp_path):
- ini = tmp_path / "w.ini"
- ini.write_text(
- "[Sessions\\H]\nHostName=scp.example.com\nPortNumber=22\nUserName=u\nFSProtocol=1\n"
- )
- assert parse_ini_file(ini)[0].protocol == "sftp"
-
-
-def test_parse_ini_invalid_port(tmp_path):
- ini = tmp_path / "w.ini"
- ini.write_text("[Sessions\\H]\nHostName=h.com\nPortNumber=notanumber\nUserName=u\n")
- assert parse_ini_file(ini)[0].port == 0
-
-
-def test_parse_registry_sessions_non_windows():
- with patch.object(sys, "platform", "linux"):
- assert parse_registry_sessions() == []
-
-
-def test_parse_registry_sessions_key_missing():
- winreg_mock = MagicMock()
- winreg_mock.OpenKey.side_effect = OSError
- winreg_mock.HKEY_CURRENT_USER = 0
- with patch.dict("sys.modules", {"winreg": winreg_mock}):
- with patch.object(sys, "platform", "win32"):
- result = parse_registry_sessions()
- assert result == []
-
-
-def test_detect_protocol_ftps_flag():
- assert _detect_protocol({"Ftps": "1", "FSProtocol": "", "FileProtocol": ""}) == "ftps"
-
-
-def test_detect_protocol_file_protocol_ftp():
- assert _detect_protocol({"FileProtocol": "ftp", "FSProtocol": "", "Ftps": ""}) == "ftp"
-
-
-def test_detect_protocol_default_sftp():
- assert _detect_protocol({"FSProtocol": "", "FileProtocol": "", "Ftps": ""}) == "sftp"
-
-
-def test_detect_protocol_numeric_ftp():
- assert _detect_protocol({"FSProtocol": "5", "FileProtocol": "", "Ftps": ""}) == "ftp"
-
-
-def test_decode_name_url_encoded():
- assert _decode_name("My%20Server") == "My Server"
-
-
-def test_decode_name_backslash():
- assert _decode_name("path%5Cto") == "path\\to"
-
-
-# ---------------------------------------------------------------------------
-# cyberduck
-# ---------------------------------------------------------------------------
-
-
-def test_detect_bookmarks_dir_appdata(monkeypatch):
- monkeypatch.setenv("APPDATA", "/fake/appdata")
- assert "Cyberduck" in str(detect_bookmarks_dir())
-
-
-def test_detect_bookmarks_dir_no_appdata(monkeypatch):
- monkeypatch.delenv("APPDATA", raising=False)
- assert "Cyberduck" in str(detect_bookmarks_dir())
-
-
-def test_parse_bookmark_ssh_protocol(tmp_path):
- duck = tmp_path / "x.duck"
- duck.write_bytes(
- plistlib.dumps(
- {
- "Hostname": "h.com",
- "Protocol": "ssh",
- "Port": 22,
- "Username": "u",
- "Path": "/",
- "Nickname": "X",
- }
- )
- )
- assert parse_bookmark_file(duck).protocol == "sftp"
-
-
-def test_parse_bookmark_invalid_port(tmp_path):
- duck = tmp_path / "x.duck"
- duck.write_bytes(
- plistlib.dumps(
- {
- "Hostname": "h.com",
- "Protocol": "sftp",
- "Port": "bad",
- "Username": "u",
- "Path": "/",
- "Nickname": "X",
- }
- )
- )
- assert parse_bookmark_file(duck).port == 0
-
-
-def test_parse_bookmark_no_nickname_user_at_host(tmp_path):
- duck = tmp_path / "x.duck"
- duck.write_bytes(
- plistlib.dumps(
- {"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Username": "alice", "Path": "/"}
- )
- )
- assert parse_bookmark_file(duck).name == "alice@h.com"
-
-
-def test_parse_bookmark_no_nickname_no_user(tmp_path):
- duck = tmp_path / "x.duck"
- duck.write_bytes(
- plistlib.dumps({"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Path": "/"})
- )
- assert parse_bookmark_file(duck).name == "h.com"
-
-
-def test_parse_bookmarks_dir_skips_bad_files(tmp_path):
- (tmp_path / "bad.duck").write_text("not a plist")
- assert parse_bookmarks_dir(tmp_path) == []
-
-
-def test_parse_bookmarks_dir_nonexistent(tmp_path):
- assert parse_bookmarks_dir(tmp_path / "nonexistent") == []
-
-
-def test_map_protocol_unknown():
- assert _map_protocol("unknown") == "sftp"
-
-
-def test_map_protocol_ftps():
- assert _map_protocol("ftps") == "ftps"
-
-
-# ---------------------------------------------------------------------------
-# filezilla
-# ---------------------------------------------------------------------------
-
-
-def test_detect_path_appdata(monkeypatch):
- monkeypatch.setenv("APPDATA", "/fake/appdata")
- assert "sitemanager.xml" in str(detect_path())
-
-
-def test_detect_path_no_appdata(monkeypatch):
- monkeypatch.delenv("APPDATA", raising=False)
- assert detect_path().name == "sitemanager.xml"
-
-
-def test_parse_file_skips_no_host(tmp_path):
- xml = '122uX'
- f = tmp_path / "sm.xml"
- f.write_text(xml)
- assert parse_file(f) == []
-
-
-def test_parse_file_ftps_protocol(tmp_path):
- xml = 'h.com321uX'
- f = tmp_path / "sm.xml"
- f.write_text(xml)
- assert parse_file(f)[0].protocol == "ftps"
-
-
-def test_parse_file_invalid_port(tmp_path):
- xml = 'h.com1notanumberuX'
- f = tmp_path / "sm.xml"
- f.write_text(xml)
- assert parse_file(f)[0].port == 0
-
-
-def test_decode_password_base64_invalid():
- elem = ET.fromstring('!!!notbase64!!!')
- server = ET.Element("Server")
- server.append(elem)
- assert _decode_password(server) == ""
-
-
-def test_decode_password_plaintext():
- elem = ET.fromstring("mypassword")
- server = ET.Element("Server")
- server.append(elem)
- assert _decode_password(server) == "mypassword"
-
-
-def test_decode_password_empty():
- assert _decode_password(ET.Element("Server")) == ""
-
-
-def test_parse_remote_dir_absolute():
- assert _parse_remote_dir("/home/user") == "/home/user"
-
-
-def test_parse_remote_dir_relative():
- assert _parse_remote_dir("uploads") == "/uploads"
-
-
-def test_parse_remote_dir_empty():
- assert _parse_remote_dir("") == "/"
-
-
-def test_parse_remote_dir_filezilla_format():
- assert _parse_remote_dir("1 0 4 home 4 user") == "/home/user"
diff --git a/tests/test_importers_cyberduck.py b/tests/test_importers_cyberduck.py
index cab1680..de5406e 100644
--- a/tests/test_importers_cyberduck.py
+++ b/tests/test_importers_cyberduck.py
@@ -13,3 +13,106 @@ def test_parse_cyberduck_bookmark_fixture():
assert site.port == 22
assert site.username == "alice"
assert site.initial_dir == "/uploads"
+
+
+def test_detect_bookmarks_dir_appdata(monkeypatch):
+ monkeypatch.setenv("APPDATA", "/fake/appdata")
+ from portkeydrop.importers.cyberduck import detect_bookmarks_dir
+
+ assert "Cyberduck" in str(detect_bookmarks_dir())
+
+
+def test_detect_bookmarks_dir_no_appdata(monkeypatch):
+ monkeypatch.delenv("APPDATA", raising=False)
+ from portkeydrop.importers.cyberduck import detect_bookmarks_dir
+
+ assert "Cyberduck" in str(detect_bookmarks_dir())
+
+
+def test_parse_bookmark_ssh_protocol(tmp_path):
+ import plistlib
+ from portkeydrop.importers.cyberduck import parse_bookmark_file
+
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "h.com",
+ "Protocol": "ssh",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
+ )
+ )
+ assert parse_bookmark_file(duck).protocol == "sftp"
+
+
+def test_parse_bookmark_invalid_port(tmp_path):
+ import plistlib
+ from portkeydrop.importers.cyberduck import parse_bookmark_file
+
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "h.com",
+ "Protocol": "sftp",
+ "Port": "bad",
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
+ )
+ )
+ assert parse_bookmark_file(duck).port == 0
+
+
+def test_parse_bookmark_no_nickname_user_at_host(tmp_path):
+ import plistlib
+ from portkeydrop.importers.cyberduck import parse_bookmark_file
+
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Username": "alice", "Path": "/"}
+ )
+ )
+ assert parse_bookmark_file(duck).name == "alice@h.com"
+
+
+def test_parse_bookmark_no_nickname_no_user(tmp_path):
+ import plistlib
+ from portkeydrop.importers.cyberduck import parse_bookmark_file
+
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(
+ plistlib.dumps({"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Path": "/"})
+ )
+ assert parse_bookmark_file(duck).name == "h.com"
+
+
+def test_parse_bookmarks_dir_skips_bad_files(tmp_path):
+ from portkeydrop.importers.cyberduck import parse_bookmarks_dir
+
+ (tmp_path / "bad.duck").write_text("not a plist")
+ assert parse_bookmarks_dir(tmp_path) == []
+
+
+def test_parse_bookmarks_dir_nonexistent(tmp_path):
+ from portkeydrop.importers.cyberduck import parse_bookmarks_dir
+
+ assert parse_bookmarks_dir(tmp_path / "nonexistent") == []
+
+
+def test_map_protocol_unknown():
+ from portkeydrop.importers.cyberduck import _map_protocol
+
+ assert _map_protocol("unknown") == "sftp"
+
+
+def test_map_protocol_ftps():
+ from portkeydrop.importers.cyberduck import _map_protocol
+
+ assert _map_protocol("ftps") == "ftps"
diff --git a/tests/test_importers_filezilla.py b/tests/test_importers_filezilla.py
index 0c1fa59..83c46b4 100644
--- a/tests/test_importers_filezilla.py
+++ b/tests/test_importers_filezilla.py
@@ -23,3 +23,95 @@ def test_parse_filezilla_sites_fixture():
assert second.protocol == "ftp"
assert second.password == "plaintext"
assert second.initial_dir == "/incoming"
+
+
+def test_detect_path_appdata(monkeypatch):
+ monkeypatch.setenv("APPDATA", "/fake/appdata")
+ from portkeydrop.importers.filezilla import detect_path
+
+ assert "sitemanager.xml" in str(detect_path())
+
+
+def test_detect_path_no_appdata(monkeypatch):
+ monkeypatch.delenv("APPDATA", raising=False)
+ from portkeydrop.importers.filezilla import detect_path
+
+ assert detect_path().name == "sitemanager.xml"
+
+
+def test_parse_file_skips_no_host(tmp_path):
+ from portkeydrop.importers.filezilla import parse_file
+
+ xml = '122uX'
+ f = tmp_path / "sm.xml"
+ f.write_text(xml)
+ assert parse_file(f) == []
+
+
+def test_parse_file_ftps_protocol(tmp_path):
+ from portkeydrop.importers.filezilla import parse_file
+
+ xml = 'h.com321uX'
+ f = tmp_path / "sm.xml"
+ f.write_text(xml)
+ assert parse_file(f)[0].protocol == "ftps"
+
+
+def test_parse_file_invalid_port(tmp_path):
+ from portkeydrop.importers.filezilla import parse_file
+
+ xml = 'h.com1notanumberuX'
+ f = tmp_path / "sm.xml"
+ f.write_text(xml)
+ assert parse_file(f)[0].port == 0
+
+
+def test_decode_password_base64_invalid():
+ import xml.etree.ElementTree as ET
+ from portkeydrop.importers.filezilla import _decode_password
+
+ elem = ET.fromstring('!!!notbase64!!!')
+ server = ET.Element("Server")
+ server.append(elem)
+ assert _decode_password(server) == ""
+
+
+def test_decode_password_plaintext():
+ import xml.etree.ElementTree as ET
+ from portkeydrop.importers.filezilla import _decode_password
+
+ elem = ET.fromstring("mypassword")
+ server = ET.Element("Server")
+ server.append(elem)
+ assert _decode_password(server) == "mypassword"
+
+
+def test_decode_password_empty():
+ import xml.etree.ElementTree as ET
+ from portkeydrop.importers.filezilla import _decode_password
+
+ assert _decode_password(ET.Element("Server")) == ""
+
+
+def test_parse_remote_dir_absolute():
+ from portkeydrop.importers.filezilla import _parse_remote_dir
+
+ assert _parse_remote_dir("/home/user") == "/home/user"
+
+
+def test_parse_remote_dir_relative():
+ from portkeydrop.importers.filezilla import _parse_remote_dir
+
+ assert _parse_remote_dir("uploads") == "/uploads"
+
+
+def test_parse_remote_dir_empty():
+ from portkeydrop.importers.filezilla import _parse_remote_dir
+
+ assert _parse_remote_dir("") == "/"
+
+
+def test_parse_remote_dir_filezilla_format():
+ from portkeydrop.importers.filezilla import _parse_remote_dir
+
+ assert _parse_remote_dir("1 0 4 home 4 user") == "/home/user"
diff --git a/tests/test_importers_init.py b/tests/test_importers_init.py
new file mode 100644
index 0000000..77f687e
--- /dev/null
+++ b/tests/test_importers_init.py
@@ -0,0 +1,205 @@
+"""Tests for portkeydrop.importers __init__ — source dispatch and path detection."""
+
+from __future__ import annotations
+
+import plistlib
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from portkeydrop.importers import (
+ _load_from_unknown_path,
+ _winscp_registry_available,
+ detect_default_path,
+ load_from_source,
+)
+
+
+def test_winscp_registry_available_non_windows():
+ with patch.object(sys, "platform", "linux"):
+ assert _winscp_registry_available() is False
+
+
+def test_detect_default_path_filezilla():
+ assert "sitemanager.xml" in str(detect_default_path("filezilla"))
+
+
+def test_detect_default_path_cyberduck():
+ result = str(detect_default_path("cyberduck"))
+ assert "Cyberduck" in result or "cyberduck" in result.lower()
+
+
+def test_detect_default_path_winscp_no_registry():
+ with patch("portkeydrop.importers._winscp_registry_available", return_value=False):
+ result = detect_default_path("winscp")
+ assert isinstance(result, Path)
+ assert result.name == "WinSCP.ini"
+
+
+def test_detect_default_path_unknown_returns_none():
+ assert detect_default_path("unknown_client") is None
+
+
+def test_load_from_source_filezilla(tmp_path):
+ xml = 'example.com122bobc2VjcmV0Test'
+ f = tmp_path / "sitemanager.xml"
+ f.write_text(xml)
+ sites = load_from_source("filezilla", f)
+ assert len(sites) == 1
+ assert sites[0].host == "example.com"
+
+
+def test_load_from_source_filezilla_auto_detect(tmp_path):
+ xml = 'auto.com122uAuto'
+ f = tmp_path / "sitemanager.xml"
+ f.write_text(xml)
+ with patch("portkeydrop.importers.filezilla.detect_path", return_value=f):
+ sites = load_from_source("filezilla", None)
+ assert len(sites) == 1
+
+
+def test_load_from_source_winscp_with_explicit_path(tmp_path):
+ ini = tmp_path / "WinSCP.ini"
+ ini.write_text(
+ "[Sessions\\MyServer]\nHostName=sftp.example.com\nPortNumber=22\nUserName=alice\n"
+ )
+ sites = load_from_source("winscp", ini)
+ assert len(sites) == 1
+ assert sites[0].host == "sftp.example.com"
+
+
+def test_load_from_source_winscp_auto_detect_ini(tmp_path):
+ ini = tmp_path / "WinSCP.ini"
+ ini.write_text("[Sessions\\Server1]\nHostName=host1.com\nPortNumber=22\nUserName=user\n")
+ with patch("portkeydrop.importers.winscp.detect_ini_path", return_value=ini):
+ sites = load_from_source("winscp", None)
+ assert any(s.host == "host1.com" for s in sites)
+
+
+def test_load_from_source_winscp_falls_back_to_registry(tmp_path):
+ ini = tmp_path / "WinSCP.ini" # does not exist
+ with patch("portkeydrop.importers.winscp.detect_ini_path", return_value=ini):
+ with patch(
+ "portkeydrop.importers.winscp.parse_registry_sessions", return_value=[]
+ ) as mock_reg:
+ load_from_source("winscp", None)
+ mock_reg.assert_called_once()
+
+
+def test_load_from_source_cyberduck_directory(tmp_path):
+ duck = tmp_path / "test.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "sftp.example.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "alice",
+ "Path": "/uploads",
+ "Nickname": "Test",
+ }
+ )
+ )
+ sites = load_from_source("cyberduck", tmp_path)
+ assert len(sites) == 1
+
+
+def test_load_from_source_cyberduck_single_file(tmp_path):
+ duck = tmp_path / "test.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "host.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
+ )
+ )
+ sites = load_from_source("cyberduck", duck)
+ assert len(sites) == 1
+
+
+def test_load_from_source_cyberduck_auto_detect(tmp_path):
+ duck = tmp_path / "b.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "cd.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "B",
+ }
+ )
+ )
+ with patch("portkeydrop.importers.cyberduck.detect_bookmarks_dir", return_value=tmp_path):
+ sites = load_from_source("cyberduck", None)
+ assert len(sites) >= 1
+
+
+def test_load_from_source_from_file_requires_path():
+ with pytest.raises(ValueError, match="Path is required"):
+ load_from_source("from_file", None)
+
+
+def test_load_from_source_unknown_source_raises():
+ with pytest.raises(ValueError, match="Unknown import source"):
+ load_from_source("bogus", None)
+
+
+def test_load_from_unknown_path_winscp_ini(tmp_path):
+ ini = tmp_path / "WinSCP.ini"
+ ini.write_text("[Sessions\\S]\nHostName=h.com\nPortNumber=22\nUserName=u\n")
+ sites = _load_from_unknown_path(ini)
+ assert len(sites) >= 1
+
+
+def test_load_from_unknown_path_duck_file(tmp_path):
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "h.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
+ )
+ )
+ assert len(_load_from_unknown_path(duck)) == 1
+
+
+def test_load_from_unknown_path_xml_file(tmp_path):
+ xml = 'h.com122uX'
+ f = tmp_path / "sites.xml"
+ f.write_text(xml)
+ assert len(_load_from_unknown_path(f)) >= 1
+
+
+def test_load_from_unknown_path_directory_with_ducks(tmp_path):
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "h.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
+ )
+ )
+ assert len(_load_from_unknown_path(tmp_path)) == 1
+
+
+def test_load_from_unknown_path_empty_directory(tmp_path):
+ assert _load_from_unknown_path(tmp_path) == []
diff --git a/tests/test_importers_winscp.py b/tests/test_importers_winscp.py
index 2b5d5e3..3cdccfc 100644
--- a/tests/test_importers_winscp.py
+++ b/tests/test_importers_winscp.py
@@ -54,3 +54,111 @@ def test_load_from_source_winscp_none_path_tries_registry():
mock_reg.return_value = []
load_from_source("winscp", None)
mock_reg.assert_called_once()
+
+
+def test_detect_ini_path_appdata(monkeypatch):
+ monkeypatch.setenv("APPDATA", "/fake/appdata")
+ from portkeydrop.importers.winscp import detect_ini_path
+
+ assert str(detect_ini_path()) == "/fake/appdata/WinSCP.ini"
+
+
+def test_detect_ini_path_no_appdata(monkeypatch):
+ monkeypatch.delenv("APPDATA", raising=False)
+ from portkeydrop.importers.winscp import detect_ini_path
+
+ assert detect_ini_path().name == "WinSCP.ini"
+
+
+def test_parse_ini_skips_non_session_sections(tmp_path):
+ from portkeydrop.importers.winscp import parse_ini_file
+
+ ini = tmp_path / "w.ini"
+ ini.write_text(
+ "[Configuration]\nKey=Value\n[Sessions\\MyHost]\nHostName=h.com\nPortNumber=22\nUserName=u\n"
+ )
+ assert len(parse_ini_file(ini)) == 1
+
+
+def test_parse_ini_skips_missing_hostname(tmp_path):
+ from portkeydrop.importers.winscp import parse_ini_file
+
+ ini = tmp_path / "w.ini"
+ ini.write_text("[Sessions\\NoHost]\nPortNumber=22\nUserName=u\n")
+ assert parse_ini_file(ini) == []
+
+
+def test_parse_ini_scp_mapped_to_sftp(tmp_path):
+ from portkeydrop.importers.winscp import parse_ini_file
+
+ ini = tmp_path / "w.ini"
+ ini.write_text(
+ "[Sessions\\H]\nHostName=scp.example.com\nPortNumber=22\nUserName=u\nFSProtocol=1\n"
+ )
+ assert parse_ini_file(ini)[0].protocol == "sftp"
+
+
+def test_parse_ini_invalid_port(tmp_path):
+ from portkeydrop.importers.winscp import parse_ini_file
+
+ ini = tmp_path / "w.ini"
+ ini.write_text("[Sessions\\H]\nHostName=h.com\nPortNumber=notanumber\nUserName=u\n")
+ assert parse_ini_file(ini)[0].port == 0
+
+
+def test_parse_registry_sessions_non_windows():
+ import sys
+ from unittest.mock import patch
+ from portkeydrop.importers.winscp import parse_registry_sessions
+
+ with patch.object(sys, "platform", "linux"):
+ assert parse_registry_sessions() == []
+
+
+def test_parse_registry_sessions_key_missing():
+ import sys
+ from unittest.mock import MagicMock, patch
+ from portkeydrop.importers.winscp import parse_registry_sessions
+
+ winreg_mock = MagicMock()
+ winreg_mock.OpenKey.side_effect = OSError
+ winreg_mock.HKEY_CURRENT_USER = 0
+ with patch.dict("sys.modules", {"winreg": winreg_mock}):
+ with patch.object(sys, "platform", "win32"):
+ assert parse_registry_sessions() == []
+
+
+def test_detect_protocol_ftps_flag():
+ from portkeydrop.importers.winscp import _detect_protocol
+
+ assert _detect_protocol({"Ftps": "1", "FSProtocol": "", "FileProtocol": ""}) == "ftps"
+
+
+def test_detect_protocol_file_protocol_ftp():
+ from portkeydrop.importers.winscp import _detect_protocol
+
+ assert _detect_protocol({"FileProtocol": "ftp", "FSProtocol": "", "Ftps": ""}) == "ftp"
+
+
+def test_detect_protocol_default_sftp():
+ from portkeydrop.importers.winscp import _detect_protocol
+
+ assert _detect_protocol({"FSProtocol": "", "FileProtocol": "", "Ftps": ""}) == "sftp"
+
+
+def test_detect_protocol_numeric_ftp():
+ from portkeydrop.importers.winscp import _detect_protocol
+
+ assert _detect_protocol({"FSProtocol": "5", "FileProtocol": "", "Ftps": ""}) == "ftp"
+
+
+def test_decode_name_url_encoded():
+ from portkeydrop.importers.winscp import _decode_name
+
+ assert _decode_name("My%20Server") == "My Server"
+
+
+def test_decode_name_backslash():
+ from portkeydrop.importers.winscp import _decode_name
+
+ assert _decode_name("path%5Cto") == "path\\to"
From 1dba17871e25a14c2425487aff7568e6d6a30a0d Mon Sep 17 00:00:00 2001
From: Orinks <38449772+Orinks@users.noreply.github.com>
Date: Sat, 28 Feb 2026 15:18:58 +0000
Subject: [PATCH 06/10] fix(import): set focus on first control when navigating
wizard steps for screen reader accessibility
---
src/portkeydrop/dialogs/import_connections.py | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/src/portkeydrop/dialogs/import_connections.py b/src/portkeydrop/dialogs/import_connections.py
index badae58..8e17fba 100644
--- a/src/portkeydrop/dialogs/import_connections.py
+++ b/src/portkeydrop/dialogs/import_connections.py
@@ -289,6 +289,15 @@ def _update_step_ui(self) -> None:
self.import_btn.Show(self._step == 2)
self.Layout()
+ # Move focus to the first meaningful control on each page so screen
+ # readers announce the new step without the user having to navigate.
+ if self._step == 0:
+ self.source_radio.SetFocus()
+ elif self._step == 1:
+ self.path_text.SetFocus()
+ elif self._step == 2:
+ self.preview_list.SetFocus()
+
def _file_wildcard_for_source(self, source: str) -> str:
if source == "filezilla":
return "FileZilla XML (*.xml)|*.xml|All files (*.*)|*.*"
From ea8916bcc5747bd64ee010b4576af342fe6015d5 Mon Sep 17 00:00:00 2001
From: Orinks <38449772+Orinks@users.noreply.github.com>
Date: Sat, 28 Feb 2026 15:27:29 +0000
Subject: [PATCH 07/10] fix(import): rename 'Import Connections' to 'Import
Sites' to match app terminology
---
src/portkeydrop/app.py | 4 ++--
src/portkeydrop/dialogs/import_connections.py | 12 ++++++------
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/src/portkeydrop/app.py b/src/portkeydrop/app.py
index 6656c5a..357e041 100644
--- a/src/portkeydrop/app.py
+++ b/src/portkeydrop/app.py
@@ -104,7 +104,7 @@ def _build_menu(self) -> None:
file_menu.AppendSeparator()
file_menu.Append(
ID_IMPORT_CONNECTIONS,
- "&Import Connections...",
+ "&Import Sites...",
"Import sites from other FTP/SFTP clients",
)
file_menu.AppendSeparator()
@@ -489,7 +489,7 @@ def _on_import_connections(self, event: wx.CommandEvent) -> None:
f"{'s' if len(duplicate_names) != 1 else ''}: {dup_preview}"
)
- wx.MessageBox(message, "Import Connections", wx.OK | wx.ICON_INFORMATION, self)
+ wx.MessageBox(message, "Import Sites", wx.OK | wx.ICON_INFORMATION, self)
def _effective_site_port(self, protocol: str, port: int) -> int:
if port > 0:
diff --git a/src/portkeydrop/dialogs/import_connections.py b/src/portkeydrop/dialogs/import_connections.py
index 8e17fba..ee7af1a 100644
--- a/src/portkeydrop/dialogs/import_connections.py
+++ b/src/portkeydrop/dialogs/import_connections.py
@@ -21,7 +21,7 @@ class ImportConnectionsDialog(wx.Dialog):
def __init__(self, parent: wx.Window | None) -> None:
super().__init__(
parent,
- title="Import Connections",
+ title="Import Sites",
size=(680, 480),
style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER,
)
@@ -203,7 +203,7 @@ def _on_import(self, event: wx.CommandEvent) -> None:
if not selected:
wx.MessageBox(
"Select at least one connection to import.",
- "Import Connections",
+ "Import Sites",
wx.OK | wx.ICON_INFORMATION,
self,
)
@@ -221,7 +221,7 @@ def _load_preview(self) -> bool:
if source == "from_file" and not path:
wx.MessageBox(
"Choose a file or folder for 'From file...' import.",
- "Import Connections",
+ "Import Sites",
wx.OK | wx.ICON_WARNING,
self,
)
@@ -235,7 +235,7 @@ def _load_preview(self) -> bool:
if path and not path.exists():
wx.MessageBox(
f"Path does not exist:\n{path}",
- "Import Connections",
+ "Import Sites",
wx.OK | wx.ICON_WARNING,
self,
)
@@ -246,7 +246,7 @@ def _load_preview(self) -> bool:
except Exception as exc:
wx.MessageBox(
f"Failed to parse configuration: {exc}",
- "Import Connections",
+ "Import Sites",
wx.OK | wx.ICON_ERROR,
self,
)
@@ -255,7 +255,7 @@ def _load_preview(self) -> bool:
if not self._loaded_sites:
wx.MessageBox(
"No connections were found in the selected source.",
- "Import Connections",
+ "Import Sites",
wx.OK | wx.ICON_INFORMATION,
self,
)
From bd76f88b41261e959fc1b7d3e87c15a272862423 Mon Sep 17 00:00:00 2001
From: Orinks <38449772+Orinks@users.noreply.github.com>
Date: Sat, 28 Feb 2026 15:30:27 +0000
Subject: [PATCH 08/10] fix(import): move Import Sites menu item to Sites menu
---
src/portkeydrop/app.py | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/portkeydrop/app.py b/src/portkeydrop/app.py
index 357e041..3f6e25a 100644
--- a/src/portkeydrop/app.py
+++ b/src/portkeydrop/app.py
@@ -102,12 +102,6 @@ def _build_menu(self) -> None:
file_menu.Append(ID_CONNECT, "&Connect\tCtrl+Enter", "Connect to server")
file_menu.Append(ID_DISCONNECT, "&Disconnect\tCtrl+Q", "Disconnect from server")
file_menu.AppendSeparator()
- file_menu.Append(
- ID_IMPORT_CONNECTIONS,
- "&Import Sites...",
- "Import sites from other FTP/SFTP clients",
- )
- file_menu.AppendSeparator()
file_menu.Append(ID_SETTINGS, "Se&ttings...", "Application settings")
file_menu.AppendSeparator()
file_menu.Append(wx.ID_EXIT, "E&xit\tAlt+F4", "Exit application")
@@ -160,6 +154,12 @@ def _build_menu(self) -> None:
sites_menu.Append(
ID_SAVE_CONNECTION, "Sa&ve Current Connection...", "Save active connection as a site"
)
+ sites_menu.AppendSeparator()
+ sites_menu.Append(
+ ID_IMPORT_CONNECTIONS,
+ "&Import Sites...",
+ "Import sites from other FTP/SFTP clients",
+ )
menubar.Append(sites_menu, "S&ites")
# Help menu
From 38ce06d56cc619c7af8e005176608e1d4f0977cf Mon Sep 17 00:00:00 2001
From: Orinks <38449772+Orinks@users.noreply.github.com>
Date: Sat, 28 Feb 2026 15:48:40 +0000
Subject: [PATCH 09/10] feat(import): only show installed clients in wizard,
auto-advance when one detected
---
src/portkeydrop/dialogs/import_connections.py | 31 +++++++-
src/portkeydrop/importers/__init__.py | 18 +++++
tests/test_importers_init.py | 72 +++++++++++++++++++
3 files changed, 119 insertions(+), 2 deletions(-)
diff --git a/src/portkeydrop/dialogs/import_connections.py b/src/portkeydrop/dialogs/import_connections.py
index ee7af1a..9b46422 100644
--- a/src/portkeydrop/dialogs/import_connections.py
+++ b/src/portkeydrop/dialogs/import_connections.py
@@ -9,6 +9,7 @@
from portkeydrop.importers import (
SOURCES,
WINSCP_REGISTRY_SENTINEL,
+ available_sources,
detect_default_path,
load_from_source,
)
@@ -40,6 +41,8 @@ def _build_ui(self) -> None:
self.step_title = wx.StaticText(self, label="")
root.Add(self.step_title, 0, wx.ALL, 8)
+ self._available_sources = available_sources()
+ self._auto_advance = False
self.pages = [
self._build_source_page(),
self._build_path_page(),
@@ -77,7 +80,22 @@ def _build_source_page(self) -> wx.Panel:
panel = wx.Panel(self)
sizer = wx.BoxSizer(wx.VERTICAL)
- choices = [source.label for source in SOURCES]
+ self._available_sources = available_sources()
+ choices = [source.label for source in self._available_sources]
+
+ if not choices:
+ choices = ["From file..."]
+
+ if (
+ len(choices) == 1
+ and self._available_sources
+ and self._available_sources[0].key != "from_file"
+ ):
+ # Only one client detected — note it for auto-skip
+ self._auto_advance = True
+ else:
+ self._auto_advance = False
+
self.source_radio = wx.RadioBox(
panel,
label="Choose source client",
@@ -87,6 +105,15 @@ def _build_source_page(self) -> wx.Panel:
)
sizer.Add(self.source_radio, 0, wx.EXPAND | wx.ALL, 4)
+ if not self._available_sources or all(
+ s.key == "from_file" for s in self._available_sources
+ ):
+ note = wx.StaticText(
+ panel,
+ label="No supported FTP clients detected. You can still import from a file.",
+ )
+ sizer.Add(note, 0, wx.ALL, 4)
+
panel.SetSizer(sizer)
return panel
@@ -183,7 +210,7 @@ def _on_back(self, event: wx.CommandEvent) -> None:
def _on_next(self, event: wx.CommandEvent) -> None:
if self._step == 0:
- self._source = SOURCES[self.source_radio.GetSelection()].key
+ self._source = self._available_sources[self.source_radio.GetSelection()].key
self._step = 1
self._update_step_ui()
return
diff --git a/src/portkeydrop/importers/__init__.py b/src/portkeydrop/importers/__init__.py
index 7eaf922..118fb52 100644
--- a/src/portkeydrop/importers/__init__.py
+++ b/src/portkeydrop/importers/__init__.py
@@ -40,6 +40,24 @@ def _winscp_registry_available() -> bool:
return False
+def is_source_available(source: str) -> bool:
+ """Return True if the given import source has detectable config on this machine."""
+ if source == "filezilla":
+ return filezilla.detect_path().exists()
+ if source == "winscp":
+ return _winscp_registry_available() or winscp.detect_ini_path().exists()
+ if source == "cyberduck":
+ return cyberduck.detect_bookmarks_dir().exists()
+ if source == "from_file":
+ return True
+ return False
+
+
+def available_sources() -> list[ImportSource]:
+ """Return only the sources that have detectable config on this machine."""
+ return [s for s in SOURCES if is_source_available(s.key)]
+
+
def detect_default_path(source: str) -> Path | str | None:
"""Return the default path (or sentinel) for the requested source client."""
if source == "filezilla":
diff --git a/tests/test_importers_init.py b/tests/test_importers_init.py
index 77f687e..2d0f7f7 100644
--- a/tests/test_importers_init.py
+++ b/tests/test_importers_init.py
@@ -203,3 +203,75 @@ def test_load_from_unknown_path_directory_with_ducks(tmp_path):
def test_load_from_unknown_path_empty_directory(tmp_path):
assert _load_from_unknown_path(tmp_path) == []
+
+
+def test_is_source_available_from_file_always_true():
+ from portkeydrop.importers import is_source_available
+
+ assert is_source_available("from_file") is True
+
+
+def test_is_source_available_unknown_returns_false():
+ from portkeydrop.importers import is_source_available
+
+ assert is_source_available("bogus") is False
+
+
+def test_is_source_available_filezilla_exists(tmp_path):
+ from portkeydrop.importers import is_source_available
+
+ f = tmp_path / "sitemanager.xml"
+ f.touch()
+ with patch("portkeydrop.importers.filezilla.detect_path", return_value=f):
+ assert is_source_available("filezilla") is True
+
+
+def test_is_source_available_filezilla_missing(tmp_path):
+ from portkeydrop.importers import is_source_available
+
+ with patch(
+ "portkeydrop.importers.filezilla.detect_path", return_value=tmp_path / "missing.xml"
+ ):
+ assert is_source_available("filezilla") is False
+
+
+def test_is_source_available_winscp_registry():
+ from portkeydrop.importers import is_source_available
+
+ with patch("portkeydrop.importers._winscp_registry_available", return_value=True):
+ assert is_source_available("winscp") is True
+
+
+def test_is_source_available_winscp_ini(tmp_path):
+ from portkeydrop.importers import is_source_available
+
+ ini = tmp_path / "WinSCP.ini"
+ ini.touch()
+ with patch("portkeydrop.importers._winscp_registry_available", return_value=False):
+ with patch("portkeydrop.importers.winscp.detect_ini_path", return_value=ini):
+ assert is_source_available("winscp") is True
+
+
+def test_is_source_available_winscp_nothing(tmp_path):
+ from portkeydrop.importers import is_source_available
+
+ with patch("portkeydrop.importers._winscp_registry_available", return_value=False):
+ with patch(
+ "portkeydrop.importers.winscp.detect_ini_path", return_value=tmp_path / "missing.ini"
+ ):
+ assert is_source_available("winscp") is False
+
+
+def test_is_source_available_cyberduck_exists(tmp_path):
+ from portkeydrop.importers import is_source_available
+
+ with patch("portkeydrop.importers.cyberduck.detect_bookmarks_dir", return_value=tmp_path):
+ assert is_source_available("cyberduck") is True
+
+
+def test_available_sources_returns_from_file_always():
+ from portkeydrop.importers import available_sources
+
+ with patch("portkeydrop.importers.is_source_available", side_effect=lambda s: s == "from_file"):
+ sources = available_sources()
+ assert any(s.key == "from_file" for s in sources)
From 7ed9f1f8cdd84eb909ff092e700a94df7be89783 Mon Sep 17 00:00:00 2001
From: Orinks <38449772+Orinks@users.noreply.github.com>
Date: Sat, 28 Feb 2026 15:52:35 +0000
Subject: [PATCH 10/10] fix(tests): add SetFocus stub to _Window to fix dialog
test failures
---
tests/test_import_connections_dialog.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tests/test_import_connections_dialog.py b/tests/test_import_connections_dialog.py
index b0e03dd..e18c281 100644
--- a/tests/test_import_connections_dialog.py
+++ b/tests/test_import_connections_dialog.py
@@ -27,6 +27,9 @@ def Bind(self, event, handler):
def Show(self, show=True) -> None:
self._shown = show
+ def SetFocus(self) -> None:
+ pass
+
def Enable(self, enable=True) -> None:
self._enabled = enable