Feat #5: choose additive or replacement import
This commit is contained in:
parent
48c9760e3c
commit
9040cf72a8
4 changed files with 251 additions and 22 deletions
|
|
@ -22,7 +22,8 @@ on macOS and Linux.
|
||||||
- Creates a timestamped Pre-edit Backup before Privileged Mode begins.
|
- Creates a timestamped Pre-edit Backup before Privileged Mode begins.
|
||||||
- Restores the current session's Pre-edit Backup after explicit confirmation.
|
- Restores the current session's Pre-edit Backup after explicit confirmation.
|
||||||
- Supports undo and redo during the current Privileged Mode session.
|
- 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,
|
The application starts in Read-only Mode. Default Entries cannot be edited,
|
||||||
deleted, activated, deactivated, or moved.
|
deleted, activated, deactivated, or moved.
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,14 @@ from .backup_restore_modal import BackupRestoreModal
|
||||||
from .delete_confirmation_modal import DeleteConfirmationModal
|
from .delete_confirmation_modal import DeleteConfirmationModal
|
||||||
from .filter_modal import FilterModal
|
from .filter_modal import FilterModal
|
||||||
from .help_modal import HelpModal
|
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 .custom_footer import CustomFooter
|
||||||
from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS
|
from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS
|
||||||
from .keybindings import HOSTS_MANAGER_BINDINGS
|
from .keybindings import HOSTS_MANAGER_BINDINGS
|
||||||
|
|
@ -955,18 +962,52 @@ class HostsManagerApp(App):
|
||||||
def import_file(format: ImportFormat, path):
|
def import_file(format: ImportFormat, path):
|
||||||
return importers[format](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:
|
def handle_import(request: ImportRequest | None) -> None:
|
||||||
if request is None:
|
if request is None:
|
||||||
return
|
return
|
||||||
result = request.result
|
if request.mode is ImportMode.ADD:
|
||||||
snapshot = self.capture_mutation_state()
|
apply_import(request)
|
||||||
self.hosts_file = HostsFile(entries=result.entries)
|
return
|
||||||
self.selected_entry_index = 0
|
|
||||||
if self.save_mutation(snapshot, "Import"):
|
def handle_replace_confirmation(confirmed: bool | None) -> None:
|
||||||
self.table_handler.populate_entries_table()
|
if confirmed:
|
||||||
self.details_handler.update_entry_details()
|
apply_import(request)
|
||||||
self.update_status(
|
|
||||||
f"✓ Imported and saved {result.successfully_imported} Host Entries from {request.path}"
|
self.push_screen(
|
||||||
|
ReplaceImportConfirmationModal(
|
||||||
|
len(self.hosts_file.entries), len(request.result.entries)
|
||||||
|
),
|
||||||
|
handle_replace_confirmation,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.push_screen(
|
self.push_screen(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Keyboard-first import and export forms for Host Entries."""
|
"""Keyboard-first import and export forms for Host Entries."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
@ -21,12 +22,20 @@ class ExportRequest:
|
||||||
format: ExportFormat
|
format: ExportFormat
|
||||||
|
|
||||||
|
|
||||||
|
class ImportMode(Enum):
|
||||||
|
"""How imported Host Entries are applied to the current Hosts File."""
|
||||||
|
|
||||||
|
ADD = "add"
|
||||||
|
REPLACE = "replace"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ImportRequest:
|
class ImportRequest:
|
||||||
"""A validated request to replace the Hosts File from an import."""
|
"""A validated request to apply imported Host Entries."""
|
||||||
|
|
||||||
path: Path
|
path: Path
|
||||||
format: ImportFormat
|
format: ImportFormat
|
||||||
|
mode: ImportMode
|
||||||
result: ImportResult
|
result: ImportResult
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -55,6 +64,10 @@ class _FormatModal(ModalScreen):
|
||||||
height: 5; margin-top: 1; padding: 0 1; border: round $primary;
|
height: 5; margin-top: 1; padding: 0 1; border: round $primary;
|
||||||
}
|
}
|
||||||
#format-select { height: 3; margin: 0; padding: 0; border: none; }
|
#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; }
|
.button-row { margin-top: 1; height: 3; align: center middle; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -181,7 +194,7 @@ class ExportModal(_FormatModal):
|
||||||
|
|
||||||
|
|
||||||
class ImportModal(_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):
|
def __init__(self, formats: list[ImportFormat], import_file):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
@ -192,7 +205,7 @@ class ImportModal(_FormatModal):
|
||||||
with Vertical(id="format-container"):
|
with Vertical(id="format-container"):
|
||||||
yield Static("Import Host Entries", classes="format-title")
|
yield Static("Import Host Entries", classes="format-title")
|
||||||
yield Static(
|
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",
|
classes="format-copy",
|
||||||
)
|
)
|
||||||
with Vertical(classes="path-section") as location:
|
with Vertical(classes="path-section") as location:
|
||||||
|
|
@ -220,6 +233,15 @@ class ImportModal(_FormatModal):
|
||||||
value=index == 0,
|
value=index == 0,
|
||||||
compact=True,
|
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"):
|
with Horizontal(classes="button-row"):
|
||||||
yield Button("Cancel", id="cancel-button")
|
yield Button("Cancel", id="cancel-button")
|
||||||
yield Button("Import and save", id="import-button", variant="primary")
|
yield Button("Import and save", id="import-button", variant="primary")
|
||||||
|
|
@ -238,7 +260,12 @@ class ImportModal(_FormatModal):
|
||||||
if not result.success:
|
if not result.success:
|
||||||
self._show_message("#workflow-error", "; ".join(result.errors))
|
self._show_message("#workflow-error", "; ".join(result.errors))
|
||||||
return
|
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":
|
elif event.button.id == "browse-button":
|
||||||
self.action_browse()
|
self.action_browse()
|
||||||
|
|
||||||
|
|
@ -256,6 +283,59 @@ class ImportModal(_FormatModal):
|
||||||
self.app.push_screen(FileBrowserModal(start, choose_directory=False), selected)
|
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):
|
class ParentDirectoryTree(DirectoryTree):
|
||||||
"""Directory tree with a conventional parent entry above its root."""
|
"""Directory tree with a conventional parent entry above its root."""
|
||||||
|
|
||||||
|
|
@ -378,6 +458,12 @@ def _format_label(format: ExportFormat | ImportFormat) -> str:
|
||||||
}[format.value]
|
}[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:
|
def _format_suffix(format: ExportFormat) -> str:
|
||||||
"""Provide an editable default filename after choosing an export folder."""
|
"""Provide an editable default filename after choosing an export folder."""
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,12 @@ from hosts.core.import_export import (
|
||||||
)
|
)
|
||||||
from hosts.core.models import HostEntry, HostsFile
|
from hosts.core.models import HostEntry, HostsFile
|
||||||
from hosts.tui.app import HostsManagerApp
|
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
|
@pytest.mark.asyncio
|
||||||
|
|
@ -236,14 +241,45 @@ async def test_import_shortcut_requires_privileged_mode_and_surfaces_service_err
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_successful_import_replaces_entries_and_saves_through_the_manager():
|
async def test_import_form_asks_how_to_apply_entries_at_minimum_supported_size():
|
||||||
"""A valid import reaches the persistence seam without a real Hosts File."""
|
"""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 = HostsManagerApp()
|
||||||
app.hosts_file = HostsFile(
|
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.edit_mode = True
|
||||||
app.manager.edit_mode = True
|
app.manager.edit_mode = True
|
||||||
|
app.load_hosts_file = Mock()
|
||||||
app.save_mutation = Mock(return_value=True)
|
app.save_mutation = Mock(return_value=True)
|
||||||
app.import_export_service = Mock()
|
app.import_export_service = Mock()
|
||||||
app.import_export_service.get_supported_import_formats.return_value = list(
|
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()
|
await pilot.pause()
|
||||||
|
|
||||||
assert [entry.hostnames for entry in app.hosts_file.entries] == [
|
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()
|
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()
|
app.query_one("#message-rail", Static).render()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue