Adopt mise for development workflows

- Lock Python and uv versions with mise
- Add unified sync, app, test, lint, format, and typecheck tasks
- Update development documentation and strengthen type handling
This commit is contained in:
Philip Henning 2026-09-04 14:39:02 +02:00
parent d04ab6feaf
commit 780d9c12fe
19 changed files with 216 additions and 901 deletions

View file

@ -17,18 +17,20 @@ is reachable in the current application.
## Commands ## Commands
```bash ```bash
uv sync mise install
uv run hosts mise run sync
uv run pytest mise run app
uv run pytest tests/test_parser.py mise run test
uv run ruff check . mise run test -- tests/test_parser.py
uv run ruff format --check . mise run lint
uv run ruff format . mise run format-check
mise run typecheck
mise run check
mise run format
``` ```
Use `uv run pytest <path>` for the narrowest relevant test during development. Use `mise run test -- <path>` for the narrowest relevant test during
Before handing off a change, run the full test suite, Ruff lint, and Ruff format development. Before handing off a change, run `mise run check`.
check.
## Code boundaries ## Code boundaries

View file

@ -9,17 +9,18 @@ bugs or coordinate consequential changes.
## Development setup ## Development setup
The project requires Python 3.13 or newer and The project uses [mise](https://mise.jdx.dev/) to install its locked Python and
[uv](https://docs.astral.sh/uv/): uv versions and to provide its development tasks:
```bash ```bash
git clone https://git.s1q.dev/phg/hosts.git git clone https://git.s1q.dev/phg/hosts.git
cd hosts cd hosts
uv sync mise install
uv run hosts mise run sync
mise run app
``` ```
`uv run hosts` starts the real application and reads `/etc/hosts`. Stay in `mise run app` starts the real application and reads `/etc/hosts`. Stay in
Read-only Mode unless you deliberately intend to exercise privileged behavior Read-only Mode unless you deliberately intend to exercise privileged behavior
on your machine. Automated tests must use mocks and temporary files instead. on your machine. Automated tests must use mocks and temporary files instead.
@ -80,23 +81,21 @@ Treat any test capable of touching the real system file as a defect.
Run the narrowest relevant test while developing. Examples: Run the narrowest relevant test while developing. Examples:
```bash ```bash
uv run pytest tests/test_parser.py mise run test -- tests/test_parser.py
uv run pytest tests/test_manager.py mise run test -- tests/test_manager.py
uv run pytest tests/test_main.py mise run test -- tests/test_main.py
``` ```
Before handing off any change, run the complete validation suite: Before handing off any change, run the complete validation suite:
```bash ```bash
uv run pytest mise run check
uv run ruff check .
uv run ruff format --check .
``` ```
Apply formatting with: Apply formatting with:
```bash ```bash
uv run ruff format . mise run format
``` ```
Add or update tests for changed behavior. Test user-reachable workflows at the Add or update tests for changed behavior. Test user-reachable workflows at the

View file

@ -93,10 +93,14 @@ Clone the repository for development:
```bash ```bash
git clone https://git.s1q.dev/phg/hosts.git git clone https://git.s1q.dev/phg/hosts.git
cd hosts cd hosts
uv sync mise install
uv run hosts mise run sync
mise run app
``` ```
[mise](https://mise.jdx.dev/) installs the locked Python and uv versions and
provides the project's development tasks.
See [CONTRIBUTING.md](CONTRIBUTING.md) for code boundaries, safety contracts, See [CONTRIBUTING.md](CONTRIBUTING.md) for code boundaries, safety contracts,
tests, linting, and documentation expectations. Domain language lives in tests, linting, and documentation expectations. Domain language lives in
[CONTEXT.md](CONTEXT.md), and consequential design decisions live in [CONTEXT.md](CONTEXT.md), and consequential design decisions live in

45
mise.lock Normal file
View file

@ -0,0 +1,45 @@
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
lockfile_version = 1
[[tools.python]]
version = "3.13.15"
backend = "core:python"
specifiers = ["3.13"]
[tools.python."platforms.linux-arm64"]
checksum = "sha256:01ce0ce9189feaead3298abf10d4efe998c55a489b3d5d38ca4f83dda7e7977e"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260901/cpython-3.13.15+20260901-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
[tools.python."platforms.linux-x64"]
checksum = "sha256:8a689a077337bea6d1c4bc0b7df1d52fcaa28f5f67e50df8bf417c1e3f9d8874"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260901/cpython-3.13.15+20260901-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
[tools.python."platforms.macos-arm64"]
checksum = "sha256:d3904bd6a072246e07aa0bdadee9a14e80521e42a943c0848059feb16a2816dc"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260901/cpython-3.13.15+20260901-aarch64-apple-darwin-install_only_stripped.tar.gz"
[tools.python."platforms.macos-x64"]
checksum = "sha256:f712a9143c8a5d248438ec7921a0b48d548bca4f1337d33c690d28c2d0504137"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260901/cpython-3.13.15+20260901-x86_64-apple-darwin-install_only_stripped.tar.gz"
[[tools.uv]]
version = "0.12.9"
backend = "aqua:astral-sh/uv"
specifiers = ["0.12"]
[tools.uv."platforms.linux-arm64"]
checksum = "sha256:7eb9bf48516448c9db6a9e436d8e747ac9c8a9cac74717160a29918249b080a6"
url = "https://github.com/astral-sh/uv/releases/download/0.12.9/uv-aarch64-unknown-linux-musl.tar.gz"
[tools.uv."platforms.linux-x64"]
checksum = "sha256:aa4b1f8770910f7c7c543c7acc980e4270e52e70750c996acef813ea1c7c2912"
url = "https://github.com/astral-sh/uv/releases/download/0.12.9/uv-x86_64-unknown-linux-musl.tar.gz"
[tools.uv."platforms.macos-arm64"]
checksum = "sha256:301f72afaf54060f92da7016cb0115bd077f43a9c8e39c1d8170a0bac80fd398"
url = "https://github.com/astral-sh/uv/releases/download/0.12.9/uv-aarch64-apple-darwin.tar.gz"
[tools.uv."platforms.macos-x64"]
checksum = "sha256:e1ca175824f1056589ce9908f7631879ebc3c36535b5e63dc06510beb370b4c1"
url = "https://github.com/astral-sh/uv/releases/download/0.12.9/uv-x86_64-apple-darwin.tar.gz"

42
mise.toml Normal file
View file

@ -0,0 +1,42 @@
[tools]
python = "3.13"
uv = "0.12"
[settings]
lockfile = true
[tool_config]
locked = true
[tasks.sync]
description = "Synchronize project dependencies"
run = "uv sync"
[tasks.app]
description = "Run the hosts application"
run = "uv run hosts"
[tasks.test]
description = "Run the test suite"
run = "uv run pytest"
raw = true
[tasks.lint]
description = "Check code with Ruff"
run = "uv run ruff check ."
[tasks.format]
description = "Format code with Ruff"
run = "uv run ruff format ."
[tasks.format-check]
description = "Check formatting with Ruff"
run = "uv run ruff format --check ."
[tasks.typecheck]
description = "Type-check the project with ty"
run = "uv run ty check"
[tasks.check]
description = "Run the complete validation suite"
depends = ["test", "lint", "format-check", "typecheck"]

View file

@ -6,9 +6,14 @@ readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"textual>=5.0.1", "textual>=5.0.1",
]
[dependency-groups]
dev = [
"pytest>=8.4.1", "pytest>=8.4.1",
"pytest-asyncio>=0.21.0", "pytest-asyncio>=0.21.0",
"ruff>=0.12.5", "ruff>=0.12.5",
"ty>=0.0.78",
] ]
[project.scripts] [project.scripts]

View file

@ -15,7 +15,6 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass
class DNSResolutionStatus(Enum): class DNSResolutionStatus(Enum):
"""Status of DNS resolution for an entry.""" """Status of DNS resolution for an entry."""
@ -70,6 +69,8 @@ async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution
if result: if result:
# Get first result (usually IPv4) # Get first result (usually IPv4)
ip_address = result[0][4][0] ip_address = result[0][4][0]
if not isinstance(ip_address, str):
raise OSError("DNS resolver returned a non-IP address")
return DNSResolution( return DNSResolution(
hostname=hostname, hostname=hostname,
resolved_ip=ip_address, resolved_ip=ip_address,
@ -124,7 +125,7 @@ async def resolve_hostnames_batch(
# Convert exceptions to failed resolutions # Convert exceptions to failed resolutions
resolutions = [] resolutions = []
for i, result in enumerate(results): for i, result in enumerate(results):
if isinstance(result, Exception): if isinstance(result, BaseException):
resolutions.append( resolutions.append(
DNSResolution( DNSResolution(
hostname=hostnames[i], hostname=hostnames[i],

View file

@ -423,8 +423,9 @@ class ImportExportService:
# Validate required columns # Validate required columns
required_columns = ["hostnames"] required_columns = ["hostnames"]
fieldnames = reader.fieldnames or []
missing_columns = [ missing_columns = [
col for col in required_columns if col not in reader.fieldnames col for col in required_columns if col not in fieldnames
] ]
if missing_columns: if missing_columns:
return ImportResult( return ImportResult(

View file

@ -14,7 +14,7 @@ from ..core.models import HostEntry
from .styles import ADD_ENTRY_MODAL_CSS from .styles import ADD_ENTRY_MODAL_CSS
class AddEntryModal(ModalScreen): class AddEntryModal(ModalScreen[HostEntry | None]):
""" """
Modal screen for adding new host entries. Modal screen for adding new host entries.
@ -132,6 +132,7 @@ class AddEntryModal(ModalScreen):
# Reset checkbox to default (active) for IP entries # Reset checkbox to default (active) for IP entries
active_checkbox.value = True active_checkbox.value = True
if isinstance(active_section, Vertical):
active_section.border_title = "Activate Entry" active_section.border_title = "Activate Entry"
# Focus IP input # Focus IP input
@ -149,6 +150,7 @@ class AddEntryModal(ModalScreen):
# Set checkbox to inactive for DNS entries (will be activated after resolution) # Set checkbox to inactive for DNS entries (will be activated after resolution)
active_checkbox.value = False active_checkbox.value = False
if isinstance(active_section, Vertical):
active_section.border_title = ( active_section.border_title = (
"Activate Entry (DNS entries activate after resolution)" "Activate Entry (DNS entries activate after resolution)"
) )
@ -172,7 +174,7 @@ class AddEntryModal(ModalScreen):
# Determine entry type # Determine entry type
radio_set = self.query_one("#entry-type-radio", RadioSet) radio_set = self.query_one("#entry-type-radio", RadioSet)
is_dns_entry = ( is_dns_entry = (
radio_set.pressed_button radio_set.pressed_button is not None
and radio_set.pressed_button.id == "dns-entry-radio" and radio_set.pressed_button.id == "dns-entry-radio"
) )

View file

@ -8,6 +8,7 @@ all the handlers and provides the primary user interface.
from dataclasses import dataclass from dataclasses import dataclass
from textual.app import App, ComposeResult, SuspendNotSupported from textual.app import App, ComposeResult, SuspendNotSupported
from textual.binding import Binding
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.widgets import ( from textual.widgets import (
Header, Header,
@ -29,7 +30,6 @@ from ..core.filters import EntryFilter, FilterOptions
from .config_modal import ConfigModal from .config_modal import ConfigModal
from .add_entry_modal import AddEntryModal from .add_entry_modal import AddEntryModal
from .delete_confirmation_modal import DeleteConfirmationModal from .delete_confirmation_modal import DeleteConfirmationModal
from .filter_modal import FilterModal
from .custom_footer import CustomFooter from .custom_footer import CustomFooter
from .styles import HOSTS_MANAGER_CSS from .styles import HOSTS_MANAGER_CSS
from .keybindings import HOSTS_MANAGER_BINDINGS from .keybindings import HOSTS_MANAGER_BINDINGS
@ -289,7 +289,7 @@ class HostsManagerApp(App):
# Process keybindings and add to appropriate sections # Process keybindings and add to appropriate sections
for binding in self.BINDINGS: for binding in self.BINDINGS:
# Skip tuple-style bindings and only process Binding objects # Skip tuple-style bindings and only process Binding objects
if not hasattr(binding, "show"): if not isinstance(binding, Binding):
continue continue
# Only show bindings marked with show=True # Only show bindings marked with show=True
@ -506,7 +506,7 @@ class HostsManagerApp(App):
def action_config(self) -> None: def action_config(self) -> None:
"""Show configuration modal.""" """Show configuration modal."""
def handle_config_result(config_changed: bool) -> None: def handle_config_result(config_changed: bool | None) -> None:
if config_changed: if config_changed:
# Reload the table to apply new filtering # Reload the table to apply new filtering
self.table_handler.populate_entries_table() self.table_handler.populate_entries_table()
@ -719,7 +719,7 @@ class HostsManagerApp(App):
self.update_status("❌ Cannot delete system default entry") self.update_status("❌ Cannot delete system default entry")
return return
def handle_delete_confirmation(confirmed: bool) -> None: def handle_delete_confirmation(confirmed: bool | None) -> None:
if not confirmed: if not confirmed:
self.update_status("Entry deletion cancelled") self.update_status("Entry deletion cancelled")
return return
@ -750,7 +750,7 @@ class HostsManagerApp(App):
self.push_screen(DeleteConfirmationModal(entry), handle_delete_confirmation) self.push_screen(DeleteConfirmationModal(entry), handle_delete_confirmation)
def action_quit(self) -> None: async def action_quit(self) -> None:
"""Quit the application.""" """Quit the application."""
self.navigation_handler.quit_application() self.navigation_handler.quit_application()
@ -987,63 +987,6 @@ class HostsManagerApp(App):
self.run_worker(update_single_dns(), exclusive=False) self.run_worker(update_single_dns(), exclusive=False)
self.update_status(f"🔄 Resolving DNS for {entry.dns_name}...") self.update_status(f"🔄 Resolving DNS for {entry.dns_name}...")
def action_show_filters(self) -> None:
"""Show advanced filtering modal."""
def handle_filter_result(filter_options: FilterOptions) -> None:
if filter_options is None:
# User cancelled
self.update_status("Filtering cancelled")
return
# Apply the new filter options
self.current_filter_options = filter_options
# Update the search term from filter if it has one
if filter_options.search_term:
self.search_term = filter_options.search_term
# Update the search input to reflect the filter search term
try:
search_input = self.query_one("#search-input", Input)
search_input.value = filter_options.search_term
except Exception:
pass # Search input not ready
else:
# Clear search term if no search in filter
self.search_term = ""
try:
search_input = self.query_one("#search-input", Input)
search_input.value = ""
except Exception:
pass
# Refresh the table with new filtering
self.table_handler.populate_entries_table()
self.details_handler.update_entry_details()
# Get filter statistics for status message
counts = self.entry_filter.count_filtered_entries(
self.hosts_file.entries, filter_options
)
preset_info = (
f" (preset: {filter_options.preset_name})"
if filter_options.preset_name
else ""
)
self.update_status(
f"✅ Filter applied: showing {counts['filtered']} of {counts['total']} entries{preset_info}"
)
# Show the filter modal with current options and entries for preview
self.push_screen(
FilterModal(
initial_options=self.current_filter_options,
entries=self.hosts_file.entries,
entry_filter=self.entry_filter,
),
handle_filter_result,
)
def _resolve_new_dns_entry(self, entry) -> None: def _resolve_new_dns_entry(self, entry) -> None:
"""Trigger DNS resolution for a newly added DNS entry.""" """Trigger DNS resolution for a newly added DNS entry."""
if not hasattr(entry, "dns_name") or not entry.dns_name: if not hasattr(entry, "dns_name") or not entry.dns_name:

View file

@ -14,7 +14,7 @@ from ..core.config import Config
from .styles import CONFIG_MODAL_CSS from .styles import CONFIG_MODAL_CSS
class ConfigModal(ModalScreen): class ConfigModal(ModalScreen[bool]):
""" """
Modal screen for application configuration. Modal screen for application configuration.

View file

@ -14,7 +14,7 @@ from ..core.models import HostEntry
from .styles import DELETE_CONFIRMATION_MODAL_CSS from .styles import DELETE_CONFIRMATION_MODAL_CSS
class DeleteConfirmationModal(ModalScreen): class DeleteConfirmationModal(ModalScreen[bool]):
""" """
Modal screen for confirming entry deletion. Modal screen for confirming entry deletion.

View file

@ -1,155 +0,0 @@
"""
DNS status widget for displaying DNS resolution status in the TUI.
This module provides a visual indicator widget that shows the current
DNS resolution status and allows users to toggle DNS service.
"""
from textual.widgets import Static
from textual.reactive import reactive
from textual.containers import Horizontal
from ..core.dns import DNSService
class DNSStatusWidget(Static):
"""
Widget to display DNS resolution service status.
Shows visual indicators for DNS service status and resolution progress.
"""
# Reactive attributes
dns_enabled: reactive[bool] = reactive(False)
resolving_count: reactive[int] = reactive(0)
resolved_count: reactive[int] = reactive(0)
failed_count: reactive[int] = reactive(0)
def __init__(self, dns_service: DNSService, **kwargs):
super().__init__(**kwargs)
self.dns_service = dns_service
self.dns_enabled = dns_service.enabled
self.update_status()
def compose(self):
"""Create the DNS status display."""
with Horizontal(classes="dns-status-container"):
yield Static("", id="dns-status-indicator", classes="dns-indicator")
yield Static("", id="dns-status-text", classes="dns-status-text")
def update_status(self) -> None:
"""Update the DNS status display."""
try:
indicator = self.query_one("#dns-status-indicator", Static)
text_widget = self.query_one("#dns-status-text", Static)
if not self.dns_enabled:
indicator.update("")
text_widget.update("DNS: Disabled")
indicator.remove_class("dns-active")
indicator.remove_class("dns-resolving")
indicator.add_class("dns-disabled")
elif self.resolving_count > 0:
indicator.update("🔄")
text_widget.update(f"DNS: Resolving ({self.resolving_count} pending)")
indicator.remove_class("dns-disabled")
indicator.remove_class("dns-active")
indicator.add_class("dns-resolving")
else:
indicator.update("")
status_parts = []
if self.resolved_count > 0:
status_parts.append(f"{self.resolved_count} resolved")
if self.failed_count > 0:
status_parts.append(f"{self.failed_count} failed")
if status_parts:
status_text = f"DNS: Active ({', '.join(status_parts)})"
else:
status_text = "DNS: Active"
text_widget.update(status_text)
indicator.remove_class("dns-disabled")
indicator.remove_class("dns-resolving")
indicator.add_class("dns-active")
except Exception:
# Widget not ready yet
pass
def watch_dns_enabled(self, enabled: bool) -> None:
"""React to DNS service enable/disable changes."""
self.update_status()
def watch_resolving_count(self, count: int) -> None:
"""React to changes in resolving count."""
self.update_status()
def watch_resolved_count(self, count: int) -> None:
"""React to changes in resolved count."""
self.update_status()
def watch_failed_count(self, count: int) -> None:
"""React to changes in failed count."""
self.update_status()
def update_from_service(self) -> None:
"""Update status from the current DNS service state."""
self.dns_enabled = self.dns_service.enabled
# Count DNS resolution states from the service
if hasattr(self.dns_service, "_resolution_cache"):
cache = self.dns_service._resolution_cache
resolving = sum(1 for r in cache.values() if r.status == "RESOLVING")
resolved = sum(
1 for r in cache.values() if r.status in ["RESOLVED", "IP_MATCH"]
)
failed = sum(
1
for r in cache.values()
if r.status in ["RESOLUTION_FAILED", "IP_MISMATCH"]
)
self.resolving_count = resolving
self.resolved_count = resolved
self.failed_count = failed
else:
self.resolving_count = 0
self.resolved_count = 0
self.failed_count = 0
def toggle_service(self) -> None:
"""Toggle the DNS service on/off."""
if self.dns_service.enabled:
self.dns_service.stop()
else:
self.dns_service.start()
self.dns_enabled = self.dns_service.enabled
self.update_status()
def get_status_text(self) -> str:
"""Get current status as text for display purposes."""
if not self.dns_enabled:
return "DNS Disabled"
elif self.resolving_count > 0:
return f"DNS Resolving ({self.resolving_count})"
else:
parts = []
if self.resolved_count > 0:
parts.append(f"{self.resolved_count} resolved")
if self.failed_count > 0:
parts.append(f"{self.failed_count} failed")
if parts:
return f"DNS Active ({', '.join(parts)})"
else:
return "DNS Active"
def get_status_symbol(self) -> str:
"""Get current status symbol."""
if not self.dns_enabled:
return ""
elif self.resolving_count > 0:
return "🔄"
else:
return ""

View file

@ -1,612 +0,0 @@
"""
Filter modal for advanced entry filtering configuration.
This module provides a professional modal dialog for configuring comprehensive
filtering options including status, type, resolution status, and search filtering.
"""
from textual.app import ComposeResult
from textual.containers import Grid, Horizontal, Container
from textual.widgets import (
Static,
Button,
Checkbox,
Input,
Select,
Label,
RadioSet,
RadioButton,
Collapsible,
)
from textual.screen import ModalScreen
from textual.reactive import reactive
from textual import on
from typing import Optional, Dict, List
from ..core.filters import FilterOptions, EntryFilter
class FilterModal(ModalScreen[Optional[FilterOptions]]):
"""Advanced filtering configuration modal."""
DEFAULT_CSS = """
FilterModal {
align: center middle;
}
#filter-dialog {
grid-size: 1;
grid-gutter: 1 2;
grid-rows: auto 1fr auto;
padding: 0 1;
width: 80;
height: auto;
border: thick $background 80%;
background: $surface;
max-height: 90%;
}
#filter-header {
dock: top;
width: 1fr;
height: 3;
content-align: center middle;
text-style: bold;
background: $primary;
color: $text;
}
#filter-content {
layout: vertical;
overflow-y: auto;
height: auto;
max-height: 70vh;
padding: 1;
}
#filter-actions {
dock: bottom;
layout: horizontal;
width: 1fr;
height: 3;
align: center middle;
padding: 0 1;
background: $panel;
}
.filter-section {
margin: 1 0;
padding: 1;
border: round $primary 20%;
background: $panel;
}
.filter-section-title {
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.filter-checkboxes {
layout: vertical;
margin: 0 2;
}
.filter-radios {
layout: vertical;
margin: 0 2;
}
.filter-input-row {
layout: horizontal;
margin: 0 2;
height: 3;
align: center left;
}
.filter-input-label {
width: 20;
content-align: left middle;
margin-right: 1;
}
.filter-input {
width: 30;
}
.preset-row {
layout: horizontal;
margin: 1 2;
height: 3;
align: center left;
}
.preset-select {
width: 30;
margin-right: 2;
}
Button {
margin: 0 1;
min-width: 12;
}
Checkbox {
margin: 0 1;
}
RadioButton {
margin: 0 1;
}
.count-display {
text-style: italic;
color: $text-muted;
content-align: center middle;
height: 1;
margin: 1 0;
}
"""
# Reactive properties for real-time updates
current_options: reactive[FilterOptions] = reactive(FilterOptions())
entry_counts: reactive[Dict[str, int]] = reactive({})
def __init__(
self,
initial_options: Optional[FilterOptions] = None,
entries: Optional[List] = None,
entry_filter: Optional[EntryFilter] = None,
):
"""
Initialize filter modal.
Args:
initial_options: Current filter options to display
entries: List of entries for count preview
entry_filter: EntryFilter instance for applying filters
"""
super().__init__()
self.current_options = initial_options or FilterOptions()
self.entries = entries or []
self.entry_filter = entry_filter or EntryFilter()
self.entry_counts = self._calculate_counts()
def compose(self) -> ComposeResult:
"""Compose the filter modal interface."""
with Grid(id="filter-dialog"):
yield Static("Advanced Filtering", id="filter-header")
with Container(id="filter-content"):
# Filter presets section
with Collapsible(title="Filter Presets", collapsed=False):
with Container(classes="filter-section"):
with Horizontal(classes="preset-row"):
yield Label("Preset:", classes="filter-input-label")
yield Select(
[
(name, name)
for name in self.entry_filter.get_preset_names()
],
value=self.current_options.preset_name,
id="preset-select",
classes="preset-select",
)
yield Button("Load", id="load-preset", variant="primary")
yield Button("Save", id="save-preset")
yield Button("Delete", id="delete-preset", variant="error")
# Status filtering section
with Collapsible(title="Status Filtering", collapsed=False):
with Container(classes="filter-section"):
yield Static("Status Filtering", classes="filter-section-title")
with RadioSet(id="status-filter-type"):
yield RadioButton("Show All", value="all", id="status-all")
yield RadioButton(
"Active Only", value="active", id="status-active"
)
yield RadioButton(
"Inactive Only", value="inactive", id="status-inactive"
)
yield RadioButton(
"Custom", value="custom", id="status-custom"
)
with Container(
classes="filter-checkboxes", id="status-custom-options"
):
yield Checkbox(
"Show Active Entries", value=True, id="show-active"
)
yield Checkbox(
"Show Inactive Entries", value=True, id="show-inactive"
)
# DNS type filtering section
with Collapsible(title="Entry Type Filtering", collapsed=False):
with Container(classes="filter-section"):
yield Static(
"Entry Type Filtering", classes="filter-section-title"
)
with RadioSet(id="type-filter-type"):
yield RadioButton("Show All", value="all", id="type-all")
yield RadioButton(
"DNS Entries Only", value="dns", id="type-dns"
)
yield RadioButton(
"IP Entries Only", value="ip", id="type-ip"
)
yield RadioButton(
"Custom", value="custom", id="type-custom"
)
with Container(
classes="filter-checkboxes", id="type-custom-options"
):
yield Checkbox(
"Show DNS Entries", value=True, id="show-dns"
)
yield Checkbox("Show IP Entries", value=True, id="show-ip")
# DNS resolution status filtering section
with Collapsible(title="Resolution Status Filtering", collapsed=False):
with Container(classes="filter-section"):
yield Static(
"Resolution Status Filtering",
classes="filter-section-title",
)
with RadioSet(id="resolution-filter-type"):
yield RadioButton(
"Show All", value="all", id="resolution-all"
)
yield RadioButton(
"Resolved Only",
value="resolved",
id="resolution-resolved",
)
yield RadioButton(
"Mismatches Only",
value="mismatch",
id="resolution-mismatch",
)
yield RadioButton(
"Custom", value="custom", id="resolution-custom"
)
with Container(
classes="filter-checkboxes", id="resolution-custom-options"
):
yield Checkbox(
"Show Resolved", value=True, id="show-resolved"
)
yield Checkbox(
"Show Unresolved", value=True, id="show-unresolved"
)
yield Checkbox(
"Show Resolving", value=True, id="show-resolving"
)
yield Checkbox("Show Failed", value=True, id="show-failed")
yield Checkbox(
"Show Mismatched", value=True, id="show-mismatched"
)
# Search filtering section
with Collapsible(title="Search Filtering", collapsed=True):
with Container(classes="filter-section"):
yield Static("Search Filtering", classes="filter-section-title")
with Horizontal(classes="filter-input-row"):
yield Label("Search term:", classes="filter-input-label")
yield Input(
placeholder="Enter search term...",
value=self.current_options.search_term or "",
id="search-term",
classes="filter-input",
)
with Container(classes="filter-checkboxes"):
yield Checkbox(
"Search in hostnames", value=True, id="search-hostnames"
)
yield Checkbox(
"Search in comments", value=True, id="search-comments"
)
yield Checkbox(
"Search in IP addresses", value=True, id="search-ips"
)
yield Checkbox(
"Case sensitive",
value=False,
id="search-case-sensitive",
)
# Entry count display
yield Static("", id="count-display", classes="count-display")
with Horizontal(id="filter-actions"):
yield Button("Apply", id="apply", variant="primary")
yield Button("Reset", id="reset")
yield Button("Cancel", id="cancel")
def on_mount(self) -> None:
"""Initialize the modal with current options."""
self._update_ui_from_options()
self._update_count_display()
def _update_ui_from_options(self) -> None:
"""Update UI controls to reflect current options."""
options = self.current_options
# Status filtering
if options.active_only:
self.query_one("#status-active", RadioButton).value = True
elif options.inactive_only:
self.query_one("#status-inactive", RadioButton).value = True
elif options.show_active and options.show_inactive:
self.query_one("#status-all", RadioButton).value = True
else:
self.query_one("#status-custom", RadioButton).value = True
self.query_one("#show-active", Checkbox).value = options.show_active
self.query_one("#show-inactive", Checkbox).value = options.show_inactive
# Type filtering
if options.dns_only:
self.query_one("#type-dns", RadioButton).value = True
elif options.ip_only:
self.query_one("#type-ip", RadioButton).value = True
elif options.show_dns_entries and options.show_ip_entries:
self.query_one("#type-all", RadioButton).value = True
else:
self.query_one("#type-custom", RadioButton).value = True
self.query_one("#show-dns", Checkbox).value = options.show_dns_entries
self.query_one("#show-ip", Checkbox).value = options.show_ip_entries
# Resolution status filtering
if options.resolved_only:
self.query_one("#resolution-resolved", RadioButton).value = True
elif options.mismatch_only:
self.query_one("#resolution-mismatch", RadioButton).value = True
elif (
options.show_resolved
and options.show_unresolved
and options.show_resolving
and options.show_failed
and options.show_mismatched
):
self.query_one("#resolution-all", RadioButton).value = True
else:
self.query_one("#resolution-custom", RadioButton).value = True
self.query_one("#show-resolved", Checkbox).value = options.show_resolved
self.query_one("#show-unresolved", Checkbox).value = options.show_unresolved
self.query_one("#show-resolving", Checkbox).value = options.show_resolving
self.query_one("#show-failed", Checkbox).value = options.show_failed
self.query_one("#show-mismatched", Checkbox).value = options.show_mismatched
# Search filtering
if options.search_term:
self.query_one("#search-term", Input).value = options.search_term
self.query_one(
"#search-hostnames", Checkbox
).value = options.search_in_hostnames
self.query_one("#search-comments", Checkbox).value = options.search_in_comments
self.query_one("#search-ips", Checkbox).value = options.search_in_ips
self.query_one(
"#search-case-sensitive", Checkbox
).value = options.case_sensitive
self._update_custom_options_visibility()
def _update_custom_options_visibility(self) -> None:
"""Show/hide custom option containers based on radio selections."""
# Status custom options
status_custom = self.query_one("#status-custom", RadioButton).value
status_container = self.query_one("#status-custom-options")
status_container.display = status_custom
# Type custom options
type_custom = self.query_one("#type-custom", RadioButton).value
type_container = self.query_one("#type-custom-options")
type_container.display = type_custom
# Resolution custom options
resolution_custom = self.query_one("#resolution-custom", RadioButton).value
resolution_container = self.query_one("#resolution-custom-options")
resolution_container.display = resolution_custom
def _calculate_counts(self) -> Dict[str, int]:
"""Calculate entry counts for current filter options."""
if not self.entries:
return {}
return self.entry_filter.count_filtered_entries(
self.entries, self.current_options
)
def _update_count_display(self) -> None:
"""Update the count display with current filter results."""
counts = self._calculate_counts()
if counts:
count_text = (
f"Showing {counts['filtered']} of {counts['total']} entries "
f"({counts['active']} active, {counts['inactive']} inactive)"
)
else:
count_text = "No entries to filter"
self.query_one("#count-display", Static).update(count_text)
def _get_current_options_from_ui(self) -> FilterOptions:
"""Extract current filter options from UI controls."""
# Status filtering
status_type = self.query_one("#status-filter-type", RadioSet).pressed_button
if status_type and status_type.id == "status-active":
show_active, show_inactive = True, False
active_only, inactive_only = True, False
elif status_type and status_type.id == "status-inactive":
show_active, show_inactive = False, True
active_only, inactive_only = False, True
elif status_type and status_type.id == "status-all":
show_active, show_inactive = True, True
active_only, inactive_only = False, False
else: # custom
show_active = self.query_one("#show-active", Checkbox).value
show_inactive = self.query_one("#show-inactive", Checkbox).value
active_only, inactive_only = False, False
# Type filtering
type_type = self.query_one("#type-filter-type", RadioSet).pressed_button
if type_type and type_type.id == "type-dns":
show_dns_entries, show_ip_entries = True, False
dns_only, ip_only = True, False
elif type_type and type_type.id == "type-ip":
show_dns_entries, show_ip_entries = False, True
dns_only, ip_only = False, True
elif type_type and type_type.id == "type-all":
show_dns_entries, show_ip_entries = True, True
dns_only, ip_only = False, False
else: # custom
show_dns_entries = self.query_one("#show-dns", Checkbox).value
show_ip_entries = self.query_one("#show-ip", Checkbox).value
dns_only, ip_only = False, False
# Resolution status filtering
resolution_type = self.query_one(
"#resolution-filter-type", RadioSet
).pressed_button
if resolution_type and resolution_type.id == "resolution-resolved":
resolved_only, mismatch_only = True, False
(
show_resolved,
show_unresolved,
show_resolving,
show_failed,
show_mismatched,
) = True, False, False, False, False
elif resolution_type and resolution_type.id == "resolution-mismatch":
resolved_only, mismatch_only = False, True
(
show_resolved,
show_unresolved,
show_resolving,
show_failed,
show_mismatched,
) = False, False, False, False, True
elif resolution_type and resolution_type.id == "resolution-all":
resolved_only, mismatch_only = False, False
(
show_resolved,
show_unresolved,
show_resolving,
show_failed,
show_mismatched,
) = True, True, True, True, True
else: # custom
resolved_only, mismatch_only = False, False
show_resolved = self.query_one("#show-resolved", Checkbox).value
show_unresolved = self.query_one("#show-unresolved", Checkbox).value
show_resolving = self.query_one("#show-resolving", Checkbox).value
show_failed = self.query_one("#show-failed", Checkbox).value
show_mismatched = self.query_one("#show-mismatched", Checkbox).value
# Search filtering
search_term = self.query_one("#search-term", Input).value or None
search_hostnames = self.query_one("#search-hostnames", Checkbox).value
search_comments = self.query_one("#search-comments", Checkbox).value
search_ips = self.query_one("#search-ips", Checkbox).value
case_sensitive = self.query_one("#search-case-sensitive", Checkbox).value
return FilterOptions(
show_active=show_active,
show_inactive=show_inactive,
active_only=active_only,
inactive_only=inactive_only,
show_dns_entries=show_dns_entries,
show_ip_entries=show_ip_entries,
dns_only=dns_only,
ip_only=ip_only,
show_resolved=show_resolved,
show_unresolved=show_unresolved,
show_resolving=show_resolving,
show_failed=show_failed,
show_mismatched=show_mismatched,
mismatch_only=mismatch_only,
resolved_only=resolved_only,
search_term=search_term,
search_in_hostnames=search_hostnames,
search_in_comments=search_comments,
search_in_ips=search_ips,
case_sensitive=case_sensitive,
)
@on(RadioSet.Changed)
def on_radio_changed(self, event: RadioSet.Changed) -> None:
"""Handle radio button changes."""
self._update_custom_options_visibility()
self.current_options = self._get_current_options_from_ui()
self._update_count_display()
@on(Checkbox.Changed)
@on(Input.Changed)
def on_input_changed(self) -> None:
"""Handle input changes for real-time preview."""
self.current_options = self._get_current_options_from_ui()
self._update_count_display()
@on(Button.Pressed, "#apply")
def on_apply_pressed(self) -> None:
"""Handle apply button press."""
self.dismiss(self._get_current_options_from_ui())
@on(Button.Pressed, "#cancel")
def on_cancel_pressed(self) -> None:
"""Handle cancel button press."""
self.dismiss(None)
@on(Button.Pressed, "#reset")
def on_reset_pressed(self) -> None:
"""Handle reset button press."""
self.current_options = FilterOptions()
self._update_ui_from_options()
self._update_count_display()
@on(Button.Pressed, "#load-preset")
def on_load_preset_pressed(self) -> None:
"""Handle load preset button press."""
preset_select = self.query_one("#preset-select", Select)
if preset_select.value != Select.BLANK:
preset_options = self.entry_filter.load_preset(str(preset_select.value))
if preset_options:
self.current_options = preset_options
self._update_ui_from_options()
self._update_count_display()
@on(Button.Pressed, "#save-preset")
def on_save_preset_pressed(self) -> None:
"""Handle save preset button press."""
# TODO: Implement preset name input dialog
# For now, just save with a generic name
current_options = self._get_current_options_from_ui()
preset_name = f"Custom Preset {len(self.entry_filter.presets) + 1}"
self.entry_filter.save_preset(preset_name, current_options)
# Update preset select with new preset
preset_select = self.query_one("#preset-select", Select)
preset_select.set_options(
[(name, name) for name in self.entry_filter.get_preset_names()]
)
preset_select.value = preset_name
@on(Button.Pressed, "#delete-preset")
def on_delete_preset_pressed(self) -> None:
"""Handle delete preset button press."""
preset_select = self.query_one("#preset-select", Select)
if preset_select.value != Select.BLANK:
preset_name = str(preset_select.value)
if self.entry_filter.delete_preset(preset_name):
# Update preset select options
preset_select.set_options(
[(name, name) for name in self.entry_filter.get_preset_names()]
)
preset_select.value = Select.BLANK

View file

@ -13,7 +13,7 @@ from textual.binding import Binding
from .styles import SAVE_CONFIRMATION_MODAL_CSS from .styles import SAVE_CONFIRMATION_MODAL_CSS
class SaveConfirmationModal(ModalScreen): class SaveConfirmationModal(ModalScreen[str]):
""" """
Modal screen for save confirmation when exiting edit entry mode. Modal screen for save confirmation when exiting edit entry mode.

View file

@ -6,6 +6,7 @@ DNS name entries, validation, and mutual exclusion logic.
""" """
import pytest import pytest
from typing import cast
from unittest.mock import Mock from unittest.mock import Mock
from textual.widgets import Input, Checkbox, RadioSet, Static from textual.widgets import Input, Checkbox, RadioSet, Static
@ -177,10 +178,7 @@ class TestAddEntryModalDNSSupport:
self.modal.query_one = Mock(side_effect=Exception("Widget not found")) self.modal.query_one = Mock(side_effect=Exception("Widget not found"))
# This should not raise an exception # This should not raise an exception
try:
self.modal._show_error("dns-error", "Test error message") self.modal._show_error("dns-error", "Test error message")
except Exception:
pytest.fail("_show_error should handle missing widgets gracefully")
class TestAddEntryModalRadioButtonLogic: class TestAddEntryModalRadioButtonLogic:
@ -222,7 +220,7 @@ class TestAddEntryModalRadioButtonLogic:
event = MockEvent() event = MockEvent()
# Call the event handler # Call the event handler
self.modal.on_radio_set_changed(event) self.modal.on_radio_set_changed(cast(RadioSet.Changed, event))
# Verify IP section is shown and DNS section is hidden # Verify IP section is shown and DNS section is hidden
mock_ip_section.remove_class.assert_called_with("hidden") mock_ip_section.remove_class.assert_called_with("hidden")
@ -261,7 +259,7 @@ class TestAddEntryModalRadioButtonLogic:
event = MockEvent() event = MockEvent()
# Call the event handler # Call the event handler
self.modal.on_radio_set_changed(event) self.modal.on_radio_set_changed(cast(RadioSet.Changed, event))
# Verify DNS section is shown and IP section is hidden # Verify DNS section is shown and IP section is hidden
mock_ip_section.add_class.assert_called_with("hidden") mock_ip_section.add_class.assert_called_with("hidden")
@ -324,7 +322,7 @@ class TestAddEntryModalSaveLogic:
# Verify validation was called # Verify validation was called
self.modal._validate_input.assert_called_once_with( self.modal._validate_input.assert_called_once_with(
"192.168.1.1", "", "example.com, www.example.com", None "192.168.1.1", "", "example.com, www.example.com", False
) )
# Verify modal was dismissed with a HostEntry # Verify modal was dismissed with a HostEntry

View file

@ -125,6 +125,7 @@ class TestResolveHostname:
assert resolution.hostname == "slow.example" assert resolution.hostname == "slow.example"
assert resolution.resolved_ip is None assert resolution.resolved_ip is None
assert resolution.status == DNSResolutionStatus.RESOLUTION_FAILED assert resolution.status == DNSResolutionStatus.RESOLUTION_FAILED
assert resolution.error_message is not None
assert "Timeout after 1.0s" in resolution.error_message assert "Timeout after 1.0s" in resolution.error_message
assert resolution.is_success() is False assert resolution.is_success() is False
@ -256,6 +257,7 @@ class TestResolveHostnamesBatch:
assert resolutions[0].is_success() is True assert resolutions[0].is_success() is True
assert resolutions[1].hostname == "error.example" assert resolutions[1].hostname == "error.example"
assert resolutions[1].is_success() is False assert resolutions[1].is_success() is False
assert resolutions[1].error_message is not None
assert "Network error" in resolutions[1].error_message assert "Network error" in resolutions[1].error_message

View file

@ -6,6 +6,7 @@ validating application behavior, navigation, and user interactions.
""" """
from datetime import datetime from datetime import datetime
from typing import Any, cast
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
@ -192,7 +193,7 @@ class TestHostsManagerApp:
return mock_active_checkbox return mock_active_checkbox
return Mock() return Mock()
app.query_one = mock_query_one cast(Any, app).query_one = mock_query_one
# Add test entry # Add test entry
app.hosts_file = HostsFile() app.hosts_file = HostsFile()
@ -248,7 +249,7 @@ class TestHostsManagerApp:
return mock_active_checkbox return mock_active_checkbox
return Mock() return Mock()
app.query_one = mock_query_one cast(Any, app).query_one = mock_query_one
app.hosts_file = HostsFile() app.hosts_file = HostsFile()
app.update_entry_details() app.update_entry_details()
@ -327,7 +328,7 @@ class TestHostsManagerApp:
return mock_footer return mock_footer
return Mock() return Mock()
app.query_one = mock_query_one cast(Any, app).query_one = mock_query_one
# Add test hosts_file for footer status generation # Add test hosts_file for footer status generation
app.hosts_file = HostsFile() app.hosts_file = HostsFile()
@ -837,7 +838,7 @@ class TestHostsManagerApp:
return mock_dns_section return mock_dns_section
return Mock() return Mock()
app.query_one = mock_query_one cast(Any, app).query_one = mock_query_one
app.edit_handler.update_field_visibility(show_ip=True, show_dns=False) app.edit_handler.update_field_visibility(show_ip=True, show_dns=False)
@ -867,7 +868,7 @@ class TestHostsManagerApp:
return mock_dns_section return mock_dns_section
return Mock() return Mock()
app.query_one = mock_query_one cast(Any, app).query_one = mock_query_one
app.edit_handler.update_field_visibility(show_ip=False, show_dns=True) app.edit_handler.update_field_visibility(show_ip=False, show_dns=True)
@ -908,7 +909,7 @@ class TestHostsManagerApp:
return mock_dns_radio return mock_dns_radio
return Mock() return Mock()
app.query_one = mock_query_one cast(Any, app).query_one = mock_query_one
app.edit_handler.handle_entry_type_change = Mock() app.edit_handler.handle_entry_type_change = Mock()
# Test that the method can be called without errors # Test that the method can be called without errors
@ -962,7 +963,7 @@ class TestHostsManagerApp:
return mock_dns_input return mock_dns_input
return Mock() return Mock()
app.query_one = mock_query_one cast(Any, app).query_one = mock_query_one
app.edit_handler.handle_entry_type_change = Mock() app.edit_handler.handle_entry_type_change = Mock()
# Mock the set_timer method to avoid event loop issues in tests # Mock the set_timer method to avoid event loop issues in tests
@ -1017,7 +1018,7 @@ class TestHostsManagerApp:
return mock_active_checkbox return mock_active_checkbox
return Mock() return Mock()
app.query_one = mock_query_one cast(Any, app).query_one = mock_query_one
# Add test entry # Add test entry
app.hosts_file = HostsFile() app.hosts_file = HostsFile()
@ -1156,10 +1157,12 @@ class TestHostsManagerApp:
assert app.hosts_file.entries[0].is_active is False assert app.hosts_file.entries[0].is_active is False
assert app.manager.can_undo() assert app.manager.can_undo()
assert not app.manager.can_redo() assert not app.manager.can_redo()
app.table_handler.populate_entries_table.assert_called_once() cast(Mock, app.table_handler.populate_entries_table).assert_called_once()
app.table_handler.move_cursor_to_entry_index.assert_called_once_with(0) cast(
app.details_handler.update_entry_details.assert_called_once() Mock, app.table_handler.move_cursor_to_entry_index
app.update_status.assert_called_once_with( ).assert_called_once_with(0)
cast(Mock, app.details_handler.update_entry_details).assert_called_once()
cast(Mock, app.update_status).assert_called_once_with(
"❌ Undo save failed; previous state restored: Permission denied" "❌ Undo save failed; previous state restored: Permission denied"
) )
@ -1204,10 +1207,12 @@ class TestHostsManagerApp:
assert app.hosts_file.entries[0].is_active is True assert app.hosts_file.entries[0].is_active is True
assert not app.manager.can_undo() assert not app.manager.can_undo()
assert app.manager.can_redo() assert app.manager.can_redo()
app.table_handler.populate_entries_table.assert_called_once() cast(Mock, app.table_handler.populate_entries_table).assert_called_once()
app.table_handler.move_cursor_to_entry_index.assert_called_once_with(0) cast(
app.details_handler.update_entry_details.assert_called_once() Mock, app.table_handler.move_cursor_to_entry_index
app.update_status.assert_called_once_with( ).assert_called_once_with(0)
cast(Mock, app.details_handler.update_entry_details).assert_called_once()
cast(Mock, app.update_status).assert_called_once_with(
"❌ Redo save failed; previous state restored: Permission denied" "❌ Redo save failed; previous state restored: Permission denied"
) )
@ -1234,7 +1239,7 @@ class TestHostsManagerApp:
assert app.selected_entry_index == 0 assert app.selected_entry_index == 0
assert not app.manager.can_undo() assert not app.manager.can_undo()
assert app.manager.can_redo() assert app.manager.can_redo()
app.update_status.assert_called_once_with( cast(Mock, app.update_status).assert_called_once_with(
"❌ Toggle save failed; previous state restored: Permission denied" "❌ Toggle save failed; previous state restored: Permission denied"
) )
@ -1261,7 +1266,7 @@ class TestHostsManagerApp:
] ]
assert app.selected_entry_index == 1 assert app.selected_entry_index == 1
assert not app.manager.can_undo() assert not app.manager.can_undo()
app.update_status.assert_called_once_with( cast(Mock, app.update_status).assert_called_once_with(
"❌ Move save failed; previous state restored: Permission denied" "❌ Move save failed; previous state restored: Permission denied"
) )
@ -1287,7 +1292,7 @@ class TestHostsManagerApp:
] ]
assert app.selected_entry_index == 0 assert app.selected_entry_index == 0
assert not app.manager.can_undo() assert not app.manager.can_undo()
app.update_status.assert_called_once_with( cast(Mock, app.update_status).assert_called_once_with(
"❌ Add save failed; previous state restored: Permission denied" "❌ Add save failed; previous state restored: Permission denied"
) )
@ -1378,7 +1383,7 @@ class TestHostsManagerApp:
] ]
assert app.selected_entry_index == 1 assert app.selected_entry_index == 1
assert not app.manager.can_undo() assert not app.manager.can_undo()
app.update_status.assert_called_once_with( cast(Mock, app.update_status).assert_called_once_with(
"❌ Delete save failed; previous state restored: Permission denied" "❌ Delete save failed; previous state restored: Permission denied"
) )
@ -1503,7 +1508,7 @@ class TestHostsManagerApp:
assert entry.last_resolved == original_time assert entry.last_resolved == original_time
assert entry.dns_resolution_status == "match" assert entry.dns_resolution_status == "match"
assert app.selected_entry_index == 0 assert app.selected_entry_index == 0
app.update_status.assert_any_call( cast(Mock, app.update_status).assert_any_call(
"❌ DNS refresh save failed; previous state restored: Permission denied" "❌ DNS refresh save failed; previous state restored: Permission denied"
) )
@ -1549,7 +1554,7 @@ class TestHostsManagerApp:
assert entry.resolved_ip == "192.0.2.1" assert entry.resolved_ip == "192.0.2.1"
assert entry.last_resolved == original_time assert entry.last_resolved == original_time
assert entry.dns_resolution_status == "match" assert entry.dns_resolution_status == "match"
app.update_status.assert_any_call( cast(Mock, app.update_status).assert_any_call(
"❌ DNS refresh save failed; previous state restored: Permission denied" "❌ DNS refresh save failed; previous state restored: Permission denied"
) )
@ -1638,7 +1643,7 @@ class TestHostsManagerApp:
assert restored.dns_resolution_status is None assert restored.dns_resolution_status is None
assert restored.is_active is False assert restored.is_active is False
assert app.manager.can_undo() assert app.manager.can_undo()
app.update_status.assert_any_call( cast(Mock, app.update_status).assert_any_call(
"❌ DNS activation save failed; previous state restored: Permission denied" "❌ DNS activation save failed; previous state restored: Permission denied"
) )

45
uv.lock generated
View file

@ -1,5 +1,5 @@
version = 1 version = 1
revision = 2 revision = 3
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]] [[package]]
@ -16,18 +16,26 @@ name = "hosts"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
{ name = "textual" }, { name = "textual" },
] ]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
{ name = "ty" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [{ name = "textual", specifier = ">=5.0.1" }]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.4.1" }, { name = "pytest", specifier = ">=8.4.1" },
{ name = "pytest-asyncio", specifier = ">=0.21.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" },
{ name = "ruff", specifier = ">=0.12.5" }, { name = "ruff", specifier = ">=0.12.5" },
{ name = "textual", specifier = ">=5.0.1" }, { name = "ty", specifier = ">=0.0.78" },
] ]
[[package]] [[package]]
@ -210,6 +218,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/94/976d89db23efed9f3114403faf3f767ec707bfca469a93d0fb715cd352fa/textual-5.0.1-py3-none-any.whl", hash = "sha256:816eab21d22a702b3858ee23615abccaf157c05d386e82968000084c3c2c26aa", size = 699674, upload-time = "2025-07-25T19:50:57.686Z" }, { url = "https://files.pythonhosted.org/packages/cf/94/976d89db23efed9f3114403faf3f767ec707bfca469a93d0fb715cd352fa/textual-5.0.1-py3-none-any.whl", hash = "sha256:816eab21d22a702b3858ee23615abccaf157c05d386e82968000084c3c2c26aa", size = 699674, upload-time = "2025-07-25T19:50:57.686Z" },
] ]
[[package]]
name = "ty"
version = "0.0.78"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/c7/2ba0861384c5b5097ac354383abb98188112cb330208c21d5197e98a29e5/ty-0.0.78.tar.gz", hash = "sha256:770b45854f85fa11595208f08c0f28df80943164d10a2832d86be6ac29f135b2", size = 7050609, upload-time = "2026-09-02T22:41:33.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/ed/f34cfc06a9ba72979df219e48dae1a0b6a6c74a340763ccd30a547edf913/ty-0.0.78-py3-none-linux_armv6l.whl", hash = "sha256:122700b98f9d45785c1ce91a9418154562e7f64f4bf34679f6f7b38c5b97453f", size = 13304624, upload-time = "2026-09-02T22:40:56.649Z" },
{ url = "https://files.pythonhosted.org/packages/1c/28/5576e2a08b57676d9b2a736d528f077a6c6e32c3d1de2d75dbbe28346966/ty-0.0.78-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cbe7e3709ccf29ef3d9f58f5914cc30ec36fb647008a5a4ff8483abe401bfe8d", size = 12928383, upload-time = "2026-09-02T22:40:59.162Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/027d49c3da5235e634da3d3fc52c4874ec89f50830388c9c259f8fb71e0e/ty-0.0.78-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c3528897b3ab9d3589561bc2b5e61a4d5d686de527a4400bb09a439b922a6c70", size = 12730058, upload-time = "2026-09-02T22:41:01.235Z" },
{ url = "https://files.pythonhosted.org/packages/bb/bc/6bf8ffefd8063730a70ae889cf85def72bc155fd1453135ebf8a09c2f1c2/ty-0.0.78-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8bfba4c44a06484093f527b8ef43a317abcf91395b49396170266629eedf74aa", size = 12813321, upload-time = "2026-09-02T22:41:03.251Z" },
{ url = "https://files.pythonhosted.org/packages/6d/c0/b3cdf26f82108908d92fdb7ddb741019c446ceb666cf204d4dbf611bde33/ty-0.0.78-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f903c06fdee17baf8373173039ab28f1bce28e66b1c734929a5f50b37eb54d9", size = 13072219, upload-time = "2026-09-02T22:41:05.225Z" },
{ url = "https://files.pythonhosted.org/packages/4d/36/16bfb0abdd178dae11dbc4b57b9a86c2b62642a18c1103fd2c2930aa5879/ty-0.0.78-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd83e5fe3f07291d1bd4e591f8d2607c204f3eab53bcb1b8060c40e876e1aa61", size = 13903958, upload-time = "2026-09-02T22:41:07.333Z" },
{ url = "https://files.pythonhosted.org/packages/0f/0c/74d3b1f0344b13156c719dd68a7edf7e48c2051718c9f326ba3cbf45ea53/ty-0.0.78-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:325285377319cf7168a2b8b771ae5027f411530e3c7eab1faf4583cdd72fd7c9", size = 14355581, upload-time = "2026-09-02T22:41:09.56Z" },
{ url = "https://files.pythonhosted.org/packages/8d/d7/7ca0359e1e61b15b8b02328c8d120db3c507876b9b7c6bd9f26935353e0e/ty-0.0.78-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b7846a404da6492697b524a769755ee9bee157d67674063ecd9c5f69dc52ff", size = 14044749, upload-time = "2026-09-02T22:41:11.678Z" },
{ url = "https://files.pythonhosted.org/packages/3b/6a/731f16ff42c5fc96e742c2f4e0a6915356bae114ae02ae4af6f33502175f/ty-0.0.78-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:169d3b9d134c0b48fe1af8a844142d374655552054a9d6c15a4b6e51bb0382ea", size = 13391647, upload-time = "2026-09-02T22:41:13.928Z" },
{ url = "https://files.pythonhosted.org/packages/0c/af/250cc29daf310e837188509ea4d78460c495d8a74c4fb84b4152d9ef2d4f/ty-0.0.78-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6108cb3b2d28dac5981d4e25008a0a5547d8c877a67f6da9e8c38e7e946be44f", size = 13945020, upload-time = "2026-09-02T22:41:15.968Z" },
{ url = "https://files.pythonhosted.org/packages/b8/f9/96aab1dee4535e66554e8dc7657c69f6c61c181d8e70b9c3527908e93ef4/ty-0.0.78-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:589ad03608d9d2975ef4b23c41c4f847e325bcc7686f51d4c66066b8dbc18a2a", size = 12851149, upload-time = "2026-09-02T22:41:18.06Z" },
{ url = "https://files.pythonhosted.org/packages/85/b0/53d8fe9a847534ef5fe2165c7d5292cb853b29dfd7011cbfb1cdb90a8012/ty-0.0.78-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b3fa0786edc1af06030f872d83499c0cea7c261dbbb651e0aee8306bf2c8a869", size = 13091464, upload-time = "2026-09-02T22:41:20.227Z" },
{ url = "https://files.pythonhosted.org/packages/21/65/94a4e5a02de559f6c6c0ee7a14b88660b4eee058ec6fec5c0ff6ecfbf5cc/ty-0.0.78-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92c5639befc577578c8abd4e5a7fccf98d828db98b09e8d4dd81605dc0e54aef", size = 13389389, upload-time = "2026-09-02T22:41:22.27Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b6/d0c7fe6be64ca5a15100c4b1f0e39c19660d53bc544f609fa117b346e661/ty-0.0.78-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0dce70bc51652b2775debd1d1e422fb0179f5207c73deb4d5d0aca37e5f61695", size = 13691928, upload-time = "2026-09-02T22:41:24.292Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5d/36081205611ba3fa802efc4ad63e4b1e949bda2f7bb37b34ac9bb6c00db0/ty-0.0.78-py3-none-win32.whl", hash = "sha256:1c80976ca9185d7a9d1baab1fb57240331b8dac58d3b11e46200284086a6de8d", size = 12647346, upload-time = "2026-09-02T22:41:26.699Z" },
{ url = "https://files.pythonhosted.org/packages/f1/89/b925fe1ea1bc56fc7f11d2496e29072fdd2ceb7b389884fc7b2d07cf0c41/ty-0.0.78-py3-none-win_amd64.whl", hash = "sha256:32e82b704471eab34f67b51c151660ca8a00815977b28278905d76fba54f7415", size = 13241810, upload-time = "2026-09-02T22:41:28.857Z" },
{ url = "https://files.pythonhosted.org/packages/91/f1/090ef7b52355bcedfbfbff6ce70fa81ba5bd5f6ed3f8d99ae05e6f2fe75b/ty-0.0.78-py3-none-win_arm64.whl", hash = "sha256:3a14d641a3c04fa9a80f2a46be1531d915f60d4fb79d4b894627bbe46bb35d64", size = 13077433, upload-time = "2026-09-02T22:41:31.525Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.14.1" version = "4.14.1"