From 9040cf72a8efdf75330dd8bd63a21a518e56aa92 Mon Sep 17 00:00:00 2001 From: phg Date: Tue, 8 Sep 2026 18:20:50 +0200 Subject: [PATCH] Feat #5: choose additive or replacement import --- README.md | 3 +- src/hosts/tui/app.py | 63 ++++++++++++--- src/hosts/tui/import_export_modal.py | 94 +++++++++++++++++++++- tests/test_import_export_workflow.py | 113 +++++++++++++++++++++++++-- 4 files changed, 251 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 591333e..f49f9fe 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ on macOS and Linux. - Creates a timestamped Pre-edit Backup before Privileged Mode begins. - Restores the current session's Pre-edit Backup after explicit confirmation. - Supports undo and redo during the current Privileged Mode session. -- Exports Host Entries as hosts, JSON, or CSV files, and imports those formats in Privileged Mode. +- Exports Host Entries as hosts, JSON, or CSV files. In Privileged Mode, + imports can add Host Entries or replace the Hosts File after confirmation. The application starts in Read-only Mode. Default Entries cannot be edited, deleted, activated, deactivated, or moved. diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 044901f..0fd0277 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -36,7 +36,14 @@ from .backup_restore_modal import BackupRestoreModal from .delete_confirmation_modal import DeleteConfirmationModal from .filter_modal import FilterModal from .help_modal import HelpModal -from .import_export_modal import ExportModal, ImportModal, ExportRequest, ImportRequest +from .import_export_modal import ( + ExportModal, + ImportModal, + ExportRequest, + ImportMode, + ImportRequest, + ReplaceImportConfirmationModal, +) from .custom_footer import CustomFooter from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS from .keybindings import HOSTS_MANAGER_BINDINGS @@ -955,19 +962,53 @@ class HostsManagerApp(App): def import_file(format: ImportFormat, path): return importers[format](path) + def apply_import(request: ImportRequest) -> None: + result = request.result + snapshot = self.capture_mutation_state() + if request.mode is ImportMode.ADD: + self.hosts_file = HostsFile( + entries=[*self.hosts_file.entries, *result.entries], + header_comments=list(self.hosts_file.header_comments), + footer_comments=list(self.hosts_file.footer_comments), + ) + action = "Add imported Host Entries" + outcome = "Added and saved" + else: + self.hosts_file = HostsFile(entries=list(result.entries)) + action = "Replace Hosts File from import" + outcome = "Replaced Hosts File with" + self.selected_entry_index = 0 + if self.save_mutation(snapshot, action): + self.table_handler.populate_entries_table() + self.details_handler.update_entry_details() + noun = ( + "Host Entry" + if result.successfully_imported == 1 + else "Host Entries" + ) + self.update_status( + f"✓ {outcome} {result.successfully_imported} " + f"{'imported ' if request.mode is ImportMode.REPLACE else ''}" + f"{noun} from {request.path}" + ) + def handle_import(request: ImportRequest | None) -> None: if request is None: return - result = request.result - snapshot = self.capture_mutation_state() - self.hosts_file = HostsFile(entries=result.entries) - self.selected_entry_index = 0 - if self.save_mutation(snapshot, "Import"): - self.table_handler.populate_entries_table() - self.details_handler.update_entry_details() - self.update_status( - f"✓ Imported and saved {result.successfully_imported} Host Entries from {request.path}" - ) + if request.mode is ImportMode.ADD: + apply_import(request) + return + + def handle_replace_confirmation(confirmed: bool | None) -> None: + if confirmed: + apply_import(request) + + self.push_screen( + ReplaceImportConfirmationModal( + len(self.hosts_file.entries), len(request.result.entries) + ), + handle_replace_confirmation, + ) self.push_screen( ImportModal( diff --git a/src/hosts/tui/import_export_modal.py b/src/hosts/tui/import_export_modal.py index b3c792f..a90f920 100644 --- a/src/hosts/tui/import_export_modal.py +++ b/src/hosts/tui/import_export_modal.py @@ -1,6 +1,7 @@ """Keyboard-first import and export forms for Host Entries.""" from dataclasses import dataclass +from enum import Enum from pathlib import Path from rich.text import Text @@ -21,12 +22,20 @@ class ExportRequest: format: ExportFormat +class ImportMode(Enum): + """How imported Host Entries are applied to the current Hosts File.""" + + ADD = "add" + REPLACE = "replace" + + @dataclass(frozen=True) class ImportRequest: - """A validated request to replace the Hosts File from an import.""" + """A validated request to apply imported Host Entries.""" path: Path format: ImportFormat + mode: ImportMode result: ImportResult @@ -55,6 +64,10 @@ class _FormatModal(ModalScreen): height: 5; margin-top: 1; padding: 0 1; border: round $primary; } #format-select { height: 3; margin: 0; padding: 0; border: none; } + .import-mode-section { + height: 4; margin-top: 1; padding: 0 1; border: round $primary; + } + #import-mode-select { height: 2; margin: 0; padding: 0; border: none; } .button-row { margin-top: 1; height: 3; align: center middle; } """ @@ -181,7 +194,7 @@ class ExportModal(_FormatModal): class ImportModal(_FormatModal): - """Collect an import file and format before replacing Host Entries.""" + """Collect an import file, format, and application mode.""" def __init__(self, formats: list[ImportFormat], import_file): super().__init__() @@ -192,7 +205,7 @@ class ImportModal(_FormatModal): with Vertical(id="format-container"): yield Static("Import Host Entries", classes="format-title") yield Static( - "Import replaces the current Host Entries and saves the Hosts File.", + "Choose a file, format, and how to apply its Host Entries.", classes="format-copy", ) with Vertical(classes="path-section") as location: @@ -220,6 +233,15 @@ class ImportModal(_FormatModal): value=index == 0, compact=True, ) + with Vertical(classes="import-mode-section") as modes: + modes.border_title = "Import action" + with RadioSet(id="import-mode-select", compact=True): + yield RadioButton( + "Add to Hosts File", id="mode-add", value=True, compact=True + ) + yield RadioButton( + "Replace Hosts File", id="mode-replace", compact=True + ) with Horizontal(classes="button-row"): yield Button("Cancel", id="cancel-button") yield Button("Import and save", id="import-button", variant="primary") @@ -238,7 +260,12 @@ class ImportModal(_FormatModal): if not result.success: self._show_message("#workflow-error", "; ".join(result.errors)) return - self.dismiss(ImportRequest(path, format, result)) + mode = ( + ImportMode.REPLACE + if self.query_one("#mode-replace", RadioButton).value + else ImportMode.ADD + ) + self.dismiss(ImportRequest(path, format, mode, result)) elif event.button.id == "browse-button": self.action_browse() @@ -256,6 +283,59 @@ class ImportModal(_FormatModal): self.app.push_screen(FileBrowserModal(start, choose_directory=False), selected) +class ReplaceImportConfirmationModal(ModalScreen[bool]): + """Warn before replacing every current Host Entry with imported entries.""" + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + CSS = """ + ReplaceImportConfirmationModal { align: center middle; } + #replace-import-confirmation-container { + width: 68; height: auto; max-width: 90%; background: $surface; + border: thick $error; padding: 1 2; + } + .replace-title { + text-align: center; text-style: bold; color: $error; + } + #replace-warning { height: auto; margin-top: 1; color: $warning; } + .replace-consequence { height: auto; margin-top: 1; } + .button-row { margin-top: 1; height: 3; align: center middle; } + """ + + def __init__(self, current_count: int, imported_count: int): + super().__init__() + self._current_count = current_count + self._imported_count = imported_count + + def compose(self) -> ComposeResult: + with Vertical(id="replace-import-confirmation-container"): + yield Static("Replace Hosts File?", classes="replace-title") + yield Static( + f"This will remove all {_entry_count(self._current_count, 'current')} " + f"and replace the Hosts File with " + f"{_entry_count(self._imported_count, 'imported')}.", + id="replace-warning", + ) + yield Static( + "The current Hosts File will not be changed unless you confirm.", + classes="replace-consequence", + ) + with Horizontal(classes="button-row"): + yield Button("Cancel", id="cancel-button") + yield Button("Replace Hosts File", id="replace-button", variant="error") + + def on_mount(self) -> None: + self.query_one("#cancel-button", Button).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "replace-button": + self.dismiss(True) + elif event.button.id == "cancel-button": + self.action_cancel() + + def action_cancel(self) -> None: + self.dismiss(False) + + class ParentDirectoryTree(DirectoryTree): """Directory tree with a conventional parent entry above its root.""" @@ -378,6 +458,12 @@ def _format_label(format: ExportFormat | ImportFormat) -> str: }[format.value] +def _entry_count(count: int, qualifier: str) -> str: + """Describe a qualified number of Host Entries with correct grammar.""" + noun = "Host Entry" if count == 1 else "Host Entries" + return f"{count} {qualifier} {noun}" + + def _format_suffix(format: ExportFormat) -> str: """Provide an editable default filename after choosing an export folder.""" return { diff --git a/tests/test_import_export_workflow.py b/tests/test_import_export_workflow.py index 339d87f..8e2a6a7 100644 --- a/tests/test_import_export_workflow.py +++ b/tests/test_import_export_workflow.py @@ -15,7 +15,12 @@ from hosts.core.import_export import ( ) from hosts.core.models import HostEntry, HostsFile from hosts.tui.app import HostsManagerApp -from hosts.tui.import_export_modal import ExportModal, FileBrowserModal, ImportModal +from hosts.tui.import_export_modal import ( + ExportModal, + FileBrowserModal, + ImportModal, + ReplaceImportConfirmationModal, +) @pytest.mark.asyncio @@ -236,14 +241,45 @@ async def test_import_shortcut_requires_privileged_mode_and_surfaces_service_err @pytest.mark.asyncio -async def test_successful_import_replaces_entries_and_saves_through_the_manager(): - """A valid import reaches the persistence seam without a real Hosts File.""" +async def test_import_form_asks_how_to_apply_entries_at_minimum_supported_size(): + """Both import actions remain visible and reachable at 100 by 30.""" + app = HostsManagerApp() + app.edit_mode = True + app.manager.edit_mode = True + app.import_export_service = Mock() + app.import_export_service.get_supported_import_formats.return_value = list( + ImportFormat + ) + + async with app.run_test(size=(100, 30)) as pilot: + app.action_import_entries() + await pilot.pause() + + assert isinstance(app.screen, ImportModal) + actions = app.screen.query_one("#import-mode-select", RadioSet) + assert [button.label.plain for button in actions.query(RadioButton)] == [ + "Add to Hosts File", + "Replace Hosts File", + ] + assert app.screen.query_one("#mode-add", RadioButton).value + assert not app.screen.query_one("#mode-replace", RadioButton).value + container = app.screen.query_one("#format-container") + submit = app.screen.query_one("#import-button", Button) + assert submit.region.bottom <= container.region.bottom + + +@pytest.mark.asyncio +async def test_successful_import_adds_entries_and_saves_through_the_manager(): + """The safe default appends imported Host Entries to the Hosts File.""" app = HostsManagerApp() app.hosts_file = HostsFile( - entries=[HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])] + entries=[HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])], + header_comments=["existing header"], + footer_comments=["existing footer"], ) app.edit_mode = True app.manager.edit_mode = True + app.load_hosts_file = Mock() app.save_mutation = Mock(return_value=True) app.import_export_service = Mock() app.import_export_service.get_supported_import_formats.return_value = list( @@ -269,9 +305,74 @@ async def test_successful_import_replaces_entries_and_saves_through_the_manager( await pilot.pause() assert [entry.hostnames for entry in app.hosts_file.entries] == [ - ["imported.test"] + ["original.test"], + ["imported.test"], ] + assert app.hosts_file.header_comments == ["existing header"] + assert app.hosts_file.footer_comments == ["existing footer"] app.save_mutation.assert_called_once() - assert "Imported and saved 1 Host Entries" in str( + assert "Added and saved 1 Host Entry" in str( + app.query_one("#message-rail", Static).render() + ) + + +@pytest.mark.asyncio +async def test_replace_import_requires_explicit_destructive_confirmation(): + """Replacement cannot mutate until the warning modal is confirmed.""" + app = HostsManagerApp() + original = HostEntry(ip_address="192.0.2.10", hostnames=["original.test"]) + imported = HostEntry(ip_address="198.51.100.3", hostnames=["imported.test"]) + app.hosts_file = HostsFile(entries=[original]) + app.edit_mode = True + app.manager.edit_mode = True + app.load_hosts_file = Mock() + app.save_mutation = Mock(return_value=True) + app.import_export_service = Mock() + app.import_export_service.get_supported_import_formats.return_value = list( + ImportFormat + ) + app.import_export_service.import_json_format.return_value = ImportResult( + success=True, + entries=[imported], + errors=[], + warnings=[], + total_processed=1, + successfully_imported=1, + ) + + async with app.run_test(size=(120, 40)) as pilot: + + async def request_replacement() -> None: + app.action_import_entries() + await pilot.pause() + app.screen.query_one("#path-input", Input).value = "/tmp/entries.json" + app.screen.query_one("#format-json", RadioButton).value = True + app.screen.query_one("#mode-replace", RadioButton).value = True + await pilot.click("#import-button") + await pilot.pause() + + await request_replacement() + + assert isinstance(app.screen, ReplaceImportConfirmationModal) + assert app.focused is app.screen.query_one("#cancel-button", Button) + assert "remove all 1 current Host Entry" in str( + app.screen.query_one("#replace-warning", Static).render() + ) + assert app.hosts_file.entries == [original] + app.save_mutation.assert_not_called() + + await pilot.click("#cancel-button") + await pilot.pause() + assert app.hosts_file.entries == [original] + app.save_mutation.assert_not_called() + + await request_replacement() + await pilot.click("#replace-button") + await pilot.pause() + await pilot.pause() + + assert app.hosts_file.entries == [imported] + app.save_mutation.assert_called_once() + assert "Replaced Hosts File with 1 imported Host Entry" in str( app.query_one("#message-rail", Static).render() )