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