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:
parent
d04ab6feaf
commit
780d9c12fe
19 changed files with 216 additions and 901 deletions
22
AGENTS.md
22
AGENTS.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
45
mise.lock
Normal 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
42
mise.toml
Normal 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"]
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,7 +132,8 @@ 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
|
||||||
active_section.border_title = "Activate Entry"
|
if isinstance(active_section, Vertical):
|
||||||
|
active_section.border_title = "Activate Entry"
|
||||||
|
|
||||||
# Focus IP input
|
# Focus IP input
|
||||||
ip_input = self.query_one("#ip-address-input", Input)
|
ip_input = self.query_one("#ip-address-input", Input)
|
||||||
|
|
@ -149,9 +150,10 @@ 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
|
||||||
active_section.border_title = (
|
if isinstance(active_section, Vertical):
|
||||||
"Activate Entry (DNS entries activate after resolution)"
|
active_section.border_title = (
|
||||||
)
|
"Activate Entry (DNS entries activate after resolution)"
|
||||||
|
)
|
||||||
|
|
||||||
# Focus DNS input
|
# Focus DNS input
|
||||||
dns_input = self.query_one("#dns-name-input", Input)
|
dns_input = self.query_one("#dns-name-input", Input)
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 "✅"
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
45
uv.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue