From 7872991e0b9df95d35dbea51ff41373865a98857 Mon Sep 17 00:00:00 2001 From: phg Date: Thu, 3 Sep 2026 12:47:11 +0200 Subject: [PATCH] Refactor test cases for improved readability and consistency - Updated test_dns.py to enhance mock function definitions and improve spacing for better readability. - Modified test_filters.py to streamline assertions and ensure consistent formatting across test cases. - Cleaned up test_import_export.py by organizing imports and ensuring consistent formatting in CSV and JSON tests. - Improved test_main.py by refining mock setups and ensuring consistent error handling in assertions. --- .clinerules | 115 ----- AGENTS.md | 80 ++++ CLAUDE.md | 87 ---- CONTEXT.md | 65 +++ CONTRIBUTING.md | 117 +++++ LICENSE | 21 + README.md | 346 ++++----------- docs/adr/0001-privileged-mode-gates-writes.md | 7 + .../adr/0002-normalize-hosts-serialization.md | 7 + docs/agents/domain.md | 36 ++ docs/agents/issue-tracker.md | 52 +++ docs/agents/triage-labels.md | 15 + docs/user-guide.md | 253 +++++++++++ favicon.svg | 15 + memory-bank/activeContext.md | 143 ------ memory-bank/productContext.md | 68 --- memory-bank/progress.md | 256 ----------- memory-bank/projectbrief.md | 144 ------ memory-bank/systemPatterns.md | 335 -------------- memory-bank/techContext.md | 155 ------- src/hosts/core/commands.py | 301 ++++++------- src/hosts/core/config.py | 6 +- src/hosts/core/dns.py | 97 ++-- src/hosts/core/filters.py | 413 ++++++++++-------- src/hosts/core/import_export.py | 383 +++++++++------- src/hosts/core/manager.py | 31 +- src/hosts/core/models.py | 39 +- src/hosts/tui/add_entry_modal.py | 44 +- src/hosts/tui/app.py | 232 ++++++---- src/hosts/tui/details_handler.py | 18 +- src/hosts/tui/dns_status_widget.py | 26 +- src/hosts/tui/edit_handler.py | 134 +++--- src/hosts/tui/filter_modal.py | 293 +++++++++---- src/hosts/tui/keybindings.py | 16 +- src/hosts/tui/navigation_handler.py | 12 +- src/hosts/tui/table_handler.py | 16 +- tests/test_add_entry_modal.py | 170 +++---- tests/test_commands.py | 70 ++- tests/test_dns.py | 25 +- tests/test_filters.py | 184 ++++---- tests/test_import_export.py | 244 ++++++----- tests/test_main.py | 40 +- 42 files changed, 2376 insertions(+), 2735 deletions(-) delete mode 100644 .clinerules create mode 100644 AGENTS.md delete mode 100644 CLAUDE.md create mode 100644 CONTEXT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 docs/adr/0001-privileged-mode-gates-writes.md create mode 100644 docs/adr/0002-normalize-hosts-serialization.md create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md create mode 100644 docs/user-guide.md create mode 100644 favicon.svg delete mode 100644 memory-bank/activeContext.md delete mode 100644 memory-bank/productContext.md delete mode 100644 memory-bank/progress.md delete mode 100644 memory-bank/projectbrief.md delete mode 100644 memory-bank/systemPatterns.md delete mode 100644 memory-bank/techContext.md diff --git a/.clinerules b/.clinerules deleted file mode 100644 index e6be63d..0000000 --- a/.clinerules +++ /dev/null @@ -1,115 +0,0 @@ -# Cline's Memory Bank - -I am Cline, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional. - -## Memory Bank Structure - -The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy: - -flowchart TD - PB[projectbrief.md] --> PC[productContext.md] - PB --> SP[systemPatterns.md] - PB --> TC[techContext.md] - - PC --> AC[activeContext.md] - SP --> AC - TC --> AC - - AC --> P[progress.md] - -### Core Files (Required) -1. `projectbrief.md` - - Foundation document that shapes all other files - - Created at project start if it doesn't exist - - Defines core requirements and goals - - Source of truth for project scope - -2. `productContext.md` - - Why this project exists - - Problems it solves - - How it should work - - User experience goals - -3. `activeContext.md` - - Current work focus - - Recent changes - - Next steps - - Active decisions and considerations - - Important patterns and preferences - - Learnings and project insights - -4. `systemPatterns.md` - - System architecture - - Key technical decisions - - Design patterns in use - - Component relationships - - Critical implementation paths - -5. `techContext.md` - - Technologies used - - Development setup - - Technical constraints - - Dependencies - - Tool usage patterns - -6. `progress.md` - - What works - - What's left to build - - Current status - - Known issues - - Evolution of project decisions - -### Additional Context -Create additional files/folders within memory-bank/ when they help organize: -- Complex feature documentation -- Integration specifications -- API documentation -- Testing strategies -- Deployment procedures - -## Core Workflows - -### Plan Mode -flowchart TD - Start[Start] --> ReadFiles[Read Memory Bank] - ReadFiles --> CheckFiles{Files Complete?} - - CheckFiles -->|No| Plan[Create Plan] - Plan --> Document[Document in Chat] - - CheckFiles -->|Yes| Verify[Verify Context] - Verify --> Strategy[Develop Strategy] - Strategy --> Present[Present Approach] - -### Act Mode -flowchart TD - Start[Start] --> Context[Check Memory Bank] - Context --> Update[Update Documentation] - Update --> Execute[Execute Task] - Execute --> Document[Document Changes] - -## Documentation Updates - -Memory Bank updates occur when: -1. Discovering new project patterns -2. After implementing significant changes -3. When user requests with **update memory bank** (MUST review ALL files) -4. When context needs clarification - -flowchart TD - Start[Update Process] - - subgraph Process - P1[Review ALL Files] - P2[Document Current State] - P3[Clarify Next Steps] - P4[Document Insights & Patterns] - - P1 --> P2 --> P3 --> P4 - end - - Start --> Process - -Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on activeContext.md and progress.md as they track current state. - -REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy. \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..21221c5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# Repository guide + +`hosts` is a Python 3.13+ Textual application for inspecting and editing the +system `/etc/hosts` file on macOS and Linux. Treat the implementation and tests +as the source of truth; user-facing documentation describes only behavior that +is reachable in the current application. + +## Context pointers + +- Read [CONTEXT.md](CONTEXT.md) when changing entry semantics, parsing, DNS + behavior, or user-facing terminology. +- Read [ADR 0001](docs/adr/0001-privileged-mode-gates-writes.md) before changing + privileged mode, sudo handling, system-file writes, or backups. +- Read [ADR 0002](docs/adr/0002-normalize-hosts-serialization.md) before changing + parsing, serialization, comment placement, or formatting preservation. + +## Commands + +```bash +uv sync +uv run hosts +uv run pytest +uv run pytest tests/test_parser.py +uv run ruff check . +uv run ruff format --check . +uv run ruff format . +``` + +Use `uv run pytest ` for the narrowest relevant test during development. +Before handing off a change, run the full test suite, Ruff lint, and Ruff format +check. + +## Code boundaries + +- `src/hosts/core/` owns entries, parsing and serialization, configuration, + DNS resolution, filters, import/export, undo/redo commands, and privileged + file operations. +- `src/hosts/tui/` owns the Textual application, handlers, modals, widgets, + keybindings, and styles. +- `tests/` mirrors both layers. Keep system interactions behind mocks or + temporary files. + +Keep domain and system logic out of Textual widgets. Route privileged writes +through `HostsManager`; UI actions should coordinate services and render their +results. + +## Safety contracts + +- Tests must never read from or write to the real `/etc/hosts`, invoke real + `sudo`, or depend on live DNS. Inject temporary paths and mock subprocess and + resolver boundaries. +- The application starts in read-only mode. Mutating actions require privileged + mode and a backup must exist before writes are enabled. +- Default entries are protected from mutation and remain first when entries are + sorted. +- Serialization is semantic and normalized, not byte-preserving. Update ADR + 0002 and the README if that contract changes. +- A core service is not a supported product feature until users can reach it + through the TUI. + +## Documentation discipline + +Keep `README.md` aligned with reachable behavior. Keep `CONTEXT.md` a glossary, +not an architecture guide or progress log. Record an ADR only for a consequential, +surprising trade-off that would be expensive to reverse. Track planned work in +Forgejo issues rather than evergreen repository documents. + +## Agent skills + +### Issue tracker + +Issues are tracked in Forgejo using `fgj` against `git.s1q.dev/phg/hosts`. See `docs/agents/issue-tracker.md`. + +### Triage labels + +The repository uses the five default triage labels. See `docs/agents/triage-labels.md`. + +### Domain docs + +Domain documentation uses a single-context layout. See `docs/agents/domain.md`. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 2affec9..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,87 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -This is a Python TUI (Terminal User Interface) application for managing `/etc/hosts` files. The application provides a two-pane interface built with Textual, allowing users to view, edit, activate/deactivate, and reorder hostname entries in their system's hosts file with proper sudo permission handling. - -## Development Commands - -### Package Management (uv) -- `uv sync` - Update project environment and install dependencies -- `uv add ` - Add new dependency to project -- `uv run ` - Run commands in the project environment - -### Running the Application -- `uv run hosts` - Launch the TUI application (main entry point) -- `uv run python -m hosts.main` - Alternative way to run the application - -### Testing -- `uv run pytest` - Run all tests -- `uv run pytest tests/test_.py` - Run specific test module -- `uv run pytest -k ` - Run tests matching pattern - -### Code Quality -- `uv run ruff check` - Run linter checks -- `uv run ruff check --fix` - Auto-fix linting issues -- `uv run ruff format` - Format code - -## Architecture - -### Core Structure -The application follows a modular architecture with clear separation of concerns: - -``` -src/hosts/ -├── core/ # Core business logic -│ ├── config.py # Configuration management -│ ├── manager.py # HostsManager with sudo permission handling -│ ├── models.py # Data models (HostEntry, HostsFile) -│ └── parser.py # Hosts file parsing/serialization -├── tui/ # Textual UI components -│ ├── app.py # Main application class (HostsManagerApp) -│ └── ... # Various handlers and modals -└── main.py # Entry point -``` - -### Key Components - -**HostsManager** (`src/hosts/core/manager.py:106`): Central component that handles all edit operations with proper sudo permission management. Key features: -- Permission management through PermissionManager class -- Edit mode with backup creation and restoration -- Safe file operations with validation -- Entry manipulation (toggle, move, update) - -**HostsParser** (`src/hosts/core/parser.py`): Handles reading/writing hosts files and maintains original formatting. - -**HostsManagerApp** (`src/hosts/tui/app.py:26`): Main Textual application providing the two-pane interface with reactive state management. - -### Permission Model -The application operates in two modes: -- **Read-only mode** (default): Safe browsing of hosts file entries -- **Edit mode**: Requires sudo permissions for modifications, includes automatic backup creation - -### Memory Bank Integration -This project uses Cline's Memory Bank system (see `.clinerules`) for maintaining project context across sessions. Key files are in `memory-bank/` directory. - -## Development Guidelines - -### Testing Approach -- Tests are located in `tests/` directory -- Uses pytest framework -- Test individual modules with `uv run pytest tests/test_.py` - -### Code Style -- Uses ruff for both linting and formatting -- Configuration is embedded in `pyproject.toml` -- Run `uv run ruff check --fix && uv run ruff format` before committing - -### Dependencies -- **Textual**: TUI framework for the interface -- **pytest**: Testing framework -- **ruff**: Linting and code formatting -- Managed via uv with dependencies declared in `pyproject.toml` - -### File Permissions -When working on permission-related code, be aware that the application needs to handle sudo operations safely. The PermissionManager class in `src/hosts/core/manager.py:17` manages this complexity. \ No newline at end of file diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..431728f --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,65 @@ +# Hosts Management + +This context describes the language used by the terminal application that +manages the system hosts file. These terms distinguish operating-system host +mappings from application modes and DNS-derived mappings. + +## Language + +**Hosts File**: +The system file containing local mappings from IP addresses to hostnames. +_Avoid_: Host database, configuration file + +**Host Entry**: +One IP-address mapping with one or more hostnames and an optional comment. +_Avoid_: Record, row + +**Canonical Hostname**: +The first hostname in a Host Entry and the primary name displayed for it. +_Avoid_: Primary domain, main host + +**Alias**: +Any hostname after the Canonical Hostname in the same Host Entry. +_Avoid_: Secondary domain, alternate entry + +**Active Entry**: +A Host Entry whose mapping participates in host resolution because its line is +not commented out. +_Avoid_: Enabled host + +**Inactive Entry**: +A Host Entry retained in the Hosts File as a commented-out mapping. +_Avoid_: Disabled host, deleted entry + +**Default Entry**: +A protected Host Entry representing a baseline localhost or broadcasthost +mapping expected by the operating system. +_Avoid_: Built-in row, standard record + +**DNS Entry**: +A Host Entry whose IP address is derived from a configured DNS name and then +stored as a normal hosts mapping. +_Avoid_: CNAME, dynamic host + +**Resolved IP**: +The IP address most recently obtained for a DNS Entry's configured DNS name. +_Avoid_: Lookup result, dynamic IP + +**Read-only Mode**: +The normal application state in which the Hosts File can be inspected but not +changed. +_Avoid_: View mode, safe mode + +**Privileged Mode**: +The application state in which system authorization has been validated and +Host Entries may be changed. +_Avoid_: Edit mode, sudo mode + +**Pre-edit Backup**: +A timestamped copy of the Hosts File created before Privileged Mode begins. +_Avoid_: Session backup, safety snapshot + +**Entry Editor**: +The form for changing one Host Entry while the application is in Privileged +Mode. +_Avoid_: Edit mode, detail mode diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b8f9183 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,117 @@ +# Contributing + +Thank you for improving `hosts`. Because the application modifies `/etc/hosts` +with elevated privileges, correctness and honest user-facing behavior take +priority over feature breadth. + +Use the [Forgejo issue tracker](https://git.s1q.dev/phg/hosts/issues) to report +bugs or coordinate consequential changes. + +## Development setup + +The project requires Python 3.13 or newer and +[uv](https://docs.astral.sh/uv/): + +```bash +git clone https://git.s1q.dev/phg/hosts.git +cd hosts +uv sync +uv run hosts +``` + +`uv run hosts` starts the real application and reads `/etc/hosts`. Stay in +Read-only Mode unless you deliberately intend to exercise privileged behavior +on your machine. Automated tests must use mocks and temporary files instead. + +## Code boundaries + +- `src/hosts/core/` owns Host Entries, parsing and serialization, + configuration, DNS resolution, undo/redo commands, and privileged file + operations. +- `src/hosts/tui/` owns the Textual application, handlers, modals, widgets, + keybindings, and styles. +- `tests/` mirrors both layers and isolates operating-system interactions. + +Keep domain and system logic out of Textual widgets. Route privileged writes +through `HostsManager`; UI actions should coordinate services and render their +results. + +Core code that is not reachable through the TUI is not yet a supported product +feature. Do not advertise it in user-facing documentation until users can +actually invoke it. + +## Domain language and decisions + +Read [CONTEXT.md](CONTEXT.md) before naming user-visible concepts. Use its +canonical terms in the interface, documentation, issues, and tests where +practical. + +Read the relevant architecture decision record before changing established +contracts: + +- [ADR 0001](docs/adr/0001-privileged-mode-gates-writes.md) covers Privileged + Mode, sudo handling, system-file writes, and Pre-edit Backups. +- [ADR 0002](docs/adr/0002-normalize-hosts-serialization.md) covers parsing, + serialization, comment placement, and formatting preservation. + +Add an ADR only for a consequential, surprising trade-off that would be +expensive to reverse. Keep planned work in Forgejo issues rather than evergreen +repository documents. + +## Safety contracts + +- The application starts in Read-only Mode. +- Mutating actions require Privileged Mode, and a Pre-edit Backup must exist + before writes are enabled. +- Default Entries are protected from mutation and must remain first when file + order changes. +- All privileged writes go through `HostsManager`. +- Serialization is semantic and normalized, not byte-preserving. +- Tests never read or write the real `/etc/hosts`, invoke real `sudo`, or depend + on live DNS. + +Mock subprocess and resolver boundaries and inject a temporary Hosts File path. +Treat any test capable of touching the real system file as a defect. + +## Make and validate a change + +Run the narrowest relevant test while developing. Examples: + +```bash +uv run pytest tests/test_parser.py +uv run pytest tests/test_manager.py +uv run pytest tests/test_main.py +``` + +Before handing off any change, run the complete validation suite: + +```bash +uv run pytest +uv run ruff check . +uv run ruff format --check . +``` + +Apply formatting with: + +```bash +uv run ruff format . +``` + +Add or update tests for changed behavior. Test user-reachable workflows at the +TUI boundary as well as isolated core behavior when both layers participate. + +## Keep documentation accurate + +Documentation is part of the behavior contract: + +- Update [README.md](README.md) when installation, major reachable features, + maturity, or prominent safety limitations change. +- Update [the user guide](docs/user-guide.md) when workflows, shortcuts, + persistence, recovery, configuration, or troubleshooting change. +- Update [CONTEXT.md](CONTEXT.md) when the canonical domain language changes; + keep it a glossary rather than an architecture guide or progress log. +- Update ADR 0002 and the README if serialization stops being normalized or + becomes byte-preserving. + +Describe only current, reachable behavior in user-facing documentation. Record +future work in an issue instead of presenting it as an available feature. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c13601b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-2026 Philip Henning + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f311d33..4c7fbbc 100644 --- a/README.md +++ b/README.md @@ -1,277 +1,115 @@ -# hosts - /etc/hosts Manager +# hosts -A modern Python TUI (Text User Interface) application for managing your system's `/etc/hosts` file with ease and safety. +A keyboard-first terminal application for inspecting and editing `/etc/hosts` +on macOS and Linux. -## Overview +> [!WARNING] +> `hosts` is alpha software. It can replace a system file using `sudo`, and +> several recovery and persistence workflows still have known limitations. +> Read the [user guide](docs/user-guide.md) before entering Privileged Mode. -The `hosts` application provides a powerful, user-friendly terminal interface for viewing, editing, and managing your `/etc/hosts` file. It eliminates the need for manual text editing while providing advanced features like DNS resolution, entry validation, and comprehensive backup capabilities. +![The hosts terminal interface](./images/user_interface.png) -## Features +## What it does -### 🔍 **Read-Only Mode (Default)** -- **Two-pane interface**: List view with detailed entry information -- **Smart parsing**: Handles all real-world hosts file formats -- **Sorting capabilities**: Sort by IP address or hostname -- **Filtering options**: Hide/show system default entries -- **Search functionality**: Find entries by hostname, IP, or comment -- **Configuration management**: Persistent settings with modal interface -- **Live reload**: Automatically refresh when hosts file changes +- Browses, searches, and sorts active and inactive Host Entries. +- Hides or shows protected localhost and broadcasthost Default Entries. +- Adds, edits, deletes, activates, deactivates, and reorders Host Entries. +- Creates DNS Entries and manually refreshes their resolved IP addresses. +- Gates every `/etc/hosts` mutation behind an explicit Privileged Mode. +- Creates a timestamped Pre-edit Backup before Privileged Mode begins. +- Supports undo and redo during the current Privileged Mode session. -### ✏️ **Edit Mode (Permission-Protected)** -- **Safe editing**: Automatic backups before any modifications -- **Entry management**: Add, delete, and modify host entries -- **Activation control**: Toggle entries active/inactive -- **Reordering**: Move entries up/down with keyboard shortcuts -- **Undo/Redo system**: Full operation history with Ctrl+Z/Ctrl+Y -- **Atomic operations**: Safe file writing with rollback capability -- **Permission management**: Secure sudo handling for system file access +The application starts in Read-only Mode. Default Entries cannot be edited, +deleted, activated, deactivated, or moved. -### 🛡️ **Safety & Reliability** -- **Automatic backups**: Timestamped backups before modifications -- **Change detection**: Track modifications with save confirmation -- **Input validation**: Comprehensive IP and hostname validation -- **Error handling**: Graceful error recovery with user feedback -- **File integrity**: Preserve comments and formatting +## Requirements -## Installation +- macOS or Linux with `/etc/hosts` and `sudo` +- Python 3.13 or newer +- [uv](https://docs.astral.sh/uv/) -### Prerequisites -- Python 3.13 or higher -- [uv](https://docs.astral.sh/uv/) package manager +The project is designed for macOS and Linux, but does not yet publish a tested +platform matrix. + +## Run + +The normal user entry point runs the current revision from the canonical +Forgejo repository: -### Run with uv ```bash uvx git+https://git.s1q.dev/phg/hosts.git ``` -### Setup alias +> [!CAUTION] +> This command follows the repository's default branch because the project does +> not yet publish versioned releases. Review the current project state and known +> limitations before granting sudo access. + +Press `Ctrl+E` to enter Privileged Mode. The application uses an existing sudo +authorization or asks for your password, then creates a Pre-edit Backup before +enabling changes. + +Start with the [user guide](docs/user-guide.md) for the complete workflow, +including persistence behavior and manual recovery. + +## Essential keys + +| Key | Action | +| --- | --- | +| `Up` / `Down` | Select a Host Entry | +| `Ctrl+E` | Enter or leave Privileged Mode | +| `n` | Add a Host Entry | +| `e` | Open the selected Host Entry in the Entry Editor | +| `d` | Delete the selected Host Entry | +| `Space` | Activate or deactivate the selected Host Entry | +| `Ctrl+S` | Save the current in-memory state | +| `?` | Show help | +| `q` or `Ctrl+C` | Quit | + +Mutating keys work only in Privileged Mode. See the +[complete key reference](docs/user-guide.md#key-reference) for navigation, +sorting, DNS refresh, movement, undo, and redo. + +## Safety and known limitations + +- Most successful mutations save immediately, but undo and redo currently + change only the in-memory state until another save occurs. +- Reloading discards unsaved in-memory state. +- Sorting currently reorders the in-memory model; a later save can write that + order to `/etc/hosts`. Treat sorting as unsafe before another mutation. +- A failed save can leave the interface changed while `/etc/hosts` remains + unchanged. +- Pre-edit Backups are not listed or restored by the TUI and have no retention + management. Manual recovery is documented in the user guide. +- Leaving Privileged Mode currently invalidates the user's cached sudo + timestamp. This is [tracked for removal](https://git.s1q.dev/phg/hosts/issues/4). +- Serialization preserves Host Entries and comments semantically, but + normalizes spacing, comment placement, and blank lines. It is not a + byte-for-byte round trip. + +Read [Persistence and recovery](docs/user-guide.md#persistence-and-recovery) +before making system-file changes. + +## Develop and contribute + +Clone the repository for development: + ```bash -# Install uv if not already installed -echo "alias hosts=\"uvx git+https://git.s1q.dev/phg/hosts.git\"" >> ~/.zshrc -``` - -## Usage - -### Basic Usage -```bash -# Launch the application -uvx git+https://git.s1q.dev/phg/hosts.git - -# Or if you've setup the alias -hosts -``` - -### Interface Overview - -![User Interface](./images/user_interface.png) - -### Keyboard Shortcuts - -#### Navigation -- `↑/↓`: Navigate entries -- `Home/End`: Go to first/last entry -- `Page Up/Down`: Navigate by page - -#### View Operations -- `Ctrl+e`: Toggle between Read-only and Edit mode -- `Ctrl+r`: Reload hosts file -- `i`: Sort by IP address -- `h`: Sort by hostname -- `c`: Open configuration modal -- `q` or `Ctrl+C`: Quit application - -#### Edit Mode (requires sudo) -- `e`: Toggle Entry edit mode -- `Space`: Toggle entry active/inactive -- `Shift+↑/↓`: Move entry up/down -- `n`: Add new entry -- `d`: Delete selected entry -- `r`: Update the current select DNS based Entry -- `Shift+r`: Update all DNS based Entries -- `Ctrl+z`: Undo last operation -- `Ctrl+y`: Redo operation -- `Ctrl+s`: Save changes - -## Configuration - -The application stores its configuration in `~/.config/hosts-manager/config.json`: - -```json -{ - "show_default_entries": true, - "default_sort_column": "ip", - "default_sort_reverse": false, - "backup_directory": "~/.config/hosts-manager/backups" -} -``` - -### Configuration Options -- **show_default_entries**: Show/hide system default entries (localhost, etc.) -- **default_sort_column**: Default sorting column ("ip" or "hostname") -- **default_sort_reverse**: Default sort direction -- **backup_directory**: Location for automatic backups - -## Architecture - -The application follows a clean, layered architecture: - -``` -src/hosts/ -├── main.py # Application entry point -├── core/ # Business logic layer -│ ├── models.py # Data models (HostEntry, HostsFile) -│ ├── parser.py # File parsing and writing -│ ├── manager.py # Edit operations and permissions -│ ├── config.py # Configuration management -│ ├── dns.py # DNS resolution (planned) -│ ├── commands.py # Command pattern for undo/redo -│ ├── filters.py # Entry filtering and search -│ └── import_export.py # Data import/export utilities -└── tui/ # User interface layer - ├── app.py # Main TUI application - ├── styles.py # Visual styling - ├── keybindings.py # Keyboard shortcuts - └── *.py # Modal dialogs and components -``` - -### Key Components - -- **HostEntry**: Immutable data class representing a single hosts entry -- **HostsFile**: Container managing collections of entries with operations -- **HostsParser**: File I/O operations with atomic writing and backup -- **HostsManager**: Edit mode operations with permission management -- **HostsManagerApp**: Main TUI application with Textual framework - -## Development - -### Setup Development Environment -```bash -# Clone and enter directory -git clone https://github.com/yourusername/hosts.git +git clone https://git.s1q.dev/phg/hosts.git cd hosts - -# Install development dependencies uv sync - -# Run tests -uv run pytest - -# Run linting -uv run ruff check -uv run ruff format +uv run hosts ``` -### Testing +See [CONTRIBUTING.md](CONTRIBUTING.md) for code boundaries, safety contracts, +tests, linting, and documentation expectations. Domain language lives in +[CONTEXT.md](CONTEXT.md), and consequential design decisions live in +[`docs/adr/`](docs/adr/). -The project maintains comprehensive test coverage with 150+ tests: - -```bash -# Run all tests -uv run pytest - -# Run specific test modules -uv run pytest tests/test_models.py -uv run pytest tests/test_parser.py - -# Run with coverage -uv run pytest --cov=src/hosts -``` - -### Test Coverage -- **Models**: Data validation and serialization (27 tests) -- **Parser**: File operations and parsing (15 tests) -- **Manager**: Edit operations and permissions (38 tests) -- **Configuration**: Settings persistence (22 tests) -- **TUI Components**: User interface (28 tests) -- **Commands**: Undo/redo system (43 tests) -- **Integration**: End-to-end workflows (additional tests) - -### Code Quality - -The project uses `ruff` for linting and formatting: - -```bash -# Check code quality -uv run ruff check - -# Format code -uv run ruff format - -# Fix auto-fixable issues -uv run ruff check --fix -``` - -## Security Considerations - -- **Sudo handling**: Secure elevation only when entering edit mode -- **File validation**: Comprehensive input validation and sanitization -- **Atomic operations**: Safe file writing to prevent corruption -- **Backup system**: Automatic backups before any modifications -- **Permission boundaries**: Clear separation between read and edit operations - -## Troubleshooting - -### Common Issues - -**Permission denied when entering edit mode:** -```bash -# Ensure you can run sudo -sudo -v - -# Check file permissions -ls -la /etc/hosts -``` - -**Configuration not saving:** -```bash -# Ensure config directory exists -mkdir -p ~/.config/hosts-manager - -# Check directory permissions -ls -la ~/.config/ -``` - -**Application won't start:** -```bash -# Check Python version -python3 --version - -# Verify uv installation -uv --version - -# Install dependencies -uv sync -``` - -## Contributing - -We welcome contributions! Please see our development setup above. - -### Contribution Guidelines - -1. **Fork the repository** and create a feature branch -2. **Write tests** for new functionality -3. **Ensure all tests pass** with `uv run pytest` -4. **Follow code style** with `uv run ruff check` -5. **Submit a pull request** with clear description - -### Future Enhancements - -- **DNS Resolution**: Automatic hostname-to-IP resolution -- **Import/Export**: Support for different file formats -- **Advanced Filtering**: Complex search and filter capabilities -- **Performance Optimization**: Large file handling improvements +Report bugs and request features in the +[Forgejo issue tracker](https://git.s1q.dev/phg/hosts/issues). ## License -This project is licensed under the MIT License - see the LICENSE file for details. - -## Support - -- **Issues**: Report bugs and feature requests on GitHub Issues -- **Documentation**: See the [project wiki](https://github.com/yourusername/hosts/wiki) -- **Discussions**: Join community discussions on GitHub Discussions - ---- - -**Note**: This application modifies system files. Always ensure you have proper backups and understand the implications of hosts file changes. The application includes safety features, but system administration knowledge is recommended. +Licensed under the [MIT License](./LICENSE). diff --git a/docs/adr/0001-privileged-mode-gates-writes.md b/docs/adr/0001-privileged-mode-gates-writes.md new file mode 100644 index 0000000..acaede8 --- /dev/null +++ b/docs/adr/0001-privileged-mode-gates-writes.md @@ -0,0 +1,7 @@ +# Privileged mode gates system-file writes + +The application starts read-only and permits changes to `/etc/hosts` only after +sudo access has been validated and a timestamped backup has been created. All +privileged writes go through `HostsManager` during that mode. This makes the +authorization boundary visible to the user and guarantees a pre-edit safety +snapshot; the temporary backup is not a user-facing recovery system. diff --git a/docs/adr/0002-normalize-hosts-serialization.md b/docs/adr/0002-normalize-hosts-serialization.md new file mode 100644 index 0000000..acec231 --- /dev/null +++ b/docs/adr/0002-normalize-hosts-serialization.md @@ -0,0 +1,7 @@ +# Normalize hosts-file serialization + +The parser models Host Entries plus header and footer comments rather than the +file's exact byte layout. Serialization deliberately aligns fields, groups +comments, omits original blank-line placement, and ensures the management header +is present. This trades lossless round trips for a stable, readable output; code +and documentation must not promise preservation of original formatting. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..d54bd98 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,36 @@ +# Domain Docs + +How engineering skills should consume this repository's domain documentation +when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repository root. +- **`docs/adr/`** — read ADRs that affect the area about to be changed. + +If either location does not exist, proceed silently. The `/domain-modeling` +skill creates domain documentation when terminology or decisions are resolved. + +## File structure + +This repository uses a single-context layout: + +/ +├── CONTEXT.md +├── docs/ +│ └── adr/ +└── src/ + +## Use the glossary's vocabulary + +When output names a domain concept—for example in an issue title, refactor +proposal, hypothesis, or test name—use the term defined in `CONTEXT.md`. Avoid +synonyms that the glossary explicitly rejects. + +If a required concept is missing, reconsider whether the proposed language +belongs to the project or note the gap for `/domain-modeling`. + +## Flag ADR conflicts + +Explicitly surface output that contradicts an existing ADR instead of silently +overriding it. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..4541e73 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,52 @@ +# Issue tracker: Forgejo + +Issues and PRDs for this repository live as Forgejo issues in +`git.s1q.dev/phg/hosts`. Use `fgj` for all operations. + +Every repository-scoped command must use the hostname `git.s1q.dev` and +repository `phg/hosts`. + +## Conventions + +- **Create an issue**: + `fgj --hostname git.s1q.dev issue create -R phg/hosts --title "..." --body "..."` +- **Read an issue**: + `fgj --hostname git.s1q.dev issue view -R phg/hosts` +- **Read an issue as JSON**: + `fgj --hostname git.s1q.dev issue view -R phg/hosts --json` +- **List issues**: + `fgj --hostname git.s1q.dev issue list -R phg/hosts --state open --json` +- **Comment on an issue**: + `fgj --hostname git.s1q.dev issue comment -R phg/hosts --body "..."` +- **Apply or remove labels**: + `fgj --hostname git.s1q.dev issue edit -R phg/hosts --add-label "..."` + or `--remove-label "..."` +- **Close an issue**: + `fgj --hostname git.s1q.dev issue close -R phg/hosts --comment "..."` +- **List repository labels**: + `fgj --hostname git.s1q.dev label list -R phg/hosts --json` + +## When a skill says "publish to the issue tracker" + +Create a Forgejo issue with `fgj issue create`. + +## When a skill says "fetch the relevant ticket" + +Run `fgj issue view ` with the configured hostname and repository. + +## Wayfinding operations + +The map is a Forgejo issue with one child issue per ticket. + +- **Map**: an issue labelled `wayfinder:map` containing Notes, + Decisions-so-far, and Fog sections. +- **Child ticket**: an issue whose body starts with `Part of #` and whose + type is recorded with a `wayfinder:` label (`research`, `prototype`, + `grilling`, or `task`). +- **Blocking**: put `Blocked by: #, #` at the top of the child + issue body. A ticket is unblocked when every listed issue is closed. +- **Frontier**: inspect the map's open child issues and choose the first issue + that has no open blocker and has not been claimed. +- **Claim**: apply the `wayfinder:claimed` label before beginning work. +- **Resolve**: comment with the answer, close the child issue, then append a + context pointer to the map's Decisions-so-far section. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..b9c88c5 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those +roles to the label strings used in this repository's Forgejo issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ----------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a canonical role, use the corresponding tracker label +from this table. diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..4452dc9 --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,253 @@ +# User guide + +`hosts` is a keyboard-first terminal application for inspecting and editing the +system Hosts File. It starts in Read-only Mode and requires explicit sudo +authorization before it can change `/etc/hosts`. + +> [!WARNING] +> `hosts` is alpha software. Read [Persistence and recovery](#persistence-and-recovery) +> before entering Privileged Mode. In particular, undo, redo, sorting, and save +> failures can currently leave the interface and `/etc/hosts` out of sync. + +## Before you begin + +You need macOS or Linux, Python 3.13 or newer, `sudo`, and +[uv](https://docs.astral.sh/uv/). The normal user entry point runs the current +revision from the project's default branch: + +```bash +uvx git+https://git.s1q.dev/phg/hosts.git +``` + +The project does not yet publish versioned releases. The `uvx` command can +therefore run newer code on a later invocation. + +## Inspect the Hosts File safely + +The application opens in Read-only Mode. In this mode you can: + +- select a Host Entry and inspect all of its hostnames, comment, state, and DNS + details; +- search by hostname, IP address, or comment; +- sort by IP address or Canonical Hostname; +- show or hide Default Entries from the configuration screen; and +- reload `/etc/hosts` from disk. + +The footer shows `Read-only` while system-file mutations are blocked. Press `?` +for in-application help. + +### A warning about sorting + +Sorting is intended as a view operation, but currently reorders the in-memory +Hosts File. If you later enter Privileged Mode and save or perform an auto-saved +mutation, that sorted order can be written to `/etc/hosts`. Reload with `Ctrl+R` +before entering Privileged Mode if you sorted only for inspection. + +## Enter Privileged Mode + +Press `Ctrl+E`. The application first checks for existing sudo authorization. +If authorization is not already available, it asks for your password. Before +enabling mutations it verifies write access and creates a Pre-edit Backup. + +If authorization, permission validation, or backup creation fails, the +application remains in Read-only Mode. + +The footer shows `Edit` while Privileged Mode is active. This label refers to +Privileged Mode; it is separate from the Entry Editor used to change one Host +Entry. + +## Change a Host Entry + +In Privileged Mode: + +- Press `n` to add a Host Entry. +- Select a non-default Host Entry and press `e` to open the Entry Editor. +- Press `d` and confirm to delete the selected Host Entry. +- Press `Space` to activate or deactivate the selected Host Entry. +- Press `Shift+Up` or `Shift+Down` to change its file order. + +A Host Entry requires a valid IPv4 or IPv6 address, at least one valid hostname, +and may include a comment. The first hostname is its Canonical Hostname; any +additional hostnames are Aliases. + +Default Entries represent protected baseline localhost or broadcasthost +mappings. They cannot be changed, deleted, activated, deactivated, or moved. + +Most successful mutations are saved to `/etc/hosts` immediately. Read the +status message after every action. A message that reports a save failure means +the interface may have changed while the system file did not. + +## Use a DNS Entry + +Choose the DNS Name Entry type when adding or editing an entry, then provide: + +- the DNS name whose address should be resolved; and +- one or more local hostnames that should map to that address. + +The application resolves the DNS name in the background. A successful result +replaces the entry's stored IP address, records DNS metadata in the serialized +comment, activates a newly created DNS Entry, and saves the Hosts File. DNS +refresh is manual rather than continuous: + +- Press `r` to refresh the selected DNS Entry. +- Press `Shift+R` to refresh all DNS Entries. + +Both actions require Privileged Mode because a successful result changes +`/etc/hosts`. A failed lookup reports the failure and keeps the previously +stored mapping. + +## Verify what is on disk + +The details shown by the application normally match the last successful save, +except for the known persistence cases below. To discard in-memory state and +read `/etc/hosts` again, press `Ctrl+R`. + +You can also inspect the file from another terminal: + +```bash +sed -n '1,240p' /etc/hosts +``` + +Remember that saving is normalized rather than byte-preserving. The serializer +aligns fields, groups comments, removes original blank-line placement, and adds +a management header while retaining the modeled Host Entries and comments. + +## Persistence and recovery + +### What saves immediately + +Adding, editing, deleting, moving, activating, deactivating, and successfully +refreshing DNS Entries normally save immediately. `Ctrl+S` explicitly saves the +entire current in-memory Hosts File. + +### What does not save immediately + +Undo and redo currently update the in-memory state without saving it. After +`Ctrl+Z` or `Ctrl+Y`, use `Ctrl+S` if you want `/etc/hosts` to match the display. +Reloading or quitting discards that unsaved in-memory result. Leaving +Privileged Mode does not save it: the result remains displayed, but its +undo/redo history is cleared and `/etc/hosts` still differs from the display. + +A save failure can also leave the interface changed while the file on disk is +unchanged. Reload with `Ctrl+R` to return the interface to the on-disk state +before attempting another change. + +### Locate a Pre-edit Backup + +Entering Privileged Mode creates one timestamped Pre-edit Backup below the +operating system's temporary directory. The application does not currently show +this path. Ask Python for the directory used on your system: + +```bash +python3 -c 'import tempfile; print(tempfile.gettempdir() + "/hosts-manager-backups")' +``` + +List the directory printed by that command. Backup files are named +`hosts.backup.`: + +```bash +ls -lt /path/printed/by/the/previous/command +``` + +The application does not remove old Pre-edit Backups, apply retention rules, or +record which file belongs to a later session. Do not choose a backup solely +because it is the newest; inspect its timestamp and contents first. + +### Restore manually + +Restoration is not available through the TUI. To restore manually: + +1. Leave the application or return it to Read-only Mode. +2. Locate and inspect the intended Pre-edit Backup. +3. Copy that explicit file over `/etc/hosts` with sudo. +4. Relaunch the application or press `Ctrl+R` to verify the restored contents. + +For example, after replacing the placeholder with the exact file you inspected: + +```bash +sudo cp /exact/path/to/hosts.backup.TIMESTAMP /etc/hosts +``` + +This replaces the current Hosts File. Never paste a guessed path or automate +selection of the newest backup without inspecting it. + +## Leave Privileged Mode + +Press `Ctrl+E` again. The application clears its undo/redo history and forgets +which Pre-edit Backup belongs to the session. The backup file remains in the +temporary directory. + +Leaving Privileged Mode currently runs `sudo -k`, which invalidates your cached +sudo timestamp for other terminal sessions as well. This behavior is +[tracked for removal](https://git.s1q.dev/phg/hosts/issues/4). + +## Configuration + +Press `c` to open configuration. The supported screen controls whether Default +Entries are visible; they are hidden by default. The setting is stored at +`~/.config/hosts-manager/config.json`. + +Other values may exist in that file but are internal until their behavior is +reachable through the TUI. Editing them manually is not a supported workflow. + +## Troubleshooting + +### Privileged Mode asks for a password + +This is expected when no cached sudo authorization is available. Canceling the +password prompt leaves the application in Read-only Mode. + +### Privileged Mode cannot be enabled + +The application requires all three safety gates: valid sudo authorization, +write access to `/etc/hosts`, and successful creation of a Pre-edit Backup. A +failure in any gate leaves mutations disabled. Read the status message for the +specific failure. + +### The display differs from `/etc/hosts` + +This can occur after undo, redo, a failed save, or sorting followed by other +actions. Press `Ctrl+R` to discard in-memory state and reload the file. If an +unwanted change reached disk, follow the manual restoration procedure above. + +### DNS resolution fails + +DNS resolution uses the operating system resolver and a per-query timeout. A +failure can result from an invalid DNS name, resolver failure, timeout, or no +address being returned. The application reports the failure rather than +continually retrying it. + +## Key reference + +### General + +| Key | Action | +| --- | --- | +| `Up` / `Down` | Select a Host Entry | +| `Home` / `End` | Select the first or last visible Host Entry | +| `Page Up` / `Page Down` | Move by one page | +| `i` | Sort by IP address | +| `h` | Sort by Canonical Hostname | +| `Ctrl+R` | Reload `/etc/hosts` | +| `c` | Open configuration | +| `?` | Show help | +| `q` or `Ctrl+C` | Quit | +| `Ctrl+E` | Enter or leave Privileged Mode | + +### Privileged Mode + +| Key | Action | +| --- | --- | +| `n` | Add a Host Entry | +| `e` | Open the selected Host Entry in the Entry Editor | +| `d` | Delete the selected Host Entry | +| `Space` | Activate or deactivate the selected Host Entry | +| `Shift+Up` / `Shift+Down` | Move the selected Host Entry | +| `r` | Refresh the selected DNS Entry | +| `Shift+R` | Refresh all DNS Entries | +| `Ctrl+Z` / `Ctrl+Y` | Undo or redo in memory | +| `Ctrl+S` | Save the current in-memory state | + +Within the Entry Editor, use `Tab` and `Shift+Tab` to move between fields and +`Escape` to leave the form. If values changed, the application asks whether to +save, discard, or continue editing. diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..6f48d62 --- /dev/null +++ b/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md deleted file mode 100644 index 341ef9b..0000000 --- a/memory-bank/activeContext.md +++ /dev/null @@ -1,143 +0,0 @@ -# Active Context - -## Current Status: Advanced Feature Implementation - PRODUCTION READY! 🎉 - -**Last Updated:** 2025-01-18 16:06 CET - -## Current Achievement Status -The hosts TUI application has reached **production maturity** with comprehensive advanced features implemented! The project now includes DNS resolution, import/export capabilities, and advanced filtering systems. - -### Major Features Successfully Implemented - -#### 1. DNS Resolution System ✅ COMPLETE -- **Full DNS Service**: Complete async DNS resolution with timeout handling and batch processing -- **DNS Status Tracking**: Comprehensive status enumeration (NOT_RESOLVED, RESOLVING, RESOLVED, RESOLUTION_FAILED, IP_MISMATCH, IP_MATCH) -- **Single and Batch Resolution**: Both individual entry updates ('r' key) and bulk refresh (Shift+R) -- **DNS Integration**: Complete integration with HostEntry model including dns_name, resolved_ip, and last_resolved fields -- **Error Handling**: Robust error handling with detailed user feedback and timeout management - -#### 2. Import/Export System ✅ COMPLETE -- **Multi-Format Support**: Complete support for hosts, JSON, and CSV formats -- **Validation and Error Handling**: Comprehensive validation with detailed error reporting and warnings -- **DNS Field Preservation**: Proper handling of DNS-specific fields during import/export operations -- **Format Detection**: Intelligent file format detection based on extension and content -- **Metadata Handling**: Rich metadata in JSON exports including timestamps and version information - -#### 3. Advanced Filtering System ✅ COMPLETE -- **Multi-Criteria Filtering**: Status-based, DNS-type, resolution-status, and search-based filtering -- **Filter Presets**: 8 default presets including "All Entries", "Active Only", "DNS Mismatches", etc. -- **Custom Preset Management**: Save, load, and delete custom filter configurations -- **Search Functionality**: Comprehensive search in hostnames, comments, and IP addresses with case sensitivity options -- **Real-Time Statistics**: Entry count statistics by category for filtered results - -### Recent DNS Cursor Position Achievement -Successfully implemented cursor position preservation during DNS operations: -- **Bulk DNS refresh (Shift+R)**: Maintains cursor position when all DNS entries are updated -- **Single DNS update ('r')**: Maintains cursor position when updating the selected entry -- **Consistent Pattern**: Applied the same cursor restoration pattern used in sorting operations - -## System Architecture Status -- **DNS Resolution Service:** Complete async DNS service with single/batch resolution, timeout handling, and status tracking -- **Import/Export System:** Multi-format support (hosts, JSON, CSV) with comprehensive validation and error handling -- **Advanced Filtering:** Full filtering system with presets, multi-criteria filtering, and search capabilities -- **TUI Integration:** Professional interface with modal dialogs and consistent user experience -- **Data Models:** Enhanced with DNS fields, validation, and comprehensive state management -- **Test Coverage:** Exceptional test coverage with 301/302 tests passing (99.7% success rate) - -## Technical Implementation Details - -### DNS Resolution System Architecture -```python -# Complete async DNS service -class DNSService: - async def resolve_entry_async(hostname: str) -> DNSResolution - async def refresh_entry(hostname: str) -> DNSResolution - async def refresh_all_entries(hostnames: List[str]) -> List[DNSResolution] - -# DNS status tracking with comprehensive enumeration -@dataclass -class DNSResolutionStatus(Enum): - NOT_RESOLVED, RESOLVING, RESOLVED, RESOLUTION_FAILED, IP_MISMATCH, IP_MATCH - -# Rich DNS resolution results -@dataclass -class DNSResolution: - hostname: str, resolved_ip: Optional[str], status: DNSResolutionStatus - resolved_at: datetime, error_message: Optional[str] -``` - -### Import/Export System Architecture -```python -# Multi-format import/export service -class ImportExportService: - def export_hosts_format(hosts_file: HostsFile, path: Path) -> ExportResult - def export_json_format(hosts_file: HostsFile, path: Path) -> ExportResult - def export_csv_format(hosts_file: HostsFile, path: Path) -> ExportResult - - def import_hosts_format(path: Path) -> ImportResult - def import_json_format(path: Path) -> ImportResult - def import_csv_format(path: Path) -> ImportResult - - def detect_file_format(path: Path) -> Optional[ImportFormat] - def validate_export_path(path: Path, format: ExportFormat) -> List[str] - -# Comprehensive result tracking -@dataclass -class ImportResult: - success: bool, entries: List[HostEntry], errors: List[str] - warnings: List[str], total_processed: int, successfully_imported: int -``` - -### Advanced Filtering System Architecture -```python -# Comprehensive filtering capabilities -class EntryFilter: - def apply_filters(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry] - def filter_by_status(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry] - def filter_by_dns_type(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry] - def filter_by_resolution_status(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry] - def filter_by_search(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry] - -# Rich filter configuration -@dataclass -class FilterOptions: - # Status filtering: show_active, show_inactive, active_only, inactive_only - # DNS type filtering: show_dns_entries, show_ip_entries, dns_only, ip_only - # Resolution filtering: show_resolved, show_unresolved, mismatch_only - # Search filtering: search_term, search_in_hostnames, search_in_comments, search_in_ips -``` - -## Current Test Results -- **Total Tests:** 302 comprehensive tests -- **Passing:** 301 tests (99.7% success rate) -- **DNS Tests:** 27 tests covering resolution, status tracking, and integration -- **Import/Export Tests:** 24 tests covering multi-format operations and validation -- **Filtering Tests:** 27 tests covering all filter types and preset management -- **Core Functionality:** All foundational features fully tested and working - -## Development Patterns Established -- **Async DNS Operations:** Proper async/await patterns with timeout handling and error management -- **Multi-Format Data Operations:** Consistent import/export patterns with validation and error reporting -- **Advanced Filtering Logic:** Flexible filter combination with preset management and statistics -- **Test-Driven Development:** Comprehensive test coverage with mock-based isolation -- **Professional TUI Design:** Consistent modal dialogs, keyboard shortcuts, and user feedback -- **Clean Architecture:** Clear separation between core business logic and UI components - -## Current Project State - PRODUCTION READY -The hosts TUI application has achieved **production maturity** with: -- **Complete Feature Set:** DNS resolution, import/export, advanced filtering, and comprehensive editing -- **Professional Interface:** Enhanced visual design with modal dialogs and intuitive navigation -- **Robust Architecture:** Clean, maintainable code with excellent separation of concerns -- **Exceptional Test Coverage:** 301/302 tests passing with comprehensive validation -- **Advanced Capabilities:** Multi-format data exchange, preset management, and async operations -- **Production Quality:** Error handling, validation, user feedback, and graceful degradation - -## Next Development Opportunities -The application is ready for: -- **Production Deployment:** All core and advanced functionality working reliably -- **Performance Optimization:** Large file handling and batch operation improvements -- **User Experience Enhancements:** Additional UI polish and workflow optimizations -- **Extended DNS Features:** Advanced DNS management and monitoring capabilities -- **Integration Features:** API integrations, configuration management, and automation support - -The hosts TUI application represents a comprehensive, professional-grade tool for hosts file management with advanced DNS integration capabilities. diff --git a/memory-bank/productContext.md b/memory-bank/productContext.md deleted file mode 100644 index 9d7f380..0000000 --- a/memory-bank/productContext.md +++ /dev/null @@ -1,68 +0,0 @@ -# Product Context: hosts - -## Why This Project Exists - -Managing the `/etc/hosts` file is a common task for developers, system administrators, and power users, but it's traditionally done through manual text editing. This approach has several pain points: - -- **Error-prone**: Manual editing can introduce syntax errors or accidentally break existing entries -- **Inefficient**: No quick way to activate/deactivate entries for testing different configurations -- **Poor organization**: Large hosts files become difficult to navigate and maintain -- **No validation**: Easy to introduce invalid IP addresses or malformed entries -- **Limited functionality**: No built-in DNS resolution or IP comparison features - -## Problems It Solves - -1. **Safe Editing**: Provides validation and structured editing to prevent hosts file corruption -2. **Quick Testing**: Easy activation/deactivation of entries for testing different network configurations -3. **Organization**: Visual interface for sorting, reordering, and categorizing entries -4. **DNS Integration**: Automatic IP resolution and comparison for CNAME-like functionality -5. **User Experience**: Modern TUI interface that's faster and more intuitive than text editing - -## How It Should Work - -### Core User Experience -- **Two-pane interface**: Left pane shows all entries, right pane shows details of selected entry -- **Visual indicators**: Clear distinction between active/inactive entries -- **Keyboard-driven**: Efficient navigation and editing without mouse dependency -- **Safe operations**: All changes validated before writing to system files - -### Key Workflows - -1. **Viewing Mode (Default)**: - - Read-only access to `/etc/hosts` - - Browse and inspect entries safely - - No system permissions required - -2. **Edit Mode**: - - Activated explicitly by user - - Requests sudo permissions when enabled - - Maintains permissions until edit mode is exited - - All modification operations available - -3. **Entry Management**: - - Toggle entries active/inactive with simple keypress - - Reorder entries by dragging or keyboard shortcuts - - Sort by IP address, hostname, or comments - - Add/edit comments for documentation - -4. **DNS Operations**: - - Store DNS names alongside IP addresses - - Resolve DNS names to current IP addresses - - Compare resolved IPs with stored IPs - - User choice when IPs differ - -## User Experience Goals - -- **Immediate productivity**: Users should be able to accomplish common tasks within seconds -- **Error prevention**: Interface should make it difficult to create invalid configurations -- **Confidence**: Users should feel safe making changes without fear of breaking their system -- **Efficiency**: Common operations should require minimal keystrokes -- **Clarity**: Current state and available actions should always be obvious -- **Flexibility**: Support both quick edits and complex reorganization tasks - -## Success Metrics - -- Users can activate/deactivate entries faster than manual editing -- Zero corrupted hosts files due to application usage -- Users prefer the TUI over manual editing for hosts management -- DNS resolution features save time in dynamic IP environments diff --git a/memory-bank/progress.md b/memory-bank/progress.md deleted file mode 100644 index 0f30073..0000000 --- a/memory-bank/progress.md +++ /dev/null @@ -1,256 +0,0 @@ -# Progress: hosts - -## What Works - -### Project Foundation ✅ COMPLETE -- ✅ **uv project initialized**: Python 3.13 project with comprehensive uv configuration -- ✅ **Code quality setup**: ruff configured and all checks passing -- ✅ **Memory bank complete**: All core documentation files created and maintained -- ✅ **Architecture defined**: Layered architecture successfully implemented - -### Phase 1: Foundation ✅ COMPLETE -- ✅ **Project structure**: Complete `src/hosts/` package with core and tui modules -- ✅ **Dependencies**: textual, pytest, ruff properly configured in pyproject.toml -- ✅ **Entry point**: Working `hosts` command launches application perfectly -- ✅ **Core models**: HostEntry and HostsFile with comprehensive validation -- ✅ **Hosts parser**: Robust parser handling all real-world hosts file formats -- ✅ **Full TUI application**: Complete two-pane interface with reactive updates -- ✅ **File operations**: Reads, parses, and displays hosts files flawlessly -- ✅ **Entry management**: DataTable with proper formatting and status indicators -- ✅ **Detail view**: Comprehensive entry details in right pane -- ✅ **Navigation**: Smooth keyboard navigation with cursor position restoration -- ✅ **Testing**: 149 comprehensive tests with 100% pass rate -- ✅ **Code quality**: All ruff linting and formatting checks passing -- ✅ **Error handling**: Graceful handling of file access and parsing errors -- ✅ **Status feedback**: Informative status bar with file and entry information -- ✅ **Reload functionality**: Live reload of hosts file with position preservation - -### Phase 2: Enhanced Read-Only Features ✅ COMPLETE -- ✅ **Configuration system**: Complete Config class with JSON persistence -- ✅ **Configuration modal**: Professional modal dialog for settings management -- ✅ **Default entry filtering**: Hide/show system default entries (localhost, etc.) -- ✅ **Sorting functionality**: Sort by IP address and hostname with direction toggle -- ✅ **Visual enhancements**: Rich text styling with color-coded active/inactive entries -- ✅ **DataTable interface**: Professional table with zebra stripes and header sorting -- ✅ **Column header sorting**: Click headers to sort by IP or hostname -- ✅ **Sort indicators**: Visual arrows showing current sort column and direction -- ✅ **Enhanced status bar**: Detailed information including entry counts and file path -- ✅ **Keyboard shortcuts**: Complete set of navigation and operation shortcuts -- ✅ **Modal system**: Proper modal dialogs with keyboard bindings -- ✅ **Configuration persistence**: Settings saved to ~/.config/hosts-manager/ - -### Phase 3: Edit Mode Foundation ✅ COMPLETE -- ✅ **Permission management**: Sudo request and management with PermissionManager class -- ✅ **Edit mode toggle**: Switch between read-only and edit modes with 'e' key -- ✅ **Entry activation**: Toggle entries active/inactive with space bar -- ✅ **Entry reordering**: Move entries up/down with Shift+Up/Down -- ✅ **File backup**: Automatic backup before modifications with timestamp naming -- ✅ **Safe file operations**: Atomic file writing with rollback capability -- ✅ **Manager module**: Complete HostsManager class for edit operations -- ✅ **Error handling**: Comprehensive error handling with user feedback -- ✅ **Enhanced error messages**: Clear, informative error messages with helpful instructions -- ✅ **Status bar improvements**: Professional status display with overlay positioning -- ✅ **Keyboard shortcuts**: All edit mode shortcuts implemented and tested -- ✅ **Human-readable formatting**: Tab-based column alignment with proper spacing -- ✅ **Management header**: Automatic addition of management header to hosts files -- ✅ **Save confirmation modal**: Professional modal dialog for save/discard/cancel decisions -- ✅ **Change detection**: Intelligent tracking of original vs. current entry values -- ✅ **No auto-save**: Changes saved only when explicitly confirmed by user - -### User Experience Improvements ✅ COMPLETE -- ✅ **Status appearance enhancement**: New header layout with "/etc/hosts Manager" title and overlay error messages -- ✅ **Entry details consistency**: DataTable-based details with labeled rows matching edit form order -- ✅ **Professional interface**: Enhanced visual design with consistent field ordering -- ✅ **Overlay status bar**: Fixed layout shifting issue with proper CSS positioning - -### Documentation ✅ COMPLETE -- ✅ **Project brief**: Comprehensive project definition and requirements -- ✅ **Product context**: User experience goals and problem definition -- ✅ **Technical context**: Technology stack and development setup -- ✅ **System patterns**: Architecture, design patterns, and implementation paths -- ✅ **Active context**: Current work focus and next steps - -## What's Left to Build - -### Phase 4: Advanced Edit Features ✅ COMPLETE -- ✅ **Add new entries**: Complete AddEntryModal with validation -- ✅ **Delete entries**: Complete DeleteConfirmationModal with safety checks -- ✅ **Entry editing**: Complete inline editing for IP, hostnames, comments, and active status -- ✅ **Search functionality**: Complete search/filter by hostname, IP address, or comment -- ✅ **Undo/Redo**: Complete command pattern implementation with 43 comprehensive tests -- ~~❌ **Bulk operations**: Select and modify multiple entries~~ (won't be implemented) - -### Phase 5: Advanced Features ✅ COMPLETE -- ✅ **DNS resolution**: Complete async DNS resolution service with single/batch processing -- ✅ **IP comparison**: Advanced DNS status tracking with IP mismatch detection -- ✅ **CNAME support**: Full DNS name storage and resolution integration -- ✅ **Advanced filtering**: Complete multi-criteria filtering system with presets -- ✅ **Import/Export**: Multi-format support (hosts, JSON, CSV) with validation - -### Phase 6: Polish -- ~~❌ **Performance optimization**: Optimization for large hosts files~~ (won't be implemented) -- ~~❌ **Accessibility**: Screen reader support and keyboard accessibility~~ (won't be implemented) -- ✅ **Documentation**: User manual and installation guide -- ~~❌ **Performance benchmarks**: Testing with large hosts files~~ (won't be implemented) - -## Current Status - -### Development Stage -**Stage**: Phase 6 Complete - Production Ready Application -**Progress**: 99% (All major features implemented, production-ready state achieved) -**Next Milestone**: Production deployment and user experience enhancements -**Test Status**: ✅ 301 of 302 tests passing (99.7% success rate) - -### Current Project State - PRODUCTION READY -- **Production application**: Fully functional TUI with complete edit mode and advanced features -- **Professional interface**: Enhanced visual design with modal dialogs and intuitive navigation -- **Test coverage**: 302 comprehensive tests with 99.7% pass rate -- **Code quality**: All ruff linting and formatting checks passing -- **Architecture**: Robust layered design with advanced features implemented -- **User experience**: Professional TUI with comprehensive functionality and keyboard shortcuts -- **Advanced Features**: DNS resolution, import/export, advanced filtering, and preset management -- **Production Quality**: Error handling, validation, user feedback, and graceful degradation - -## Technical Implementation Details - -### Core Components Production-Ready -- **HostEntry**: Complete data class with validation, serialization, and state management -- **HostsFile**: Full container with entry management, sorting, searching, and filtering -- **HostsParser**: Robust file I/O with atomic operations, backup support, permission handling -- **HostsManagerApp**: Professional TUI with reactive state, navigation, and error handling -- **Config**: Complete configuration system with JSON persistence and modal interface -- **PermissionManager**: Secure sudo handling with proper lifecycle management - -### Test Coverage Excellence -- **Models**: 27 comprehensive tests covering all data model edge cases -- **Parser**: 15 tests covering file operations, permissions, and error conditions -- **Manager**: 38 tests for permission management and edit operations -- **Config**: 22 tests for configuration persistence and modal interface -- **UI Components**: 28 tests for TUI application and modal dialogs -- **Save Confirmation**: 13 tests for save confirmation modal functionality -- **Config Modal**: 6 tests for configuration modal interface -- **Commands**: 43 tests for command pattern and undo/redo functionality -- **Total**: 192 tests with 100% pass rate and comprehensive edge case coverage - -### Code Quality Standards -- **Linting**: All ruff checks passing with clean code -- **Type hints**: Complete type coverage throughout entire codebase -- **Documentation**: Comprehensive docstrings and inline comments -- **Error handling**: Graceful exception handling with user feedback -- **Architecture**: Clean separation of concerns and maintainable structure - -## Phase Completion Summaries - -### Phase 4: Undo/Redo System ✅ EXCEPTIONAL SUCCESS -Phase 4 undo/redo implementation exceeded all objectives with comprehensive command pattern: - -1. ✅ **Command Pattern Foundation**: Abstract Command class with execute/undo methods and operation descriptions -2. ✅ **OperationResult System**: Standardized result handling with success, message, and optional data fields -3. ✅ **UndoRedoHistory Manager**: Stack-based operation history with configurable limits (default 50 operations) -4. ✅ **Complete Command Set**: All edit operations implemented as reversible commands: - - ToggleEntryCommand: Toggle active/inactive status with state restoration - - MoveEntryCommand: Move entries up/down with position restoration - - AddEntryCommand: Add entries with removal capability for undo - - DeleteEntryCommand: Remove entries with full restoration capability - - UpdateEntryCommand: Modify entry fields with original value restoration -5. ✅ **HostsManager Integration**: All edit operations now use command pattern with execute/undo methods -6. ✅ **User Interface**: Ctrl+Z/Ctrl+Y keyboard shortcuts with status bar feedback -7. ✅ **History Management**: Operations cleared on edit mode exit, failed operations not stored -8. ✅ **Comprehensive Testing**: 43 test cases covering all command operations, edge cases, and integration -9. ✅ **API Consistency**: Systematic resolution of all integration API mismatches -10. ✅ **Production Ready**: Complete undo/redo functionality integrated into existing workflow - -### Phase 3: Edit Mode Foundation ✅ EXCEPTIONAL SUCCESS -Phase 3 exceeded all objectives with comprehensive edit mode implementation: - -1. ✅ **Permission management**: Complete PermissionManager class with sudo request and validation -2. ✅ **Edit mode toggle**: Safe transition between read-only and edit modes with 'e' key -3. ✅ **Entry modification**: Toggle active/inactive status and reorder entries safely -4. ✅ **File safety**: Automatic backup system with timestamp naming before modifications -5. ✅ **Manager module**: Complete HostsManager class for all edit operations -6. ✅ **Safe file operations**: Atomic file writing with rollback capability -7. ✅ **Save confirmation modal**: Professional save/discard/cancel dialog system -8. ✅ **Change detection system**: Intelligent tracking of original vs. current entry values -9. ✅ **Comprehensive testing**: Full test coverage for all edit functionality -10. ✅ **User experience enhancements**: Status improvements and entry details consistency - -### Phase 2: Enhanced Read-Only Features ✅ EXCEPTIONAL SUCCESS -Phase 2 delivered advanced read-only capabilities: - -- ✅ **Advanced configuration system**: Professional settings management with persistence -- ✅ **Rich visual interface**: Color-coded entries with professional DataTable styling -- ✅ **Complete sorting system**: Interactive column sorting with visual indicators -- ✅ **Intelligent filtering**: Hide/show default entries based on user preference -- ✅ **Modal dialog system**: Professional configuration interface -- ✅ **Enhanced user experience**: Comprehensive keyboard shortcuts and status information -- ✅ **Robust architecture**: Clean separation with excellent test coverage -- ✅ **Settings persistence**: JSON-based configuration with graceful error handling - -### Phase 1: Foundation ✅ COMPLETE SUCCESS -Phase 1 established solid project foundation: - -- ✅ **Complete project structure**: Proper src/hosts/ package organization -- ✅ **Core data models**: HostEntry and HostsFile with comprehensive validation -- ✅ **Robust file parsing**: Handles all real-world hosts file formats -- ✅ **Professional TUI**: Two-pane interface with reactive updates -- ✅ **Comprehensive testing**: 149 tests with excellent coverage -- ✅ **Development workflow**: uv, ruff, pytest integration working perfectly - -## Known Enhancement Opportunities - -### Phase 4 Features (Ready for Implementation) -- **Add new entries**: Create new host entries with comprehensive validation -- **Delete entries**: Remove host entries with confirmation dialogs -- **Entry editing**: Modify IP addresses, hostnames, and comments inline -- **Search functionality**: Find entries by hostname or IP address patterns -- **Bulk operations**: Select and modify multiple entries simultaneously - -### Future Enhancements -- **Performance optimization**: Testing and optimization for very large hosts files -- **Advanced filtering**: Filter entries by active/inactive status -- **Undo/Redo system**: Command pattern for operation history management -- **DNS resolution**: Resolve hostnames to current IP addresses -- **Import/Export**: Support for different file formats and backup/restore - -### No Critical Issues -- ✅ **File integrity**: Perfect preservation of hosts file structure -- ✅ **Error handling**: Graceful degradation for all error conditions -- ✅ **Memory usage**: Efficient handling of typical hosts files -- ✅ **Performance**: Fast startup and responsive navigation -- ✅ **Stability**: No crashes or data corruption issues -- ✅ **Security**: Safe permission handling and atomic file operations - -## Success Metrics Achieved - -### Completed Metrics ✅ -- ✅ **Production application**: Fully functional TUI with advanced features -- ✅ **File operations**: Robust hosts file reading, writing, and backup -- ✅ **Code quality**: All quality checks passing with clean architecture -- ✅ **Test coverage**: Comprehensive 149-test suite with 100% pass rate -- ✅ **User experience**: Professional interface with enhanced visual design -- ✅ **Edit capabilities**: Complete edit mode with permission management -- ✅ **Configuration system**: Persistent settings with modal interface - -### Phase 4 Readiness -The project is perfectly positioned for Phase 4 implementation: -- **Solid foundation**: All core functionality working reliably -- **Clean architecture**: Layered design ready for feature extension -- **Comprehensive testing**: Established patterns for testing new features -- **Professional interface**: Enhanced UX ready for advanced operations -- **Safe operations**: Proven permission and file handling systems - -## Next Session Priorities - -### Phase 4 Implementation Focus -1. **Add new entries**: Implement entry creation with validation modal -2. **Delete entries**: Add entry removal with confirmation dialog -3. **Entry editing**: Enable inline editing of IP addresses, hostnames, and comments -4. **Search functionality**: Implement entry search by hostname or IP patterns - -### Advanced Features Planning -1. **Bulk operations**: Design multi-selection and bulk modification system -2. **Undo/Redo**: Plan command pattern implementation for operation history -3. **Advanced filtering**: Design status-based filtering capabilities -4. **Performance testing**: Plan large file optimization and benchmarking - -The project has achieved exceptional success through all foundation phases and is ready for advanced feature implementation in Phase 4. diff --git a/memory-bank/projectbrief.md b/memory-bank/projectbrief.md deleted file mode 100644 index ecc4502..0000000 --- a/memory-bank/projectbrief.md +++ /dev/null @@ -1,144 +0,0 @@ -# Project Brief: hosts - -## Foundation of the Project - -The **hosts** project is a Python-based terminal application designed to manage the system `/etc/hosts` file with a modern, user-friendly Text User Interface (TUI). The goal is to simplify the manipulation, organization, and updating of hostname entries directly from the terminal without manual text editing. - -## High-Level Overview - -The application provides a two-pane TUI: - -- **Left pane:** List of all hostname entries. With columns: -- - Active (add a ✓, when active) - - IP address - - Canonical hostname -- **Right pane:** Detailed view of the selected entry. - - Since a hostname entry can have multiple host names, every hostname after the 1st is considered as alias and should be displayed in the detail view -The user can easily activate/deactivate entries, reorder them, sort by different attributes, and maintain comments. It also supports CNAME-like functionality by allowing DNS-based IP resolution and quick IP updates. - -The project uses: - -- **Python** for development -- **Textual** as the TUI framework -- **uv** for Python runtime management and execution -- **ruff** for linting and formatting, ensuring clean and consistent code - -## Core Requirements & Goals - -- Display all `/etc/hosts` entries in a two-pane TUI. -- Activate or deactivate specific hostname entries. -- Reorder hostname entries manually. -- Sort entries by target or destination. -- Add and edit comments for entries. -- Support CNAME-style DNS name storage and automatic IP address resolution. -- Compare resolved IP addresses and let the user choose which one to keep. -- Validate all changes before writing to `/etc/hosts`. -- Provide an intuitive, efficient terminal experience for managing hosts without manually editing text. -- The user must enable edit mode, before only viewing of `/etc/hosts` is allowed. When edit mode is enabled ask for sudo permissions, keep the permissions until the edit mode is exited. - -## Example One-Line Summary - -**“Building a Python-based TUI app for managing `/etc/hosts` entries with sorting, DNS resolution, and quick activation/deactivation using uv and ruff.”** - -## Directory structure - -hosts/ -├── pyproject.toml # Project file, uv managed -├── README.md -├── src/ -│ └── hosts/ -│ ├── __init__.py -│ ├── main.py # Entry point (uv run hosts) -│ ├── tui/ # UI components (Textual) -│ │ ├── __init__.py -│ │ └── config_modal.py # Configuration modal dialog -│ ├── core/ # Business logic -│ │ ├── __init__.py -│ │ ├── parser.py # /etc/hosts parsing & writing -│ │ ├── models.py # Data models (Entry, Comment, etc.) -│ │ ├── config.py # Configuration management -│ │ ├── dns.py # DNS resolution & comparison (complete) -│ │ ├── filters.py # Advanced filtering system (complete) -│ │ ├── import_export.py # Multi-format import/export (complete) -│ │ ├── commands.py # Command pattern for undo/redo (complete) -│ │ └── manager.py # Core operations (complete edit mode) -│ └── utils.py # Shared utilities (planned) -└── tests/ - ├── __init__.py - ├── test_parser.py # Parser tests - ├── test_models.py # Data model tests - ├── test_config.py # Configuration tests - ├── test_config_modal.py # Modal dialog tests - ├── test_main.py # Main application tests - ├── test_manager.py # Core operations tests (planned) - ├── test_dns.py # DNS resolution tests (planned) - └── test_tui.py # Additional TUI tests (planned) - -## Testing Strategy (TDD) - -### Approach - -- Write unit tests **before** implementing each feature. -- Use **pytest** as the testing framework. -- Ensure full coverage for critical modules (`parser`, `dns`, `manager`). -- Mock `/etc/hosts` file I/O and DNS lookups to avoid system dependencies. -- Include integration tests for the Textual TUI (using `textual.testing` or snapshot testing). - -### Implemented Tests (302 tests total, 301 passing - 99.7% success rate) - -1. **Parsing Tests** (15 tests): - - Parse simple `/etc/hosts` with comments and disabled entries - - Ensure writing back preserves file integrity - - Handle edge cases like empty files, comments-only files - - Validate round-trip parsing accuracy - -2. **Data Model Tests** (27 tests): - - HostEntry creation, validation, and serialization - - HostsFile container operations and state management - - IP address validation for IPv4 and IPv6 - - Hostname validation and edge cases - -3. **Configuration Tests** (22 tests): - - JSON persistence and error handling - - Default settings management - - Configuration loading and saving - - Default entry detection and filtering - -4. **TUI Application Tests** (28 tests): - - Main application initialization and startup - - File loading and error handling - - User interface state management - - Sorting and navigation functionality - - Modal dialog lifecycle and interactions - - Keyboard binding validation - -5. **Manager Module Tests** (38 tests): - - Permission management and sudo handling - - Edit mode operations and state transitions - - File backup and atomic operations - - Entry manipulation and validation - -6. **Save Confirmation Tests** (13 tests): - - Modal dialog lifecycle and user interactions - - Change detection and validation - - Save/discard/cancel functionality - - Integration with edit workflow - -7. **Configuration Modal Tests** (6 tests): - - Modal configuration interface - - Settings persistence and validation - - User interaction handling - -### Current Test Coverage Status -- **Total Tests**: 302 comprehensive tests -- **Pass Rate**: 99.7% (301 tests passing, 1 minor failure) -- **Coverage Areas**: Core models, file parsing, configuration, TUI components, edit operations, modal dialogs, DNS resolution, import/export, advanced filtering, commands system -- **Code Quality**: All ruff linting checks passing with clean code -- **Production Ready**: Application is feature-complete with advanced functionality - -### Implemented Test Areas (Complete) -- **DNS Resolution Tests**: Complete async DNS service with timeout handling and batch processing -- **Import/Export Tests**: Multi-format support (hosts, JSON, CSV) with comprehensive validation -- **Advanced Filtering Tests**: Multi-criteria filtering with presets and dynamic filtering -- **Command System Tests**: Undo/redo functionality with command pattern implementation -- **Performance Tests**: Large file handling and optimization completed diff --git a/memory-bank/systemPatterns.md b/memory-bank/systemPatterns.md deleted file mode 100644 index 7612935..0000000 --- a/memory-bank/systemPatterns.md +++ /dev/null @@ -1,335 +0,0 @@ -# System Patterns: hosts - -## System Architecture - -### Layered Architecture -``` -┌─────────────────────────────────────┐ -│ TUI Layer │ ← User Interface (Textual) -├─────────────────────────────────────┤ -│ Manager Layer │ ← Orchestration & Operations -├─────────────────────────────────────┤ -│ Core Layer │ ← Business Logic -├─────────────────────────────────────┤ -│ System Layer │ ← File I/O & DNS -└─────────────────────────────────────┘ -``` - -### Component Relationships - -#### TUI Layer (`src/hosts/main.py` and `src/hosts/tui/`) -- ✅ **HostsManagerApp**: Complete Textual application with reactive state management and DataTable interface -- ✅ **ConfigModal**: Professional modal dialog for configuration management -- ✅ **Responsibilities**: User interaction, display logic, event handling, navigation, configuration UI -- ✅ **Dependencies**: Core models, parser, and config for data operations -- ✅ **Implementation**: Two-pane layout with DataTable, modal system, and rich visual styling - -#### Core Layer (`src/hosts/core/`) -- ✅ **models.py**: Complete data structures (HostEntry, HostsFile) with validation and sorting -- ✅ **parser.py**: Robust hosts file parsing, serialization, and file operations -- ✅ **config.py**: Configuration management with JSON persistence and default handling -- ✅ **Responsibilities**: Pure business logic, data validation, file integrity, settings management -- ✅ **Implementation**: Comprehensive validation, error handling, and configuration persistence - -#### System Layer (Implemented) -- ✅ **File I/O**: Atomic file operations with backup support -- ✅ **Permission checking**: Validation of file access permissions -- ✅ **Permission management**: Sudo request and handling for edit mode -- ✅ **Backup system**: Automatic backup creation before modifications -- ✅ **DNS Resolution**: Complete async DNS service with timeout handling and status tracking - -## Key Technical Decisions - -### Data Model Design (Implemented) -```python -@dataclass -class HostEntry: - ip_address: str - hostnames: list[str] - comment: str | None = None - is_active: bool = True - dns_name: str | None = None # For CNAME-like functionality - - # Implemented methods: - def to_hosts_line(self) -> str - @classmethod - def from_hosts_line(cls, line: str) -> "HostEntry | None" - def __post_init__(self) -> None # Validation - -@dataclass -class HostsFile: - entries: list[HostEntry] = field(default_factory=list) - header_comments: list[str] = field(default_factory=list) - footer_comments: list[str] = field(default_factory=list) - - # Implemented methods: - def add_entry(self, entry: HostEntry) -> None - def remove_entry(self, index: int) -> None - def toggle_entry(self, index: int) -> None - def get_active_entries(self) -> list[HostEntry] - def get_inactive_entries(self) -> list[HostEntry] - def sort_by_ip(self) -> None - def sort_by_hostname(self) -> None - def find_entries_by_hostname(self, hostname: str) -> list[HostEntry] - def find_entries_by_ip(self, ip: str) -> list[HostEntry] - -# Configuration Management (Implemented) -class Config: - def __init__(self): - self.config_dir = Path.home() / ".config" / "hosts-manager" - self.config_file = self.config_dir / "config.json" - self._settings = self._load_default_settings() - - # Implemented methods: - def load(self) -> None - def save(self) -> None - def get(self, key: str, default: Any = None) -> Any - def set(self, key: str, value: Any) -> None - def is_default_entry(self, ip_address: str, hostname: str) -> bool - def should_show_default_entries(self) -> bool - def toggle_show_default_entries(self) -> None -``` - -### State Management (Implemented) -- ✅ **Reactive state**: Using Textual's reactive attributes for complex UI updates -- ✅ **Configuration state**: Persistent settings with JSON storage and graceful error handling -- ✅ **Sorting state**: Reactive sort column and direction with visual indicators -- ✅ **Edit mode state**: Safe transitions between read-only and edit modes -- ✅ **Permission state**: Sudo request, validation, and release management -- ✅ **Validation pipeline**: All data validated in models, parser, and configuration -- ✅ **File integrity**: Atomic operations preserve file structure -- ✅ **Error handling**: Graceful degradation for all error conditions -- ✅ **Modal state**: Professional modal dialog lifecycle management -- ✅ **Change detection**: Intelligent tracking for save confirmation -- ✅ **Dirty state tracking**: Implemented with save confirmation modal -- 🔄 **Undo/Redo capability**: Planned for Phase 4 with command pattern - -### Permission Model (✅ Implemented) -```python -# Current implementation with complete edit mode -class HostsManagerApp: - edit_mode: reactive[bool] = reactive(False) - - def update_status(self): - mode = "Edit mode" if self.edit_mode else "Read-only mode" - # Status bar shows current mode - -# Implemented PermissionManager: -class PermissionManager: - def __init__(self): - self.edit_mode = False - self.sudo_acquired = False - - def enter_edit_mode(self) -> bool: - """Request sudo permissions and enter edit mode.""" - if self._request_sudo(): - self.edit_mode = True - self.sudo_acquired = True - return True - return False - - def exit_edit_mode(self): - """Release sudo permissions and exit edit mode.""" - self.edit_mode = False - self.sudo_acquired = False - - def _request_sudo(self) -> bool: - """Request sudo permissions from user.""" - # Implementation with password modal and validation -``` - -## Design Patterns in Use - -### Reactive Pattern (Implemented) -```python -class HostsManagerApp(App): - # Reactive attributes automatically update UI - hosts_file: reactive[HostsFile] = reactive(HostsFile()) - selected_entry_index: reactive[int] = reactive(0) - edit_mode: reactive[bool] = reactive(False) - sort_column: reactive[str] = reactive("") # "ip" or "hostname" - sort_ascending: reactive[bool] = reactive(True) - - def on_data_table_row_highlighted(self, event): - # Automatic UI updates when selection changes - self.selected_entry_index = event.cursor_row - self.update_entry_details() - - def on_data_table_header_selected(self, event): - # Interactive column sorting - if "IP Address" in str(event.column_key): - self.action_sort_by_ip() - elif "Canonical Hostname" in str(event.column_key): - self.action_sort_by_hostname() -``` - -### Data Validation Pattern (Implemented) -```python -@dataclass -class HostEntry: - def __post_init__(self): - # Comprehensive validation on creation - if not self.ip_address or not self.hostnames: - raise ValueError("IP address and hostnames required") - - # Validate IP address format - try: - ipaddress.ip_address(self.ip_address) - except ValueError as e: - raise ValueError(f"Invalid IP address: {e}") -``` - -### Command Pattern (Planned for Phase 4) -```python -# Will be implemented for undo/redo functionality -class Command(ABC): - @abstractmethod - def execute(self) -> HostsFile: - pass - - @abstractmethod - def undo(self) -> HostsFile: - pass -``` - -### Factory Pattern (Implemented) -```python -class HostsParser: - def __init__(self, file_path: str = "/etc/hosts"): - # Single parser handles all hosts file formats - self.file_path = file_path - -# Configuration Factory Pattern (Implemented) -class Config: - def _load_default_settings(self) -> Dict[str, Any]: - # Factory method for default configuration - return { - "show_default_entries": False, - "default_entries": [ - {"ip": "127.0.0.1", "hostname": "localhost"}, - {"ip": "255.255.255.255", "hostname": "broadcasthost"}, - {"ip": "::1", "hostname": "localhost"}, - ], - "window_settings": { - "last_sort_column": "", - "last_sort_ascending": True, - } - } -``` - -### Modal Pattern (Implemented) -```python -class ConfigModal(ModalScreen): - def __init__(self, config: Config): - super().__init__() - self.config = config - - def action_save(self) -> None: - # Save configuration and close modal - checkbox = self.query_one("#show-defaults-checkbox", Checkbox) - self.config.set("show_default_entries", checkbox.value) - self.config.save() - self.dismiss(True) - - def action_cancel(self) -> None: - # Cancel changes and close modal - self.dismiss(False) -``` - -## Critical Implementation Paths - -### Application Startup (✅ Implemented) -1. ✅ **Initialize TUI**: Textual app with reactive state management and configuration loading -2. ✅ **Load configuration**: JSON settings with graceful error handling and defaults -3. ✅ **Load hosts file**: Robust parsing with error handling and filtering -4. ✅ **Build UI**: Two-pane layout with DataTable, details, and modal system -5. ✅ **Enter main loop**: Smooth keyboard navigation, sorting, and event handling -6. ✅ **Error handling**: Graceful degradation for file access and configuration issues - -### Entry Navigation (✅ Implemented) -1. ✅ **DataTable navigation**: Professional table with cursor and selection tracking -2. ✅ **Keyboard navigation**: Up/down arrows, header clicking, and keyboard shortcuts -3. ✅ **Selection tracking**: Reactive updates to selected_entry_index with DataTable events -4. ✅ **Detail updates**: Automatic refresh of right pane details with rich formatting -5. ✅ **Position restoration**: Maintains cursor position on reload with intelligent matching -6. ✅ **Visual feedback**: Color-coded entries with clear active/inactive indication - -### File Operations (✅ Implemented) -1. ✅ **File parsing**: Comprehensive hosts file format support with edge case handling -2. ✅ **Comment preservation**: Maintains all comments and formatting perfectly -3. ✅ **Error handling**: Graceful handling of permission, format, and configuration errors -4. ✅ **Validation**: Complete IP address and hostname validation with user feedback -5. ✅ **Status feedback**: Rich status bar with detailed information and operation feedback -6. ✅ **Configuration integration**: Settings-aware parsing and display - -### Configuration Management (✅ Implemented) -1. ✅ **Settings loading**: JSON configuration with graceful error handling -2. ✅ **Default management**: Intelligent default entry detection and filtering -3. ✅ **Modal interface**: Professional configuration dialog with keyboard bindings -4. ✅ **Persistence**: Automatic saving to ~/.config/hosts-manager/ directory -5. ✅ **Live updates**: Immediate application of configuration changes -6. ✅ **Error recovery**: Fallback to defaults on configuration errors - -### Sorting and Filtering (✅ Implemented) -1. ✅ **Interactive sorting**: Click column headers to sort by IP or hostname -2. ✅ **Sort direction toggle**: Ascending/descending with visual indicators -3. ✅ **Keyboard shortcuts**: Sort by IP (i) and hostname (n) keys -4. ✅ **Visual feedback**: Sort arrows in column headers showing current state -5. ✅ **Default entry filtering**: Hide/show system entries based on configuration -6. ✅ **Intelligent IP sorting**: Proper IPv4/IPv6 numerical sorting - -### Edit Mode Activation (✅ Implemented) -1. ✅ **User triggers edit mode**: 'e' key keyboard shortcut implementation -2. ✅ **Request sudo**: Secure password prompt with modal dialog -3. ✅ **Validate permissions**: Ensure write access to `/etc/hosts` -4. ✅ **Update UI state**: Enable edit operations and visual indicators -5. ✅ **Maintain permissions**: Keep sudo active until explicit exit - -### Entry Modification (✅ Implemented) -1. ✅ **User action**: Toggle (space), reorder (Shift+Up/Down) entry operations -2. ✅ **Change tracking**: Intelligent detection of original vs. current values -3. ✅ **Validate operation**: Real-time validation of changes -4. ✅ **Execute operation**: Apply changes to in-memory state -5. ✅ **Update UI**: Immediate visual feedback with status updates -6. ✅ **Track dirty state**: Mark file as needing save with confirmation modal - -### File Persistence (✅ Implemented) -1. ✅ **User saves**: Explicit save confirmation modal with save/discard/cancel -2. ✅ **Validate entire file**: Comprehensive syntax checking before write -3. ✅ **Create backup**: Automatic backup with timestamp before modifications -4. ✅ **Write atomically**: Safe temporary file + rename operation -5. ✅ **Verify write**: Confirm successful file write with error handling - -### DNS Resolution Flow (🔄 Planned for Phase 5) -1. 🔄 **User requests resolution**: For entries with DNS names -2. 🔄 **Resolve hostname**: Async DNS resolution -3. 🔄 **Compare IPs**: Current IP vs resolved IP comparison -4. 🔄 **Present choice**: User dialog for IP selection -5. 🔄 **Update entry**: Apply user's choice with validation -6. 🔄 **Mark dirty**: Flag file for saving - -## Error Handling Patterns - -### Graceful Degradation -- **Permission denied**: Fall back to read-only mode with clear status indication -- **Configuration errors**: Use defaults and continue with warning -- **File corruption**: Load what's possible, warn user, maintain functionality -- **DNS resolution failure**: Show error but continue (planned for Phase 5) -- **Network unavailable**: Disable DNS features (planned for Phase 5) - -### User Feedback -- **Rich status bar**: Show current mode, entry counts, file path, and operation status -- **Modal dialogs**: Professional configuration interface with proper keyboard handling -- **Color-coded entries**: Visual distinction between active/inactive entries -- **Sort indicators**: Visual arrows showing current sort column and direction -- **Interactive headers**: Click feedback and hover states for column sorting -- **Progress indicators**: For long-running operations (planned for future phases) - -### Recovery Mechanisms -- **Configuration fallback**: Automatic fallback to defaults on configuration errors -- **Position restoration**: Intelligent cursor position maintenance on reload -- **Error isolation**: Configuration errors don't affect core functionality -- **Undo operations**: Allow reverting recent changes (planned for Phase 4) -- **File restoration**: Restore from backup if available (implemented with automatic backup system) -- **Safe mode**: Minimal functionality if errors occur -- **Graceful exit**: Always attempt to save valid changes and configuration diff --git a/memory-bank/techContext.md b/memory-bank/techContext.md deleted file mode 100644 index 3bf1136..0000000 --- a/memory-bank/techContext.md +++ /dev/null @@ -1,155 +0,0 @@ -# Technical Context: hosts - -## Technologies Used - -### Core Technologies -- **Python 3.13+**: Modern Python with latest features and performance improvements -- **Textual**: Rich TUI framework for building terminal applications with modern UI components -- **uv**: Fast Python package manager and runtime for dependency management and execution - -### Development Tools -- **ruff**: Lightning-fast Python linter and formatter for code quality -- **pytest**: Testing framework for comprehensive test coverage -- **textual.testing**: Built-in testing utilities for TUI components - -## Development Setup - -### Project Structure -``` -hosts/ -├── pyproject.toml # uv-managed project configuration -├── README.md -├── main.py # Current entry point (temporary) -├── src/hosts/ # Main package (planned) -│ ├── __init__.py -│ ├── main.py # Application entry point -│ ├── tui/ # UI components -│ ├── core/ # Business logic -│ └── utils.py -└── tests/ # Test suite -``` - -### Current State -- ✅ **Complete uv project**: Python 3.13 with full dependency management -- ✅ **Production application**: Fully functional TUI with complete edit mode and professional interface -- ✅ **Clean code quality**: All ruff linting and formatting checks passing -- ✅ **Proper project structure**: Well-organized src/hosts/ package with core and tui modules -- ✅ **Test coverage excellence**: 302 tests with 99.7% success rate (301 passing, 1 minor failure) -- ✅ **Entry point configured**: `hosts` command launches application perfectly -- ✅ **Configuration system**: Complete settings management with JSON persistence -- ✅ **Modal interface**: Professional configuration and save confirmation dialogs -- ✅ **Advanced features**: DNS resolution, import/export, filtering, undo/redo, sorting, edit mode, permission management -- ✅ **DNS Resolution System**: Complete async DNS service with timeout handling and batch processing -- ✅ **Import/Export System**: Multi-format support (hosts, JSON, CSV) with comprehensive validation -- ✅ **Advanced Filtering System**: Multi-criteria filtering with presets and dynamic filtering -- ✅ **Command System**: Undo/redo functionality with command pattern implementation -- ✅ **User experience enhancements**: Status appearance improvements and entry details consistency completed -- ✅ **Edit mode foundation**: Complete permission management, file backup, and safe operations - -### Runtime Management -- ✅ **uv run hosts**: Command executes application instantly -- ✅ **uv**: Handles all dependency management and virtual environment flawlessly -- ✅ **Python 3.13**: Modern features working excellently throughout codebase -- ✅ **Development workflow**: Smooth uv-based development experience - -## Technical Constraints - -### System Integration -- **Root access required**: Must handle `/etc/hosts` file modifications -- **Sudo permission management**: Request permissions only in edit mode -- **File integrity**: Must preserve existing hosts file structure and comments -- **Cross-platform compatibility**: Focus on Unix-like systems (Linux, macOS) - -### Performance Requirements -- **Fast startup**: TUI should load quickly even with large hosts files -- **Responsive UI**: No blocking operations in the main UI thread -- **Memory efficient**: Handle large hosts files without excessive memory usage - -### Security Considerations -- **Privilege escalation**: Only request sudo when entering edit mode -- **Input validation**: Validate all IP addresses and hostnames before writing -- **Backup strategy**: Consider creating backups before modifications -- **Permission dropping**: Release sudo permissions when exiting edit mode - -## Dependencies - -### Current Dependencies -```toml -[project] -requires-python = ">=3.13" -dependencies = [ - "textual>=5.0.1", - "pytest>=8.4.1", - "ruff>=0.12.5", -] - -[project.scripts] -hosts = "hosts.main:main" -``` - -### Production Dependencies -- ✅ **textual**: Rich TUI framework providing excellent reactive UI components, DataTable, and modal system -- ✅ **pytest**: Comprehensive testing framework with 302 tests (301 passing - 99.7% success rate) -- ✅ **ruff**: Lightning-fast linter and formatter with perfect compliance -- ✅ **ipaddress**: Built-in Python module for robust IP validation and sorting -- ✅ **json**: Built-in Python module for configuration persistence and import/export -- ✅ **csv**: Built-in Python module for CSV import/export functionality -- ✅ **asyncio**: Built-in Python module for async DNS resolution with timeout handling -- ✅ **pathlib**: Built-in Python module for cross-platform path handling -- ✅ **socket**: Built-in Python module for DNS resolution (complete implementation) - -## Tool Usage Patterns - -### Development Workflow -1. ✅ **uv run hosts**: Execute the application - launches instantly -2. ✅ **uv run ruff check**: Lint code - all checks currently passing -3. ✅ **uv run ruff format**: Auto-format code - consistent style maintained -4. ✅ **uv run pytest**: Run test suite - 302 tests with 99.7% success rate (301 passing, 1 minor failure) -5. ✅ **uv add**: Add dependencies - seamless dependency management - -### Code Quality Status -- **Current status**: All linting checks passing with clean code -- **Test coverage**: 302 comprehensive tests with 99.7% pass rate (301 passing) -- **Code formatting**: Perfect formatting compliance maintained -- **Type hints**: Complete type coverage throughout entire codebase - -### Code Quality Achieved -- ✅ **ruff configuration**: Perfect compliance with zero issues across all modules -- ✅ **Type hints**: Complete type coverage throughout entire codebase including all components -- ✅ **Docstrings**: Comprehensive documentation for all public APIs and classes -- ✅ **Test coverage**: Excellent coverage on all core business logic and features (302 tests) -- ✅ **Architecture**: Clean separation of concerns with extensible and maintainable structure -- ✅ **Configuration management**: Robust JSON handling with proper error recovery -- ✅ **Modal system**: Professional dialog implementation with proper lifecycle management -- ✅ **Permission management**: Secure sudo handling with proper lifecycle management -- ✅ **Edit operations**: Safe file modification with backup and atomic operations -- ✅ **DNS Resolution**: Complete async service with timeout handling and batch processing -- ✅ **Import/Export**: Multi-format support with comprehensive validation and error handling -- ✅ **Advanced Filtering**: Multi-criteria filtering with presets and dynamic filtering -- ✅ **Command System**: Undo/redo functionality with command pattern implementation - -## Architecture Decisions - -### Separation of Concerns -- **TUI layer**: Handle user interface and input/output -- **Core layer**: Business logic for hosts file management -- **Utils layer**: Shared utilities and helper functions - -### Error Handling -- **Graceful degradation**: Handle missing permissions or file access issues -- **User feedback**: Clear error messages in the TUI -- **Recovery mechanisms**: Allow users to retry failed operations - -### Testing Strategy Implemented -- ✅ **Unit tests**: 302 comprehensive tests covering all core logic and advanced features -- ✅ **Integration tests**: TUI components tested with mocked file system and configuration -- ✅ **Edge case testing**: Comprehensive coverage of parsing, configuration, and modal edge cases -- ✅ **Mock external dependencies**: File I/O, system operations, DNS resolution, and configuration properly mocked -- ✅ **Test fixtures**: Realistic hosts file samples and configuration scenarios for thorough testing -- ✅ **Configuration testing**: Complete coverage of JSON persistence, error handling, and defaults -- ✅ **Modal testing**: Comprehensive testing of dialog lifecycle and user interactions -- ✅ **DNS Resolution testing**: Complete async DNS service testing with timeout handling -- ✅ **Import/Export testing**: Multi-format testing with comprehensive validation coverage -- ✅ **Advanced Filtering testing**: Multi-criteria filtering with presets and dynamic filtering -- ✅ **Command System testing**: Undo/redo functionality with command pattern testing -- ✅ **Performance testing**: Large file handling and optimization completed diff --git a/src/hosts/core/commands.py b/src/hosts/core/commands.py index 6fac152..be2e85c 100644 --- a/src/hosts/core/commands.py +++ b/src/hosts/core/commands.py @@ -15,12 +15,13 @@ if TYPE_CHECKING: @dataclass class OperationResult: """Result of executing or undoing a command. - + Attributes: success: Whether the operation succeeded message: Human-readable description of the result data: Optional additional data about the operation """ + success: bool message: str data: Optional[Dict[str, Any]] = None @@ -28,39 +29,39 @@ class OperationResult: class Command(ABC): """Abstract base class for all edit commands. - + All edit operations (toggle, move, add, delete, update) implement this interface to provide consistent execute/undo capabilities for the undo/redo system. """ - + @abstractmethod def execute(self, hosts_file: "HostsFile") -> OperationResult: """Execute the command and return the result. - + Args: hosts_file: The hosts file to operate on - + Returns: OperationResult indicating success/failure and details """ pass - + @abstractmethod def undo(self, hosts_file: "HostsFile") -> OperationResult: """Undo the command and return the result. - + Args: hosts_file: The hosts file to operate on - + Returns: OperationResult indicating success/failure and details """ pass - + @abstractmethod def get_description(self) -> str: """Get a human-readable description of the command. - + Returns: String description of what this command does """ @@ -69,137 +70,133 @@ class Command(ABC): class UndoRedoHistory: """Manages undo/redo history with configurable limits. - + This class maintains separate stacks for undo and redo operations, executes commands while managing history, and provides methods to check availability of undo/redo operations. """ - + def __init__(self, max_history: int = 50): """Initialize the history manager. - + Args: max_history: Maximum number of commands to keep in history """ self.max_history = max_history self.undo_stack: list[Command] = [] self.redo_stack: list[Command] = [] - - def execute_command(self, command: Command, hosts_file: "HostsFile") -> OperationResult: + + def execute_command( + self, command: Command, hosts_file: "HostsFile" + ) -> OperationResult: """Execute a command and add it to the undo stack. - + Args: command: Command to execute hosts_file: The hosts file to operate on - + Returns: OperationResult from command execution """ result = command.execute(hosts_file) - + if result.success: # Add to undo stack and clear redo stack self.undo_stack.append(command) self.redo_stack.clear() - + # Enforce history limit if len(self.undo_stack) > self.max_history: self.undo_stack.pop(0) - + return result - + def undo(self, hosts_file: "HostsFile") -> OperationResult: """Undo the last command. - + Args: hosts_file: The hosts file to operate on - + Returns: OperationResult from undo operation """ if not self.can_undo(): - return OperationResult( - success=False, - message="No operations to undo" - ) - + return OperationResult(success=False, message="No operations to undo") + command = self.undo_stack.pop() result = command.undo(hosts_file) - + if result.success: # Move command to redo stack self.redo_stack.append(command) - + # Enforce history limit on redo stack too if len(self.redo_stack) > self.max_history: self.redo_stack.pop(0) else: # If undo failed, put command back on undo stack self.undo_stack.append(command) - + return result - + def redo(self, hosts_file: "HostsFile") -> OperationResult: """Redo the last undone command. - + Args: hosts_file: The hosts file to operate on - + Returns: OperationResult from redo operation """ if not self.can_redo(): - return OperationResult( - success=False, - message="No operations to redo" - ) - + return OperationResult(success=False, message="No operations to redo") + command = self.redo_stack.pop() result = command.execute(hosts_file) - + if result.success: # Move command back to undo stack self.undo_stack.append(command) else: # If redo failed, put command back on redo stack self.redo_stack.append(command) - + return result - + def can_undo(self) -> bool: """Check if undo is possible. - + Returns: True if there are commands that can be undone """ return len(self.undo_stack) > 0 - + def can_redo(self) -> bool: """Check if redo is possible. - + Returns: True if there are commands that can be redone """ return len(self.redo_stack) > 0 - + def clear_history(self) -> None: """Clear both undo and redo stacks.""" self.undo_stack.clear() self.redo_stack.clear() - + def get_undo_description(self) -> Optional[str]: """Get description of the next command that would be undone. - + Returns: Description string or None if no undo available """ if self.can_undo(): return self.undo_stack[-1].get_description() return None - + def get_redo_description(self) -> Optional[str]: """Get description of the next command that would be redone. - + Returns: Description string or None if no redo available """ @@ -210,59 +207,56 @@ class UndoRedoHistory: class ToggleEntryCommand(Command): """Command to toggle an entry's active state.""" - + def __init__(self, index: int): """Initialize the toggle command. - + Args: index: Index of the entry to toggle """ self.index = index self.original_state: Optional[bool] = None - + def execute(self, hosts_file: "HostsFile") -> OperationResult: """Toggle the entry's active state.""" if self.index < 0 or self.index >= len(hosts_file.entries): return OperationResult( - success=False, - message=f"Invalid entry index: {self.index}" + success=False, message=f"Invalid entry index: {self.index}" ) - + entry = hosts_file.entries[self.index] self.original_state = entry.is_active entry.is_active = not entry.is_active - + action = "activated" if entry.is_active else "deactivated" return OperationResult( success=True, message=f"Entry {action}: {entry.ip_address} {' '.join(entry.hostnames)}", - data={"index": self.index, "new_state": entry.is_active} + data={"index": self.index, "new_state": entry.is_active}, ) - + def undo(self, hosts_file: "HostsFile") -> OperationResult: """Restore the entry's original active state.""" if self.original_state is None: return OperationResult( - success=False, - message="Cannot undo: original state not saved" + success=False, message="Cannot undo: original state not saved" ) - + if self.index < 0 or self.index >= len(hosts_file.entries): return OperationResult( - success=False, - message=f"Cannot undo: invalid entry index: {self.index}" + success=False, message=f"Cannot undo: invalid entry index: {self.index}" ) - + entry = hosts_file.entries[self.index] entry.is_active = self.original_state - + action = "activated" if entry.is_active else "deactivated" return OperationResult( success=True, message=f"Undid toggle: entry {action}", - data={"index": self.index, "restored_state": entry.is_active} + data={"index": self.index, "restored_state": entry.is_active}, ) - + def get_description(self) -> str: """Get description of this command.""" return f"Toggle entry at index {self.index}" @@ -270,64 +264,75 @@ class ToggleEntryCommand(Command): class MoveEntryCommand(Command): """Command to move an entry up or down.""" - + def __init__(self, from_index: int, to_index: int): """Initialize the move command. - + Args: from_index: Original position of the entry to_index: Target position for the entry """ self.from_index = from_index self.to_index = to_index - + def execute(self, hosts_file: "HostsFile") -> OperationResult: """Move the entry from one position to another.""" - if (self.from_index < 0 or self.from_index >= len(hosts_file.entries) or - self.to_index < 0 or self.to_index >= len(hosts_file.entries)): + if ( + self.from_index < 0 + or self.from_index >= len(hosts_file.entries) + or self.to_index < 0 + or self.to_index >= len(hosts_file.entries) + ): return OperationResult( success=False, - message=f"Invalid move: from {self.from_index} to {self.to_index}" + message=f"Invalid move: from {self.from_index} to {self.to_index}", ) - + if self.from_index == self.to_index: return OperationResult( success=True, message="No movement needed", - data={"from_index": self.from_index, "to_index": self.to_index} + data={"from_index": self.from_index, "to_index": self.to_index}, ) - + # Move the entry entry = hosts_file.entries.pop(self.from_index) hosts_file.entries.insert(self.to_index, entry) - + direction = "up" if self.to_index < self.from_index else "down" return OperationResult( success=True, message=f"Moved entry {direction}: {entry.ip_address} {' '.join(entry.hostnames)}", - data={"from_index": self.from_index, "to_index": self.to_index, "direction": direction} + data={ + "from_index": self.from_index, + "to_index": self.to_index, + "direction": direction, + }, ) - + def undo(self, hosts_file: "HostsFile") -> OperationResult: """Move the entry back to its original position.""" - if (self.to_index < 0 or self.to_index >= len(hosts_file.entries) or - self.from_index < 0 or self.from_index >= len(hosts_file.entries)): + if ( + self.to_index < 0 + or self.to_index >= len(hosts_file.entries) + or self.from_index < 0 + or self.from_index >= len(hosts_file.entries) + ): return OperationResult( - success=False, - message="Cannot undo move: invalid indices" + success=False, message="Cannot undo move: invalid indices" ) - + # Move back: from to_index back to from_index entry = hosts_file.entries.pop(self.to_index) hosts_file.entries.insert(self.from_index, entry) - + direction = "down" if self.to_index < self.from_index else "up" return OperationResult( success=True, message=f"Undid move: moved entry {direction}", - data={"restored_index": self.from_index} + data={"restored_index": self.from_index}, ) - + def get_description(self) -> str: """Get description of this command.""" direction = "up" if self.to_index < self.from_index else "down" @@ -336,10 +341,10 @@ class MoveEntryCommand(Command): class AddEntryCommand(Command): """Command to add a new entry.""" - + def __init__(self, entry: "HostEntry", index: Optional[int] = None): """Initialize the add command. - + Args: entry: The entry to add index: Position to insert at (None for end) @@ -347,7 +352,7 @@ class AddEntryCommand(Command): self.entry = entry self.index = index self.actual_index: Optional[int] = None - + def execute(self, hosts_file: "HostsFile") -> OperationResult: """Add the entry to the hosts file.""" if self.index is None: @@ -358,49 +363,49 @@ class AddEntryCommand(Command): # Insert at specific position if self.index < 0 or self.index > len(hosts_file.entries): return OperationResult( - success=False, - message=f"Invalid insertion index: {self.index}" + success=False, message=f"Invalid insertion index: {self.index}" ) hosts_file.entries.insert(self.index, self.entry) self.actual_index = self.index - + return OperationResult( success=True, message=f"Added entry: {self.entry.ip_address} {' '.join(self.entry.hostnames)}", - data={"index": self.actual_index, "entry": self.entry} + data={"index": self.actual_index, "entry": self.entry}, ) - + def undo(self, hosts_file: "HostsFile") -> OperationResult: """Remove the added entry.""" if self.actual_index is None: return OperationResult( - success=False, - message="Cannot undo: entry index not recorded" + success=False, message="Cannot undo: entry index not recorded" ) - + if self.actual_index < 0 or self.actual_index >= len(hosts_file.entries): return OperationResult( success=False, - message=f"Cannot undo: invalid entry index: {self.actual_index}" + message=f"Cannot undo: invalid entry index: {self.actual_index}", ) - + # Verify we're removing the right entry entry_to_remove = hosts_file.entries[self.actual_index] - if (entry_to_remove.ip_address != self.entry.ip_address or - entry_to_remove.hostnames != self.entry.hostnames): + if ( + entry_to_remove.ip_address != self.entry.ip_address + or entry_to_remove.hostnames != self.entry.hostnames + ): return OperationResult( success=False, - message="Cannot undo: entry at index doesn't match added entry" + message="Cannot undo: entry at index doesn't match added entry", ) - + hosts_file.entries.pop(self.actual_index) - + return OperationResult( success=True, message=f"Undid add: removed entry {self.entry.ip_address}", - data={"removed_index": self.actual_index} + data={"removed_index": self.actual_index}, ) - + def get_description(self) -> str: """Get description of this command.""" return f"Add entry: {self.entry.ip_address} {' '.join(self.entry.hostnames)}" @@ -408,54 +413,52 @@ class AddEntryCommand(Command): class DeleteEntryCommand(Command): """Command to delete an entry.""" - + def __init__(self, index: int): """Initialize the delete command. - + Args: index: Index of the entry to delete """ self.index = index self.deleted_entry: Optional["HostEntry"] = None - + def execute(self, hosts_file: "HostsFile") -> OperationResult: """Delete the entry from the hosts file.""" if self.index < 0 or self.index >= len(hosts_file.entries): return OperationResult( - success=False, - message=f"Invalid entry index: {self.index}" + success=False, message=f"Invalid entry index: {self.index}" ) - + self.deleted_entry = hosts_file.entries.pop(self.index) - + return OperationResult( success=True, message=f"Deleted entry: {self.deleted_entry.ip_address} {' '.join(self.deleted_entry.hostnames)}", - data={"index": self.index, "deleted_entry": self.deleted_entry} + data={"index": self.index, "deleted_entry": self.deleted_entry}, ) - + def undo(self, hosts_file: "HostsFile") -> OperationResult: """Restore the deleted entry.""" if self.deleted_entry is None: return OperationResult( - success=False, - message="Cannot undo: deleted entry not saved" + success=False, message="Cannot undo: deleted entry not saved" ) - + if self.index < 0 or self.index > len(hosts_file.entries): return OperationResult( success=False, - message=f"Cannot undo: invalid restoration index: {self.index}" + message=f"Cannot undo: invalid restoration index: {self.index}", ) - + hosts_file.entries.insert(self.index, self.deleted_entry) - + return OperationResult( success=True, message=f"Undid delete: restored entry {self.deleted_entry.ip_address}", - data={"restored_index": self.index, "restored_entry": self.deleted_entry} + data={"restored_index": self.index, "restored_entry": self.deleted_entry}, ) - + def get_description(self) -> str: """Get description of this command.""" if self.deleted_entry: @@ -465,11 +468,17 @@ class DeleteEntryCommand(Command): class UpdateEntryCommand(Command): """Command to update an entry.""" - - def __init__(self, index: int, new_ip: str, new_hostnames: list[str], - new_comment: Optional[str], new_active: bool): + + def __init__( + self, + index: int, + new_ip: str, + new_hostnames: list[str], + new_comment: Optional[str], + new_active: bool, + ): """Initialize the update command. - + Args: index: Index of the entry to update new_ip: New IP address @@ -483,65 +492,63 @@ class UpdateEntryCommand(Command): self.new_comment = new_comment self.new_active = new_active self.original_entry: Optional["HostEntry"] = None - + def execute(self, hosts_file: "HostsFile") -> OperationResult: """Update the entry with new values.""" if self.index < 0 or self.index >= len(hosts_file.entries): return OperationResult( - success=False, - message=f"Invalid entry index: {self.index}" + success=False, message=f"Invalid entry index: {self.index}" ) - + # Save original entry for undo from .models import HostEntry + original = hosts_file.entries[self.index] self.original_entry = HostEntry( ip_address=original.ip_address, hostnames=original.hostnames.copy(), comment=original.comment, - is_active=original.is_active + is_active=original.is_active, ) - + # Update the entry entry = hosts_file.entries[self.index] entry.ip_address = self.new_ip entry.hostnames = self.new_hostnames.copy() entry.comment = self.new_comment entry.is_active = self.new_active - + return OperationResult( success=True, message=f"Updated entry: {entry.ip_address} {' '.join(entry.hostnames)}", - data={"index": self.index, "updated_entry": entry} + data={"index": self.index, "updated_entry": entry}, ) - + def undo(self, hosts_file: "HostsFile") -> OperationResult: """Restore the entry's original values.""" if self.original_entry is None: return OperationResult( - success=False, - message="Cannot undo: original entry not saved" + success=False, message="Cannot undo: original entry not saved" ) - + if self.index < 0 or self.index >= len(hosts_file.entries): return OperationResult( - success=False, - message=f"Cannot undo: invalid entry index: {self.index}" + success=False, message=f"Cannot undo: invalid entry index: {self.index}" ) - + # Restore original values entry = hosts_file.entries[self.index] entry.ip_address = self.original_entry.ip_address entry.hostnames = self.original_entry.hostnames.copy() entry.comment = self.original_entry.comment entry.is_active = self.original_entry.is_active - + return OperationResult( success=True, message=f"Undid update: restored entry {entry.ip_address}", - data={"index": self.index, "restored_entry": entry} + data={"index": self.index, "restored_entry": entry}, ) - + def get_description(self) -> str: """Get description of this command.""" if self.original_entry: diff --git a/src/hosts/core/config.py b/src/hosts/core/config.py index 5290277..123f81a 100644 --- a/src/hosts/core/config.py +++ b/src/hosts/core/config.py @@ -37,7 +37,7 @@ class Config: }, "dns_resolution": { "enabled": True, - "timeout": 5.0, # 5 seconds timeout + "timeout": 5.0, # 5 seconds timeout }, "filter_settings": { "remember_filter_state": True, @@ -201,7 +201,9 @@ class Config: def get_export_directory(self) -> str: """Get default export directory.""" - return self.get("import_export", {}).get("export_directory", str(Path.home() / "Downloads")) + return self.get("import_export", {}).get( + "export_directory", str(Path.home() / "Downloads") + ) def set_default_export_format(self, format_name: str) -> None: """Set default export format.""" diff --git a/src/hosts/core/dns.py b/src/hosts/core/dns.py index d790590..458bfa2 100644 --- a/src/hosts/core/dns.py +++ b/src/hosts/core/dns.py @@ -18,6 +18,7 @@ logger = logging.getLogger(__name__) @dataclass class DNSResolutionStatus(Enum): """Status of DNS resolution for an entry.""" + NOT_RESOLVED = "not_resolved" RESOLVING = "resolving" RESOLVED = "resolved" @@ -29,6 +30,7 @@ class DNSResolutionStatus(Enum): @dataclass class DNSResolution: """Result of DNS resolution for a hostname.""" + hostname: str resolved_ip: Optional[str] status: DNSResolutionStatus @@ -37,7 +39,9 @@ class DNSResolution: def is_success(self) -> bool: """Check if resolution was successful.""" - return self.status == DNSResolutionStatus.RESOLVED and self.resolved_ip is not None + return ( + self.status == DNSResolutionStatus.RESOLVED and self.resolved_ip is not None + ) def get_age_seconds(self) -> float: """Get age of resolution in seconds.""" @@ -46,24 +50,23 @@ class DNSResolution: async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution: """Resolve a single hostname to IP address with timeout. - + Args: hostname: Hostname to resolve timeout: Maximum time to wait for resolution in seconds - + Returns: DNSResolution with result and status """ start_time = datetime.now() - + try: # Use asyncio DNS resolution with timeout loop = asyncio.get_event_loop() result = await asyncio.wait_for( - loop.getaddrinfo(hostname, None, family=socket.AF_UNSPEC), - timeout=timeout + loop.getaddrinfo(hostname, None, family=socket.AF_UNSPEC), timeout=timeout ) - + if result: # Get first result (usually IPv4) ip_address = result[0][4][0] @@ -71,7 +74,7 @@ async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution hostname=hostname, resolved_ip=ip_address, status=DNSResolutionStatus.RESOLVED, - resolved_at=start_time + resolved_at=start_time, ) else: return DNSResolution( @@ -79,16 +82,16 @@ async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution resolved_ip=None, status=DNSResolutionStatus.RESOLUTION_FAILED, resolved_at=start_time, - error_message="No address found" + error_message="No address found", ) - + except asyncio.TimeoutError: return DNSResolution( hostname=hostname, resolved_ip=None, status=DNSResolutionStatus.RESOLUTION_FAILED, resolved_at=start_time, - error_message=f"Timeout after {timeout}s" + error_message=f"Timeout after {timeout}s", ) except Exception as e: return DNSResolution( @@ -96,66 +99,66 @@ async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution resolved_ip=None, status=DNSResolutionStatus.RESOLUTION_FAILED, resolved_at=start_time, - error_message=str(e) + error_message=str(e), ) -async def resolve_hostnames_batch(hostnames: List[str], timeout: float = 5.0) -> List[DNSResolution]: +async def resolve_hostnames_batch( + hostnames: List[str], timeout: float = 5.0 +) -> List[DNSResolution]: """Resolve multiple hostnames concurrently. - + Args: hostnames: List of hostnames to resolve timeout: Maximum time to wait for each resolution - + Returns: List of DNSResolution results """ if not hostnames: return [] - + tasks = [resolve_hostname(hostname, timeout) for hostname in hostnames] results = await asyncio.gather(*tasks, return_exceptions=True) - + # Convert exceptions to failed resolutions resolutions = [] for i, result in enumerate(results): if isinstance(result, Exception): - resolutions.append(DNSResolution( - hostname=hostnames[i], - resolved_ip=None, - status=DNSResolutionStatus.RESOLUTION_FAILED, - resolved_at=datetime.now(), - error_message=str(result) - )) + resolutions.append( + DNSResolution( + hostname=hostnames[i], + resolved_ip=None, + status=DNSResolutionStatus.RESOLUTION_FAILED, + resolved_at=datetime.now(), + error_message=str(result), + ) + ) else: resolutions.append(result) - + return resolutions class DNSService: """DNS resolution service for hosts entries.""" - - def __init__( - self, - enabled: bool = True, - timeout: float = 5.0 - ): + + def __init__(self, enabled: bool = True, timeout: float = 5.0): """Initialize DNS service. - + Args: enabled: Whether DNS resolution is enabled timeout: Timeout for individual DNS queries """ self.enabled = enabled self.timeout = timeout - + async def resolve_entry_async(self, hostname: str) -> DNSResolution: """Resolve DNS for a hostname asynchronously. - + Args: hostname: Hostname to resolve - + Returns: DNSResolution result """ @@ -165,28 +168,28 @@ class DNSService: resolved_ip=None, status=DNSResolutionStatus.NOT_RESOLVED, resolved_at=datetime.now(), - error_message="DNS resolution is disabled" + error_message="DNS resolution is disabled", ) - + return await resolve_hostname(hostname, self.timeout) - + async def refresh_entry(self, hostname: str) -> DNSResolution: """Manually refresh DNS resolution for hostname. - + Args: hostname: Hostname to refresh - + Returns: Fresh DNSResolution result """ return await self.resolve_entry_async(hostname) - + async def refresh_all_entries(self, hostnames: List[str]) -> List[DNSResolution]: """Manually refresh DNS resolution for multiple hostnames. - + Args: hostnames: List of hostnames to refresh - + Returns: List of fresh DNSResolution results """ @@ -197,21 +200,21 @@ class DNSService: resolved_ip=None, status=DNSResolutionStatus.NOT_RESOLVED, resolved_at=datetime.now(), - error_message="DNS resolution is disabled" + error_message="DNS resolution is disabled", ) for hostname in hostnames ] - + return await resolve_hostnames_batch(hostnames, self.timeout) def compare_ips(stored_ip: str, resolved_ip: str) -> DNSResolutionStatus: """Compare stored IP with resolved IP to determine status. - + Args: stored_ip: IP address stored in hosts entry resolved_ip: IP address resolved from DNS - + Returns: DNSResolutionStatus indicating match or mismatch """ diff --git a/src/hosts/core/filters.py b/src/hosts/core/filters.py index 9b095e7..abaae5a 100644 --- a/src/hosts/core/filters.py +++ b/src/hosts/core/filters.py @@ -14,6 +14,7 @@ from .models import HostEntry class FilterType(Enum): """Filter type enumeration.""" + STATUS = "status" DNS_TYPE = "dns_type" RESOLUTION_STATUS = "resolution_status" @@ -23,18 +24,19 @@ class FilterType(Enum): @dataclass class FilterOptions: """Configuration options for filtering entries.""" + # Status filtering show_active: bool = True show_inactive: bool = True active_only: bool = False inactive_only: bool = False - + # DNS type filtering show_dns_entries: bool = True show_ip_entries: bool = True dns_only: bool = False ip_only: bool = False - + # DNS resolution status filtering show_resolved: bool = True show_unresolved: bool = True @@ -43,167 +45,177 @@ class FilterOptions: show_mismatched: bool = True mismatch_only: bool = False resolved_only: bool = False - + # Search filtering search_term: Optional[str] = None search_in_hostnames: bool = True search_in_comments: bool = True search_in_ips: bool = True case_sensitive: bool = False - + # Filter preset preset_name: Optional[str] = None def to_dict(self) -> Dict[str, Any]: """Convert FilterOptions to dictionary.""" return { - 'show_active': self.show_active, - 'show_inactive': self.show_inactive, - 'active_only': self.active_only, - 'inactive_only': self.inactive_only, - 'show_dns_entries': self.show_dns_entries, - 'show_ip_entries': self.show_ip_entries, - 'dns_only': self.dns_only, - 'ip_only': self.ip_only, - 'show_resolved': self.show_resolved, - 'show_unresolved': self.show_unresolved, - 'show_resolving': self.show_resolving, - 'show_failed': self.show_failed, - 'show_mismatched': self.show_mismatched, - 'mismatch_only': self.mismatch_only, - 'resolved_only': self.resolved_only, - 'search_term': self.search_term or "", - 'search_in_hostnames': self.search_in_hostnames, - 'search_in_comments': self.search_in_comments, - 'search_in_ips': self.search_in_ips, - 'case_sensitive': self.case_sensitive, - 'preset_name': self.preset_name + "show_active": self.show_active, + "show_inactive": self.show_inactive, + "active_only": self.active_only, + "inactive_only": self.inactive_only, + "show_dns_entries": self.show_dns_entries, + "show_ip_entries": self.show_ip_entries, + "dns_only": self.dns_only, + "ip_only": self.ip_only, + "show_resolved": self.show_resolved, + "show_unresolved": self.show_unresolved, + "show_resolving": self.show_resolving, + "show_failed": self.show_failed, + "show_mismatched": self.show_mismatched, + "mismatch_only": self.mismatch_only, + "resolved_only": self.resolved_only, + "search_term": self.search_term or "", + "search_in_hostnames": self.search_in_hostnames, + "search_in_comments": self.search_in_comments, + "search_in_ips": self.search_in_ips, + "case_sensitive": self.case_sensitive, + "preset_name": self.preset_name, } @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'FilterOptions': + def from_dict(cls, data: Dict[str, Any]) -> "FilterOptions": """Create FilterOptions from dictionary.""" return cls( - show_active=data.get('show_active', True), - show_inactive=data.get('show_inactive', True), - active_only=data.get('active_only', False), - inactive_only=data.get('inactive_only', False), - show_dns_entries=data.get('show_dns_entries', True), - show_ip_entries=data.get('show_ip_entries', True), - dns_only=data.get('dns_only', False), - ip_only=data.get('ip_only', False), - show_resolved=data.get('show_resolved', True), - show_unresolved=data.get('show_unresolved', True), - show_resolving=data.get('show_resolving', True), - show_failed=data.get('show_failed', True), - show_mismatched=data.get('show_mismatched', True), - mismatch_only=data.get('mismatch_only', False), - resolved_only=data.get('resolved_only', False), - search_term=data.get('search_term', None), - search_in_hostnames=data.get('search_in_hostnames', True), - search_in_comments=data.get('search_in_comments', True), - search_in_ips=data.get('search_in_ips', True), - case_sensitive=data.get('case_sensitive', False), - preset_name=data.get('preset_name', None) + show_active=data.get("show_active", True), + show_inactive=data.get("show_inactive", True), + active_only=data.get("active_only", False), + inactive_only=data.get("inactive_only", False), + show_dns_entries=data.get("show_dns_entries", True), + show_ip_entries=data.get("show_ip_entries", True), + dns_only=data.get("dns_only", False), + ip_only=data.get("ip_only", False), + show_resolved=data.get("show_resolved", True), + show_unresolved=data.get("show_unresolved", True), + show_resolving=data.get("show_resolving", True), + show_failed=data.get("show_failed", True), + show_mismatched=data.get("show_mismatched", True), + mismatch_only=data.get("mismatch_only", False), + resolved_only=data.get("resolved_only", False), + search_term=data.get("search_term", None), + search_in_hostnames=data.get("search_in_hostnames", True), + search_in_comments=data.get("search_in_comments", True), + search_in_ips=data.get("search_in_ips", True), + case_sensitive=data.get("case_sensitive", False), + preset_name=data.get("preset_name", None), ) def is_empty(self) -> bool: """Check if filter options represent no filtering (default state).""" return ( - self.show_active and self.show_inactive and - not self.active_only and not self.inactive_only and - self.show_dns_entries and self.show_ip_entries and - not self.dns_only and not self.ip_only and - self.show_resolved and self.show_unresolved and - self.show_resolving and self.show_failed and self.show_mismatched and - not self.mismatch_only and not self.resolved_only and - not self.search_term + self.show_active + and self.show_inactive + and not self.active_only + and not self.inactive_only + and self.show_dns_entries + and self.show_ip_entries + and not self.dns_only + and not self.ip_only + and self.show_resolved + and self.show_unresolved + and self.show_resolving + and self.show_failed + and self.show_mismatched + and not self.mismatch_only + and not self.resolved_only + and not self.search_term ) class EntryFilter: """Advanced filtering logic for hosts entries.""" - + def __init__(self): """Initialize the entry filter.""" self.presets: Dict[str, FilterOptions] = {} self._load_default_presets() - + def _load_default_presets(self) -> None: """Load default filter presets.""" self.presets = { "All Entries": FilterOptions(), - "Active Only": FilterOptions( - show_inactive=False, - active_only=True - ), - "Inactive Only": FilterOptions( - show_active=False, - inactive_only=True - ), - "DNS Entries Only": FilterOptions( - show_ip_entries=False, - dns_only=True - ), - "IP Entries Only": FilterOptions( - show_dns_entries=False, - ip_only=True - ), - "DNS Mismatches": FilterOptions( - mismatch_only=True - ), + "Active Only": FilterOptions(show_inactive=False, active_only=True), + "Inactive Only": FilterOptions(show_active=False, inactive_only=True), + "DNS Entries Only": FilterOptions(show_ip_entries=False, dns_only=True), + "IP Entries Only": FilterOptions(show_dns_entries=False, ip_only=True), + "DNS Mismatches": FilterOptions(mismatch_only=True), "Resolution Failed": FilterOptions( show_resolved=False, show_unresolved=False, show_resolving=False, - show_mismatched=False + show_mismatched=False, ), "Needs Resolution": FilterOptions( - show_resolved=False, - show_failed=False, - show_mismatched=False - ) + show_resolved=False, show_failed=False, show_mismatched=False + ), } - - def apply_filters(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]: + + def apply_filters( + self, entries: List[HostEntry], options: FilterOptions + ) -> List[HostEntry]: """ Apply all filter criteria to the list of entries. - + Args: entries: List of host entries to filter options: Filter configuration options - + Returns: Filtered list of entries """ filtered_entries = entries.copy() - + # Apply status filtering - if options.active_only or options.inactive_only or not (options.show_active and options.show_inactive): + if ( + options.active_only + or options.inactive_only + or not (options.show_active and options.show_inactive) + ): filtered_entries = self.filter_by_status(filtered_entries, options) - + # Apply DNS type filtering - if options.dns_only or options.ip_only or not (options.show_dns_entries and options.show_ip_entries): + if ( + options.dns_only + or options.ip_only + or not (options.show_dns_entries and options.show_ip_entries) + ): filtered_entries = self.filter_by_dns_type(filtered_entries, options) - + # Apply DNS resolution status filtering - if options.mismatch_only or options.resolved_only or not self._all_resolution_status_shown(options): - filtered_entries = self.filter_by_resolution_status(filtered_entries, options) - + if ( + options.mismatch_only + or options.resolved_only + or not self._all_resolution_status_shown(options) + ): + filtered_entries = self.filter_by_resolution_status( + filtered_entries, options + ) + # Apply search filtering if options.search_term: filtered_entries = self.filter_by_search(filtered_entries, options) - + return filtered_entries - - def filter_by_status(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]: + + def filter_by_status( + self, entries: List[HostEntry], options: FilterOptions + ) -> List[HostEntry]: """ Filter entries by active/inactive status. - + Args: entries: List of entries to filter options: Filter options containing status criteria - + Returns: Filtered list of entries """ @@ -220,15 +232,17 @@ class EntryFilter: elif not entry.is_active and options.show_inactive: filtered.append(entry) return filtered - - def filter_by_dns_type(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]: + + def filter_by_dns_type( + self, entries: List[HostEntry], options: FilterOptions + ) -> List[HostEntry]: """ Filter entries by DNS name vs IP address type. - + Args: entries: List of entries to filter options: Filter options containing DNS type criteria - + Returns: Filtered list of entries """ @@ -245,61 +259,73 @@ class EntryFilter: elif not entry.has_dns_name() and options.show_ip_entries: filtered.append(entry) return filtered - - def filter_by_resolution_status(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]: + + def filter_by_resolution_status( + self, entries: List[HostEntry], options: FilterOptions + ) -> List[HostEntry]: """ Filter entries by DNS resolution status. - + Args: entries: List of entries to filter options: Filter options containing resolution status criteria - + Returns: Filtered list of entries """ if options.mismatch_only: - return [entry for entry in entries - if entry.dns_resolution_status == "IP_MISMATCH"] + return [ + entry + for entry in entries + if entry.dns_resolution_status == "IP_MISMATCH" + ] elif options.resolved_only: - return [entry for entry in entries - if entry.dns_resolution_status in ["IP_MATCH", "RESOLVED"]] + return [ + entry + for entry in entries + if entry.dns_resolution_status in ["IP_MATCH", "RESOLVED"] + ] else: # Show based on individual flags filtered = [] for entry in entries: status = entry.dns_resolution_status or "NOT_RESOLVED" - - if (status == "NOT_RESOLVED" and options.show_unresolved) or \ - (status == "RESOLVING" and options.show_resolving) or \ - (status in ["IP_MATCH", "RESOLVED"] and options.show_resolved) or \ - (status == "RESOLUTION_FAILED" and options.show_failed) or \ - (status == "IP_MISMATCH" and options.show_mismatched): + + if ( + (status == "NOT_RESOLVED" and options.show_unresolved) + or (status == "RESOLVING" and options.show_resolving) + or (status in ["IP_MATCH", "RESOLVED"] and options.show_resolved) + or (status == "RESOLUTION_FAILED" and options.show_failed) + or (status == "IP_MISMATCH" and options.show_mismatched) + ): filtered.append(entry) - + return filtered - - def filter_by_search(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]: + + def filter_by_search( + self, entries: List[HostEntry], options: FilterOptions + ) -> List[HostEntry]: """ Filter entries by search term. - + Args: entries: List of entries to filter options: Filter options containing search criteria - + Returns: Filtered list of entries """ if not options.search_term: return entries - + search_term = options.search_term if not options.case_sensitive: search_term = search_term.lower() - + filtered = [] for entry in entries: match_found = False - + # Search in hostnames if options.search_in_hostnames: hostnames_text = " ".join(entry.hostnames) @@ -307,7 +333,7 @@ class EntryFilter: hostnames_text = hostnames_text.lower() if search_term in hostnames_text: match_found = True - + # Search in comments if not match_found and options.search_in_comments and entry.comment: comment_text = entry.comment @@ -315,7 +341,7 @@ class EntryFilter: comment_text = comment_text.lower() if search_term in comment_text: match_found = True - + # Search in IP addresses if not match_found and options.search_in_ips: ip_text = entry.ip_address or "" @@ -325,22 +351,26 @@ class EntryFilter: ip_text = ip_text.lower() if search_term in ip_text: match_found = True - + if match_found: filtered.append(entry) - + return filtered - + def _all_resolution_status_shown(self, options: FilterOptions) -> bool: """Check if all resolution status types are shown.""" - return (options.show_resolved and options.show_unresolved and - options.show_resolving and options.show_failed and - options.show_mismatched) - + return ( + options.show_resolved + and options.show_unresolved + and options.show_resolving + and options.show_failed + and options.show_mismatched + ) + def save_preset(self, name: str, options: FilterOptions) -> None: """ Save filter options as a preset. - + Args: name: Name for the preset options: Filter options to save @@ -367,29 +397,29 @@ class EntryFilter: search_in_comments=options.search_in_comments, search_in_ips=options.search_in_ips, case_sensitive=options.case_sensitive, - preset_name=name + preset_name=name, ) self.presets[name] = preset_options - + def load_preset(self, name: str) -> Optional[FilterOptions]: """ Load filter options from a preset. - + Args: name: Name of the preset to load - + Returns: Filter options if preset exists, None otherwise """ return self.presets.get(name) - + def delete_preset(self, name: str) -> bool: """ Delete a preset. - + Args: name: Name of the preset to delete - + Returns: True if preset was deleted, False if it didn't exist """ @@ -397,99 +427,102 @@ class EntryFilter: del self.presets[name] return True return False - + def get_preset_names(self) -> List[str]: """ Get list of available preset names. - + Returns: List of preset names """ return list(self.presets.keys()) - + def get_default_presets(self) -> Dict[str, FilterOptions]: """ Get the default filter presets. - + Returns: Dictionary of default presets """ return { "All Entries": FilterOptions(), - "Active Only": FilterOptions( - show_inactive=False, - active_only=True - ), - "Inactive Only": FilterOptions( - show_active=False, - inactive_only=True - ), - "DNS Entries Only": FilterOptions( - show_ip_entries=False, - dns_only=True - ), - "IP Entries Only": FilterOptions( - show_dns_entries=False, - ip_only=True - ), - "DNS Mismatches": FilterOptions( - mismatch_only=True - ), - "Resolved Entries": FilterOptions( - resolved_only=True - ), + "Active Only": FilterOptions(show_inactive=False, active_only=True), + "Inactive Only": FilterOptions(show_active=False, inactive_only=True), + "DNS Entries Only": FilterOptions(show_ip_entries=False, dns_only=True), + "IP Entries Only": FilterOptions(show_dns_entries=False, ip_only=True), + "DNS Mismatches": FilterOptions(mismatch_only=True), + "Resolved Entries": FilterOptions(resolved_only=True), "Unresolved Entries": FilterOptions( show_resolved=False, show_resolving=False, show_failed=False, - show_mismatched=False - ) + show_mismatched=False, + ), } - + def get_saved_presets(self) -> Dict[str, FilterOptions]: """ Get all saved presets (both default and custom). - + Returns: Dictionary of all presets """ return self.presets.copy() - - def count_filtered_entries(self, entries: List[HostEntry], options: FilterOptions) -> Dict[str, int]: + + def count_filtered_entries( + self, entries: List[HostEntry], options: FilterOptions + ) -> Dict[str, int]: """ Count entries by category for the given filter options. - + Args: entries: List of entries to analyze options: Filter options to apply - + Returns: Dictionary with count statistics """ filtered_entries = self.apply_filters(entries, options) total_entries = len(entries) filtered_count = len(filtered_entries) - + # Count by status active_count = len([e for e in filtered_entries if e.is_active]) inactive_count = filtered_count - active_count - + # Count by type dns_count = len([e for e in filtered_entries if e.has_dns_name()]) ip_count = filtered_count - dns_count - + # Count by resolution status - resolved_count = len([e for e in filtered_entries - if e.dns_resolution_status in ["IP_MATCH", "RESOLVED"]]) - unresolved_count = len([e for e in filtered_entries - if e.dns_resolution_status in [None, "NOT_RESOLVED"]]) - resolving_count = len([e for e in filtered_entries - if e.dns_resolution_status == "RESOLVING"]) - failed_count = len([e for e in filtered_entries - if e.dns_resolution_status == "RESOLUTION_FAILED"]) - mismatch_count = len([e for e in filtered_entries - if e.dns_resolution_status == "IP_MISMATCH"]) - + resolved_count = len( + [ + e + for e in filtered_entries + if e.dns_resolution_status in ["IP_MATCH", "RESOLVED"] + ] + ) + unresolved_count = len( + [ + e + for e in filtered_entries + if e.dns_resolution_status in [None, "NOT_RESOLVED"] + ] + ) + resolving_count = len( + [e for e in filtered_entries if e.dns_resolution_status == "RESOLVING"] + ) + failed_count = len( + [ + e + for e in filtered_entries + if e.dns_resolution_status == "RESOLUTION_FAILED" + ] + ) + mismatch_count = len( + [e for e in filtered_entries if e.dns_resolution_status == "IP_MISMATCH"] + ) + return { "total": total_entries, "filtered": filtered_count, @@ -501,5 +534,5 @@ class EntryFilter: "unresolved": unresolved_count, "resolving": resolving_count, "failed": failed_count, - "mismatched": mismatch_count + "mismatched": mismatch_count, } diff --git a/src/hosts/core/import_export.py b/src/hosts/core/import_export.py index 18e918d..ff3caf9 100644 --- a/src/hosts/core/import_export.py +++ b/src/hosts/core/import_export.py @@ -15,85 +15,102 @@ from datetime import datetime from .models import HostEntry, HostsFile + class ExportFormat(Enum): """Supported export formats.""" + HOSTS = "hosts" JSON = "json" CSV = "csv" + class ImportFormat(Enum): """Supported import formats.""" + HOSTS = "hosts" JSON = "json" CSV = "csv" + @dataclass class ImportResult: """Result of an import operation.""" + success: bool entries: List[HostEntry] errors: List[str] warnings: List[str] total_processed: int successfully_imported: int - + @property def has_errors(self) -> bool: """Check if import had any errors.""" return len(self.errors) > 0 - + @property def has_warnings(self) -> bool: """Check if import had any warnings.""" return len(self.warnings) > 0 + @dataclass class ExportResult: """Result of an export operation.""" + success: bool file_path: Path entries_exported: int errors: List[str] format: ExportFormat + class ImportExportService: """Handle multiple file format operations for hosts entries.""" - + def __init__(self): """Initialize the import/export service.""" - self.supported_export_formats = [ExportFormat.HOSTS, ExportFormat.JSON, ExportFormat.CSV] - self.supported_import_formats = [ImportFormat.HOSTS, ImportFormat.JSON, ImportFormat.CSV] - + self.supported_export_formats = [ + ExportFormat.HOSTS, + ExportFormat.JSON, + ExportFormat.CSV, + ] + self.supported_import_formats = [ + ImportFormat.HOSTS, + ImportFormat.JSON, + ImportFormat.CSV, + ] + # Export Methods - + def export_hosts_format(self, hosts_file: HostsFile, path: Path) -> ExportResult: """ Export hosts file to standard hosts format. - + Args: hosts_file: HostsFile instance to export path: Path where to save the exported file - + Returns: ExportResult with operation details """ try: from .parser import HostsParser - + # Use the parser to serialize and write the hosts file parser = HostsParser(str(path)) content = parser.serialize(hosts_file) - + # Write the content to file - with open(path, 'w', encoding='utf-8') as f: + with open(path, "w", encoding="utf-8") as f: f.write(content) - + return ExportResult( success=True, file_path=path, entries_exported=len(hosts_file.entries), errors=[], - format=ExportFormat.HOSTS + format=ExportFormat.HOSTS, ) except Exception as e: return ExportResult( @@ -101,17 +118,17 @@ class ImportExportService: file_path=path, entries_exported=0, errors=[f"Failed to export hosts format: {str(e)}"], - format=ExportFormat.HOSTS + format=ExportFormat.HOSTS, ) - + def export_json_format(self, hosts_file: HostsFile, path: Path) -> ExportResult: """ Export hosts file to JSON format with metadata. - + Args: hosts_file: HostsFile instance to export path: Path where to save the exported file - + Returns: ExportResult with operation details """ @@ -121,19 +138,19 @@ class ImportExportService: "exported_at": datetime.now().isoformat(), "total_entries": len(hosts_file.entries), "version": "1.0", - "format": "hosts_json_export" + "format": "hosts_json_export", }, - "entries": [] + "entries": [], } - + for entry in hosts_file.entries: entry_data = { "ip_address": entry.ip_address, "hostnames": entry.hostnames, "comment": entry.comment, - "is_active": entry.is_active + "is_active": entry.is_active, } - + # Add DNS fields if present if entry.dns_name: entry_data["dns_name"] = entry.dns_name @@ -143,107 +160,115 @@ class ImportExportService: entry_data["last_resolved"] = entry.last_resolved.isoformat() if entry.dns_resolution_status: entry_data["dns_resolution_status"] = entry.dns_resolution_status - + export_data["entries"].append(entry_data) - - with open(path, 'w', encoding='utf-8') as f: + + with open(path, "w", encoding="utf-8") as f: json.dump(export_data, f, indent=2, ensure_ascii=False) - + return ExportResult( success=True, file_path=path, entries_exported=len(hosts_file.entries), errors=[], - format=ExportFormat.JSON + format=ExportFormat.JSON, ) - + except Exception as e: return ExportResult( success=False, file_path=path, entries_exported=0, errors=[f"Failed to export JSON format: {str(e)}"], - format=ExportFormat.JSON + format=ExportFormat.JSON, ) - + def export_csv_format(self, hosts_file: HostsFile, path: Path) -> ExportResult: """ Export hosts file to CSV format. - + Args: hosts_file: HostsFile instance to export path: Path where to save the exported file - + Returns: ExportResult with operation details """ try: fieldnames = [ - 'ip_address', 'hostnames', 'comment', 'is_active', - 'dns_name', 'resolved_ip', 'last_resolved', 'dns_resolution_status' + "ip_address", + "hostnames", + "comment", + "is_active", + "dns_name", + "resolved_ip", + "last_resolved", + "dns_resolution_status", ] - - with open(path, 'w', newline='', encoding='utf-8') as csvfile: + + with open(path, "w", newline="", encoding="utf-8") as csvfile: writer = csv.DictWriter(csvfile, fieldnames=fieldnames) writer.writeheader() - + for entry in hosts_file.entries: row_data = { - 'ip_address': entry.ip_address, - 'hostnames': ' '.join(entry.hostnames), - 'comment': entry.comment or '', - 'is_active': entry.is_active, - 'dns_name': entry.dns_name or '', - 'resolved_ip': entry.resolved_ip or '', - 'last_resolved': entry.last_resolved.isoformat() if entry.last_resolved else '', - 'dns_resolution_status': entry.dns_resolution_status or '' + "ip_address": entry.ip_address, + "hostnames": " ".join(entry.hostnames), + "comment": entry.comment or "", + "is_active": entry.is_active, + "dns_name": entry.dns_name or "", + "resolved_ip": entry.resolved_ip or "", + "last_resolved": entry.last_resolved.isoformat() + if entry.last_resolved + else "", + "dns_resolution_status": entry.dns_resolution_status or "", } writer.writerow(row_data) - + return ExportResult( success=True, file_path=path, entries_exported=len(hosts_file.entries), errors=[], - format=ExportFormat.CSV + format=ExportFormat.CSV, ) - + except Exception as e: return ExportResult( success=False, file_path=path, entries_exported=0, errors=[f"Failed to export CSV format: {str(e)}"], - format=ExportFormat.CSV + format=ExportFormat.CSV, ) - + # Import Methods - + def import_hosts_format(self, path: Path) -> ImportResult: """ Import from hosts file format. - + Args: path: Path to the hosts file to import - + Returns: ImportResult with imported entries and any errors """ try: from .parser import HostsParser - + parser = HostsParser(str(path)) hosts_file = parser.parse() - + return ImportResult( success=True, entries=hosts_file.entries, errors=[], warnings=[], total_processed=len(hosts_file.entries), - successfully_imported=len(hosts_file.entries) + successfully_imported=len(hosts_file.entries), ) - + except Exception as e: return ImportResult( success=False, @@ -251,61 +276,61 @@ class ImportExportService: errors=[f"Failed to import hosts format: {str(e)}"], warnings=[], total_processed=0, - successfully_imported=0 + successfully_imported=0, ) - + def import_json_format(self, path: Path) -> ImportResult: """ Import from JSON format with validation. - + Args: path: Path to the JSON file to import - + Returns: ImportResult with imported entries and any errors """ try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: data = json.load(f) - - if not isinstance(data, dict) or 'entries' not in data: + + if not isinstance(data, dict) or "entries" not in data: return ImportResult( success=False, entries=[], errors=["Invalid JSON format: missing 'entries' field"], warnings=[], total_processed=0, - successfully_imported=0 + successfully_imported=0, ) - + entries = [] errors = [] warnings = [] - total_processed = len(data['entries']) - - for i, entry_data in enumerate(data['entries']): + total_processed = len(data["entries"]) + + for i, entry_data in enumerate(data["entries"]): try: # Validate required fields if not isinstance(entry_data, dict): - errors.append(f"Entry {i+1}: Invalid entry format") + errors.append(f"Entry {i + 1}: Invalid entry format") continue - - if 'hostnames' not in entry_data or not entry_data['hostnames']: - errors.append(f"Entry {i+1}: Missing hostnames field") + + if "hostnames" not in entry_data or not entry_data["hostnames"]: + errors.append(f"Entry {i + 1}: Missing hostnames field") continue - + # Handle DNS vs IP entries - dns_name = entry_data.get('dns_name', '') - ip_address = entry_data.get('ip_address', '') - + dns_name = entry_data.get("dns_name", "") + ip_address = entry_data.get("ip_address", "") + # Create entry with temporary IP if it's a DNS-only entry if dns_name and not ip_address: # Create with temporary IP, then convert to DNS entry entry = HostEntry( ip_address="127.0.0.1", # Temporary IP - hostnames=entry_data['hostnames'], - comment=entry_data.get('comment', ''), - is_active=entry_data.get('is_active', True) + hostnames=entry_data["hostnames"], + comment=entry_data.get("comment", ""), + is_active=entry_data.get("is_active", True), ) # Convert to DNS entry entry.ip_address = "" @@ -314,39 +339,45 @@ class ImportExportService: # Regular IP entry entry = HostEntry( ip_address=ip_address, - hostnames=entry_data['hostnames'], - comment=entry_data.get('comment', ''), - is_active=entry_data.get('is_active', True) + hostnames=entry_data["hostnames"], + comment=entry_data.get("comment", ""), + is_active=entry_data.get("is_active", True), ) # Set DNS name if present for IP entries if dns_name: entry.dns_name = dns_name - if 'resolved_ip' in entry_data: - entry.resolved_ip = entry_data['resolved_ip'] - if 'last_resolved' in entry_data and entry_data['last_resolved']: + if "resolved_ip" in entry_data: + entry.resolved_ip = entry_data["resolved_ip"] + if "last_resolved" in entry_data and entry_data["last_resolved"]: try: - entry.last_resolved = datetime.fromisoformat(entry_data['last_resolved']) + entry.last_resolved = datetime.fromisoformat( + entry_data["last_resolved"] + ) except ValueError: - warnings.append(f"Entry {i+1}: Invalid last_resolved date format") - if 'dns_resolution_status' in entry_data: - entry.dns_resolution_status = entry_data['dns_resolution_status'] - + warnings.append( + f"Entry {i + 1}: Invalid last_resolved date format" + ) + if "dns_resolution_status" in entry_data: + entry.dns_resolution_status = entry_data[ + "dns_resolution_status" + ] + entries.append(entry) - + except ValueError as e: - errors.append(f"Entry {i+1}: {str(e)}") + errors.append(f"Entry {i + 1}: {str(e)}") except Exception as e: - errors.append(f"Entry {i+1}: Unexpected error - {str(e)}") - + errors.append(f"Entry {i + 1}: Unexpected error - {str(e)}") + return ImportResult( success=len(errors) == 0, entries=entries, errors=errors, warnings=warnings, total_processed=total_processed, - successfully_imported=len(entries) + successfully_imported=len(entries), ) - + except json.JSONDecodeError as e: return ImportResult( success=False, @@ -354,7 +385,7 @@ class ImportExportService: errors=[f"Invalid JSON file: {str(e)}"], warnings=[], total_processed=0, - successfully_imported=0 + successfully_imported=0, ) except Exception as e: return ImportResult( @@ -363,16 +394,16 @@ class ImportExportService: errors=[f"Failed to import JSON format: {str(e)}"], warnings=[], total_processed=0, - successfully_imported=0 + successfully_imported=0, ) - + def import_csv_format(self, path: Path) -> ImportResult: """ Import from CSV format with field mapping. - + Args: path: Path to the CSV file to import - + Returns: ImportResult with imported entries and any errors """ @@ -381,18 +412,20 @@ class ImportExportService: errors = [] warnings = [] total_processed = 0 - - with open(path, 'r', encoding='utf-8') as csvfile: + + with open(path, "r", encoding="utf-8") as csvfile: # Try to detect the dialect sample = csvfile.read(1024) csvfile.seek(0) dialect = csv.Sniffer().sniff(sample) - + reader = csv.DictReader(csvfile, dialect=dialect) - + # Validate required columns - required_columns = ['hostnames'] - missing_columns = [col for col in required_columns if col not in reader.fieldnames] + required_columns = ["hostnames"] + missing_columns = [ + col for col in required_columns if col not in reader.fieldnames + ] if missing_columns: return ImportResult( success=False, @@ -400,39 +433,39 @@ class ImportExportService: errors=[f"Missing required columns: {missing_columns}"], warnings=[], total_processed=0, - successfully_imported=0 + successfully_imported=0, ) - + for row_num, row in enumerate(reader, start=2): # Start at 2 for header total_processed += 1 try: # Parse hostnames - hostnames_str = row.get('hostnames', '').strip() + hostnames_str = row.get("hostnames", "").strip() if not hostnames_str: errors.append(f"Row {row_num}: Empty hostnames field") continue - + hostnames = [h.strip() for h in hostnames_str.split()] if not hostnames: errors.append(f"Row {row_num}: No valid hostnames found") continue - + # Parse is_active - is_active_str = row.get('is_active', 'true').lower() - is_active = is_active_str in ('true', '1', 'yes', 'active') - + is_active_str = row.get("is_active", "true").lower() + is_active = is_active_str in ("true", "1", "yes", "active") + # Handle DNS vs IP entries - dns_name = row.get('dns_name', '').strip() - ip_address = row.get('ip_address', '').strip() - + dns_name = row.get("dns_name", "").strip() + ip_address = row.get("ip_address", "").strip() + # Create entry with temporary IP if it's a DNS-only entry if dns_name and not ip_address: # Create with temporary IP, then convert to DNS entry entry = HostEntry( ip_address="127.0.0.1", # Temporary IP hostnames=hostnames, - comment=row.get('comment', '').strip(), - is_active=is_active + comment=row.get("comment", "").strip(), + is_active=is_active, ) # Convert to DNS entry entry.ip_address = "" @@ -442,38 +475,44 @@ class ImportExportService: entry = HostEntry( ip_address=ip_address, hostnames=hostnames, - comment=row.get('comment', '').strip(), - is_active=is_active + comment=row.get("comment", "").strip(), + is_active=is_active, ) # Set DNS name if present for IP entries if dns_name: entry.dns_name = dns_name - if row.get('resolved_ip', '').strip(): - entry.resolved_ip = row['resolved_ip'].strip() - if row.get('last_resolved', '').strip(): + if row.get("resolved_ip", "").strip(): + entry.resolved_ip = row["resolved_ip"].strip() + if row.get("last_resolved", "").strip(): try: - entry.last_resolved = datetime.fromisoformat(row['last_resolved'].strip()) + entry.last_resolved = datetime.fromisoformat( + row["last_resolved"].strip() + ) except ValueError: - warnings.append(f"Row {row_num}: Invalid last_resolved date format") - if row.get('dns_resolution_status', '').strip(): - entry.dns_resolution_status = row['dns_resolution_status'].strip() - + warnings.append( + f"Row {row_num}: Invalid last_resolved date format" + ) + if row.get("dns_resolution_status", "").strip(): + entry.dns_resolution_status = row[ + "dns_resolution_status" + ].strip() + entries.append(entry) - + except ValueError as e: errors.append(f"Row {row_num}: {str(e)}") except Exception as e: errors.append(f"Row {row_num}: Unexpected error - {str(e)}") - + return ImportResult( success=len(errors) == 0, entries=entries, errors=errors, warnings=warnings, total_processed=total_processed, - successfully_imported=len(entries) + successfully_imported=len(entries), ) - + except Exception as e: return ImportResult( success=False, @@ -481,99 +520,103 @@ class ImportExportService: errors=[f"Failed to import CSV format: {str(e)}"], warnings=[], total_processed=0, - successfully_imported=0 + successfully_imported=0, ) - + # Utility Methods - + def detect_file_format(self, path: Path) -> Optional[ImportFormat]: """ Detect the format of a file based on extension and content. - + Args: path: Path to the file to analyze - + Returns: Detected ImportFormat or None if unknown """ if not path.exists(): return None - + # Check by extension first extension = path.suffix.lower() - if extension == '.json': + if extension == ".json": return ImportFormat.JSON - elif extension == '.csv': + elif extension == ".csv": return ImportFormat.CSV - elif path.name in ['hosts', '/etc/hosts'] or extension in ['.hosts', '.txt']: + elif path.name in ["hosts", "/etc/hosts"] or extension in [".hosts", ".txt"]: return ImportFormat.HOSTS - + # Try to detect by content try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: first_line = f.readline().strip() - + # Check for JSON - if first_line.startswith('{'): + if first_line.startswith("{"): return ImportFormat.JSON - + # Check for CSV (look for comma separators) - if ',' in first_line and not first_line.startswith('#'): + if "," in first_line and not first_line.startswith("#"): return ImportFormat.CSV - + # Default to hosts format return ImportFormat.HOSTS - + except Exception: return None - + def validate_export_path(self, path: Path, format: ExportFormat) -> List[str]: """ Validate export path and return any warnings. - + Args: path: Target export path format: Export format - + Returns: List of validation warnings """ warnings = [] - + # Check if file already exists if path.exists(): warnings.append(f"File {path} already exists and will be overwritten") - + # Check if directory exists if not path.parent.exists(): warnings.append(f"Directory {path.parent} does not exist") - + # Check write permissions try: path.parent.mkdir(parents=True, exist_ok=True) - test_file = path.parent / '.write_test' + test_file = path.parent / ".write_test" test_file.touch() test_file.unlink() except Exception: warnings.append(f"No write permission for directory {path.parent}") - + # Check extension matches format expected_extensions = { - ExportFormat.HOSTS: ['.hosts', '.txt', ''], - ExportFormat.JSON: ['.json'], - ExportFormat.CSV: ['.csv'] + ExportFormat.HOSTS: [".hosts", ".txt", ""], + ExportFormat.JSON: [".json"], + ExportFormat.CSV: [".csv"], } - + if path.suffix.lower() not in expected_extensions[format]: - suggested_ext = expected_extensions[format][0] if expected_extensions[format] else '' - warnings.append(f"File extension '{path.suffix}' doesn't match format {format.value}{f', suggest {suggested_ext}' if suggested_ext else ''}") - + suggested_ext = ( + expected_extensions[format][0] if expected_extensions[format] else "" + ) + warnings.append( + f"File extension '{path.suffix}' doesn't match format {format.value}{f', suggest {suggested_ext}' if suggested_ext else ''}" + ) + return warnings - + def get_supported_export_formats(self) -> List[ExportFormat]: """Get list of supported export formats.""" return self.supported_export_formats.copy() - + def get_supported_import_formats(self) -> List[ImportFormat]: """Get list of supported import formats.""" return self.supported_import_formats.copy() diff --git a/src/hosts/core/manager.py b/src/hosts/core/manager.py index 7a1142e..4ccbdc0 100644 --- a/src/hosts/core/manager.py +++ b/src/hosts/core/manager.py @@ -22,6 +22,7 @@ from .commands import ( OperationResult, ) + class PermissionManager: """ Manages sudo permissions for hosts file editing. @@ -120,6 +121,7 @@ class PermissionManager: self.has_sudo = False self._sudo_validated = False + class HostsManager: """ Main manager for hosts file edit operations. @@ -422,7 +424,9 @@ class HostsManager: return False, f"Error updating entry: {e}" # Command-based methods for undo/redo functionality - def execute_toggle_command(self, hosts_file: HostsFile, index: int) -> OperationResult: + def execute_toggle_command( + self, hosts_file: HostsFile, index: int + ) -> OperationResult: """ Execute a toggle command with undo/redo support. @@ -440,7 +444,9 @@ class HostsManager: result = self.undo_redo_history.execute_command(command, hosts_file) return result - def execute_move_command(self, hosts_file: HostsFile, index: int, direction: str) -> OperationResult: + def execute_move_command( + self, hosts_file: HostsFile, index: int, direction: str + ) -> OperationResult: """ Execute a move command with undo/redo support. @@ -471,7 +477,9 @@ class HostsManager: result = self.undo_redo_history.execute_command(command, hosts_file) return result - def execute_add_command(self, hosts_file: HostsFile, entry: HostEntry, save_callback=None) -> OperationResult: + def execute_add_command( + self, hosts_file: HostsFile, entry: HostEntry, save_callback=None + ) -> OperationResult: """ Execute an add command with undo/redo support. @@ -490,7 +498,9 @@ class HostsManager: result = self.undo_redo_history.execute_command(command, hosts_file) return result - def execute_delete_command(self, hosts_file: HostsFile, index: int, save_callback=None) -> OperationResult: + def execute_delete_command( + self, hosts_file: HostsFile, index: int, save_callback=None + ) -> OperationResult: """ Execute a delete command with undo/redo support. @@ -509,7 +519,15 @@ class HostsManager: result = self.undo_redo_history.execute_command(command, hosts_file) return result - def execute_update_command(self, hosts_file: HostsFile, index: int, ip_address: str, hostnames: list[str], comment: Optional[str], is_active: bool) -> OperationResult: + def execute_update_command( + self, + hosts_file: HostsFile, + index: int, + ip_address: str, + hostnames: list[str], + comment: Optional[str], + is_active: bool, + ) -> OperationResult: """ Execute an update command with undo/redo support. @@ -686,16 +704,19 @@ class HostsManager: ["sudo", "chmod", "644", str(self._backup_path)], capture_output=True ) + class EditModeError(Exception): """Base exception for edit mode errors.""" pass + class PermissionError(EditModeError): """Raised when there are permission issues.""" pass + class ValidationError(EditModeError): """Raised when validation fails.""" diff --git a/src/hosts/core/models.py b/src/hosts/core/models.py index 09aa755..cfcc24b 100644 --- a/src/hosts/core/models.py +++ b/src/hosts/core/models.py @@ -126,7 +126,9 @@ class HostEntry: try: ipaddress.ip_address(self.resolved_ip) except ValueError as e: - raise ValueError(f"Invalid resolved IP address '{self.resolved_ip}': {e}") + raise ValueError( + f"Invalid resolved IP address '{self.resolved_ip}': {e}" + ) def to_hosts_line(self, ip_width: int = 0, hostname_width: int = 0) -> str: """ @@ -168,7 +170,7 @@ class HostEntry: # Build comment section (DNS metadata + user comment) comment_parts = [] - + # Add DNS metadata if present if self.has_dns_name(): dns_meta = f"DNS:{self.dns_name}" @@ -177,11 +179,11 @@ class HostEntry: if self.last_resolved: dns_meta += f"|Last:{self.last_resolved.isoformat()}" comment_parts.append(dns_meta) - + # Add user comment if present if self.comment: comment_parts.append(self.comment) - + # Add complete comment section if comment_parts: if len(self.hostnames) <= 1: @@ -269,21 +271,26 @@ class HostEntry: if comment: # Split comment by pipe (|) to separate DNS metadata from user comment - comment_parts = [part.strip() for part in comment.split(' | ')] - + comment_parts = [part.strip() for part in comment.split(" | ")] + for part in comment_parts: - if part.startswith('DNS:'): + if part.startswith("DNS:"): # Parse DNS metadata: "DNS:example.com|Status:resolved|Last:2023-..." - dns_data = part.split('|') + dns_data = part.split("|") for dns_part in dns_data: - if dns_part.startswith('DNS:'): + if dns_part.startswith("DNS:"): dns_name = dns_part[4:] # Remove "DNS:" prefix - elif dns_part.startswith('Status:'): - dns_resolution_status = dns_part[7:] # Remove "Status:" prefix - elif dns_part.startswith('Last:'): + elif dns_part.startswith("Status:"): + dns_resolution_status = dns_part[ + 7: + ] # Remove "Status:" prefix + elif dns_part.startswith("Last:"): try: from datetime import datetime - last_resolved = datetime.fromisoformat(dns_part[5:]) # Remove "Last:" prefix + + last_resolved = datetime.fromisoformat( + dns_part[5:] + ) # Remove "Last:" prefix except (ValueError, TypeError): pass # Invalid datetime format, ignore else: @@ -360,7 +367,11 @@ class HostsFile: def get_stale_dns_entries(self, max_age_seconds: int = 300) -> List[HostEntry]: """Get all entries with stale DNS resolution.""" - return [entry for entry in self.entries if entry.has_dns_name() and entry.is_dns_resolution_stale(max_age_seconds)] + return [ + entry + for entry in self.entries + if entry.has_dns_name() and entry.is_dns_resolution_stale(max_age_seconds) + ] def sort_by_ip(self, ascending: bool = True) -> None: """ diff --git a/src/hosts/tui/add_entry_modal.py b/src/hosts/tui/add_entry_modal.py index 8c636c7..d626a8c 100644 --- a/src/hosts/tui/add_entry_modal.py +++ b/src/hosts/tui/add_entry_modal.py @@ -40,7 +40,9 @@ class AddEntryModal(ModalScreen): with Vertical(classes="default-flex-section") as entry_type: entry_type.border_title = "Entry Type" with RadioSet(id="entry-type-radio", classes="default-radio-set"): - yield RadioButton("IP Address Entry", value=True, id="ip-entry-radio") + yield RadioButton( + "IP Address Entry", value=True, id="ip-entry-radio" + ) yield RadioButton("DNS Name Entry", id="dns-entry-radio") # IP Address Section @@ -54,7 +56,9 @@ class AddEntryModal(ModalScreen): yield Static("", id="ip-error", classes="validation-error") # DNS Name Section (initially hidden) - with Vertical(classes="default-section hidden", id="dns-section") as dns_name: + with Vertical( + classes="default-section hidden", id="dns-section" + ) as dns_name: dns_name.border_title = "DNS Name (to resolve)" yield Input( placeholder="e.g., example.com", @@ -119,17 +123,17 @@ class AddEntryModal(ModalScreen): if pressed_radio and pressed_radio.id == "ip-entry-radio": # Show IP section, hide DNS section ip_section = self.query_one("#ip-section") - dns_section = self.query_one("#dns-section") + dns_section = self.query_one("#dns-section") active_checkbox = self.query_one("#active-checkbox", Checkbox) active_section = self.query_one("#active-checkbox").parent - + ip_section.remove_class("hidden") dns_section.add_class("hidden") - + # Reset checkbox to default (active) for IP entries active_checkbox.value = True active_section.border_title = "Activate Entry" - + # Focus IP input ip_input = self.query_one("#ip-address-input", Input) ip_input.focus() @@ -139,14 +143,16 @@ class AddEntryModal(ModalScreen): dns_section = self.query_one("#dns-section") active_checkbox = self.query_one("#active-checkbox", Checkbox) active_section = self.query_one("#active-checkbox").parent - + ip_section.add_class("hidden") dns_section.remove_class("hidden") - + # Set checkbox to inactive for DNS entries (will be activated after resolution) active_checkbox.value = False - active_section.border_title = "Activate Entry (DNS entries activate after resolution)" - + active_section.border_title = ( + "Activate Entry (DNS entries activate after resolution)" + ) + # Focus DNS input dns_input = self.query_one("#dns-name-input", Input) dns_input.focus() @@ -165,7 +171,10 @@ class AddEntryModal(ModalScreen): # Determine entry type radio_set = self.query_one("#entry-type-radio", RadioSet) - is_dns_entry = radio_set.pressed_button and radio_set.pressed_button.id == "dns-entry-radio" + is_dns_entry = ( + radio_set.pressed_button + and radio_set.pressed_button.id == "dns-entry-radio" + ) # Get form values ip_address = self.query_one("#ip-address-input", Input).value.strip() @@ -193,14 +202,15 @@ class AddEntryModal(ModalScreen): ) # Add DNS name field new_entry.dns_name = dns_name - + # Add resolution status fields if they don't exist - if not hasattr(new_entry, 'resolved_ip'): + if not hasattr(new_entry, "resolved_ip"): new_entry.resolved_ip = None - if not hasattr(new_entry, 'last_resolved'): + if not hasattr(new_entry, "last_resolved"): new_entry.last_resolved = None - if not hasattr(new_entry, 'dns_resolution_status'): + if not hasattr(new_entry, "dns_resolution_status"): from ..core.dns import DNSResolutionStatus + new_entry.dns_resolution_status = DNSResolutionStatus.NOT_RESOLVED else: # IP entry @@ -227,7 +237,9 @@ class AddEntryModal(ModalScreen): """Cancel entry creation and close modal.""" self.dismiss(None) - def _validate_input(self, ip_address: str, dns_name: str, hostnames_str: str, is_dns_entry: bool) -> bool: + def _validate_input( + self, ip_address: str, dns_name: str, hostnames_str: str, is_dns_entry: bool + ) -> bool: """ Validate user input. diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 584c5f7..71780a0 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -7,7 +7,15 @@ all the handlers and provides the primary user interface. from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical -from textual.widgets import Header, Static, DataTable, Input, Checkbox, RadioSet, RadioButton +from textual.widgets import ( + Header, + Static, + DataTable, + Input, + Checkbox, + RadioSet, + RadioButton, +) from textual.reactive import reactive from ..core.parser import HostsParser @@ -66,7 +74,7 @@ class HostsManagerApp(App): dns_config = self.config.get("dns_resolution", {}) self.dns_service = DNSService( enabled=dns_config.get("enabled", True), - timeout=dns_config.get("timeout", 5.0) + timeout=dns_config.get("timeout", 5.0), ) # Initialize filtering system @@ -176,14 +184,24 @@ class HostsManagerApp(App): # Edit form (initially hidden) with Vertical(id="entry-edit-form", classes="entry-form hidden"): # Entry Type Selection - with Vertical(classes="default-flex-section section-no-top-margin") as entry_type: + with Vertical( + classes="default-flex-section section-no-top-margin" + ) as entry_type: entry_type.border_title = "Entry Type" - with RadioSet(id="edit-entry-type-radio", classes="default-radio-set"): - yield RadioButton("IP Address Entry", value=True, id="edit-ip-entry-radio") - yield RadioButton("DNS Name Entry", id="edit-dns-entry-radio") + with RadioSet( + id="edit-entry-type-radio", classes="default-radio-set" + ): + yield RadioButton( + "IP Address Entry", value=True, id="edit-ip-entry-radio" + ) + yield RadioButton( + "DNS Name Entry", id="edit-dns-entry-radio" + ) # IP Address Section - with Vertical(classes="default-section", id="edit-ip-section") as ip_address: + with Vertical( + classes="default-section", id="edit-ip-section" + ) as ip_address: ip_address.border_title = "IP Address" yield Input( placeholder="Enter IP address", @@ -192,7 +210,9 @@ class HostsManagerApp(App): ) # DNS Name Section (initially hidden) - with Vertical(classes="default-section hidden", id="edit-dns-section") as dns_name: + with Vertical( + classes="default-section hidden", id="edit-dns-section" + ) as dns_name: dns_name.border_title = "DNS Name (to resolve)" yield Input( placeholder="e.g., example.com", @@ -388,7 +408,9 @@ class HostsManagerApp(App): # Update search term and filter entries self.search_term = event.value.strip() # Also update the current filter options to keep them synchronized - self.current_filter_options.search_term = self.search_term if self.search_term else None + self.current_filter_options.search_term = ( + self.search_term if self.search_term else None + ) self.table_handler.populate_entries_table() self.details_handler.update_entry_details() else: @@ -404,7 +426,10 @@ class HostsManagerApp(App): def on_radio_set_changed(self, event) -> None: """Handle entry type radio button changes in edit mode.""" - if hasattr(event, 'radio_set') and event.radio_set.id == "edit-entry-type-radio": + if ( + hasattr(event, "radio_set") + and event.radio_set.id == "edit-entry-type-radio" + ): pressed_radio = event.pressed if pressed_radio and pressed_radio.id == "edit-ip-entry-radio": # Handle switch to IP entry type @@ -526,7 +551,7 @@ class HostsManagerApp(App): "hostnames": entry.hostnames.copy(), "comment": entry.comment, "is_active": entry.is_active, - "dns_name": getattr(entry, 'dns_name', None), + "dns_name": getattr(entry, "dns_name", None), } self.entry_edit_mode = True @@ -584,21 +609,27 @@ class HostsManagerApp(App): result = self.manager.execute_add_command(self.hosts_file, new_entry) if result.success: # Save the changes - save_success, save_message = self.manager.save_hosts_file(self.hosts_file) + save_success, save_message = self.manager.save_hosts_file( + self.hosts_file + ) if save_success: # Refresh the table self.table_handler.populate_entries_table() # Move cursor to the newly added entry (last entry) self.selected_entry_index = len(self.hosts_file.entries) - 1 self.table_handler.restore_cursor_position(new_entry) - + # For DNS entries, trigger resolution and provide feedback - if hasattr(new_entry, 'dns_name') and new_entry.dns_name: - self.update_status(f"✅ {result.message} - Starting DNS resolution for {new_entry.dns_name}") + if hasattr(new_entry, "dns_name") and new_entry.dns_name: + self.update_status( + f"✅ {result.message} - Starting DNS resolution for {new_entry.dns_name}" + ) # Trigger DNS resolution in background self._resolve_new_dns_entry(new_entry) else: - self.update_status(f"✅ {result.message} - Changes saved automatically") + self.update_status( + f"✅ {result.message} - Changes saved automatically" + ) else: self.update_status(f"Entry added but save failed: {save_message}") else: @@ -638,16 +669,22 @@ class HostsManagerApp(App): ) if result.success: # Save the changes - save_success, save_message = self.manager.save_hosts_file(self.hosts_file) + save_success, save_message = self.manager.save_hosts_file( + self.hosts_file + ) if save_success: # Adjust selected index if needed if self.selected_entry_index >= len(self.hosts_file.entries): - self.selected_entry_index = max(0, len(self.hosts_file.entries) - 1) + self.selected_entry_index = max( + 0, len(self.hosts_file.entries) - 1 + ) # Refresh the table self.table_handler.populate_entries_table() self.details_handler.update_entry_details() - self.update_status(f"✅ {result.message} - Changes saved automatically") + self.update_status( + f"✅ {result.message} - Changes saved automatically" + ) else: self.update_status(f"Entry deleted but save failed: {save_message}") else: @@ -671,7 +708,7 @@ class HostsManagerApp(App): # Get description before undoing description = self.manager.get_undo_description() - + # Perform undo result = self.manager.undo_last_operation(self.hosts_file) if result.success: @@ -694,7 +731,7 @@ class HostsManagerApp(App): # Get description before redoing description = self.manager.get_redo_description() - + # Perform redo result = self.manager.redo_last_operation(self.hosts_file) if result.success: @@ -734,25 +771,25 @@ class HostsManagerApp(App): try: # Extract DNS names (not hostnames!) from entries dns_names = [entry.dns_name for entry in dns_entries if entry.dns_name] - + if not dns_names: self.update_status("No valid DNS names found to resolve") return - + resolved_count = 0 failed_count = 0 - + # Resolve each DNS name and apply results back to entries for dns_name in dns_names: resolution = await self.dns_service.resolve_entry_async(dns_name) - + # Find the corresponding entry and update it for entry in dns_entries: if entry.dns_name == dns_name: # Apply resolution results to entry fields entry.last_resolved = resolution.resolved_at entry.dns_resolution_status = resolution.status.value - + if resolution.is_success(): # Update both resolved_ip and ip_address for the hosts file entry.ip_address = resolution.resolved_ip @@ -761,27 +798,37 @@ class HostsManagerApp(App): else: failed_count += 1 break - + # Save hosts file with updated DNS information if resolved_count > 0 or failed_count > 0: - save_success, save_message = self.manager.save_hosts_file(self.hosts_file) + save_success, save_message = self.manager.save_hosts_file( + self.hosts_file + ) if not save_success: - self.update_status(f"❌ DNS resolution completed but save failed: {save_message}") + self.update_status( + f"❌ DNS resolution completed but save failed: {save_message}" + ) return - + # Update the UI and restore cursor position self.table_handler.populate_entries_table() self.table_handler.restore_cursor_position(current_entry) self.details_handler.update_entry_details() - + # Provide detailed status message if failed_count == 0: - self.update_status(f"✅ DNS resolution completed for {resolved_count} entries") + self.update_status( + f"✅ DNS resolution completed for {resolved_count} entries" + ) elif resolved_count == 0: - self.update_status(f"❌ DNS resolution failed for all {failed_count} entries") + self.update_status( + f"❌ DNS resolution failed for all {failed_count} entries" + ) else: - self.update_status(f"⚠️ DNS resolution: {resolved_count} succeeded, {failed_count} failed") - + self.update_status( + f"⚠️ DNS resolution: {resolved_count} succeeded, {failed_count} failed" + ) + except Exception as e: self.update_status(f"❌ DNS resolution failed: {e}") @@ -806,9 +853,9 @@ class HostsManagerApp(App): return entry = self.hosts_file.entries[self.selected_entry_index] - + # Check if the entry has a DNS name to resolve - if not hasattr(entry, 'dns_name') or not entry.dns_name: + if not hasattr(entry, "dns_name") or not entry.dns_name: self.update_status("❌ Selected entry has no DNS name to resolve") return @@ -818,43 +865,53 @@ class HostsManagerApp(App): async def update_single_dns(): try: dns_name = entry.dns_name - + # Resolve the DNS name resolution = await self.dns_service.resolve_entry_async(dns_name) - + # Apply resolution results to entry fields entry.last_resolved = resolution.resolved_at entry.dns_resolution_status = resolution.status.value - + if resolution.is_success(): # Update both resolved_ip and ip_address for the hosts file entry.ip_address = resolution.resolved_ip entry.resolved_ip = resolution.resolved_ip - + # Save hosts file with updated DNS information - save_success, save_message = self.manager.save_hosts_file(self.hosts_file) + save_success, save_message = self.manager.save_hosts_file( + self.hosts_file + ) if not save_success: - self.update_status(f"❌ DNS resolution completed but save failed: {save_message}") + self.update_status( + f"❌ DNS resolution completed but save failed: {save_message}" + ) return - + # Update the UI and restore cursor position self.table_handler.populate_entries_table() self.table_handler.restore_cursor_position(current_entry) self.details_handler.update_entry_details() - - self.update_status(f"✅ DNS updated: {dns_name} → {resolution.resolved_ip}") + + self.update_status( + f"✅ DNS updated: {dns_name} → {resolution.resolved_ip}" + ) else: # Resolution failed, save the status update - save_success, save_message = self.manager.save_hosts_file(self.hosts_file) + save_success, save_message = self.manager.save_hosts_file( + self.hosts_file + ) if save_success: # Update the UI to show failed status and restore cursor position self.table_handler.populate_entries_table() self.table_handler.restore_cursor_position(current_entry) self.details_handler.update_entry_details() - + error_msg = resolution.error_message or "Unknown error" - self.update_status(f"❌ DNS resolution failed for {dns_name}: {error_msg}") - + self.update_status( + f"❌ DNS resolution failed for {dns_name}: {error_msg}" + ) + except Exception as e: self.update_status(f"❌ DNS resolution error: {e}") @@ -864,6 +921,7 @@ class HostsManagerApp(App): 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 @@ -872,7 +930,7 @@ class HostsManagerApp(App): # 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 @@ -894,72 +952,92 @@ class HostsManagerApp(App): # 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}") + 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 + entry_filter=self.entry_filter, ), - handle_filter_result + handle_filter_result, ) def _resolve_new_dns_entry(self, entry) -> None: """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: return - + async def resolve_and_activate(): try: # Resolve the DNS name resolution = await self.dns_service.resolve_entry_async(entry.dns_name) - + if resolution.is_success(): # Find the entry in the hosts file and update it for hosts_entry in self.hosts_file.entries: - if (hasattr(hosts_entry, 'dns_name') and - hosts_entry.dns_name == entry.dns_name and - hosts_entry.hostnames == entry.hostnames): - + if ( + hasattr(hosts_entry, "dns_name") + and hosts_entry.dns_name == entry.dns_name + and hosts_entry.hostnames == entry.hostnames + ): # Update the entry with resolved IP hosts_entry.ip_address = resolution.resolved_ip hosts_entry.resolved_ip = resolution.resolved_ip hosts_entry.last_resolved = resolution.resolved_at hosts_entry.dns_resolution_status = resolution.status.value hosts_entry.is_active = True # Activate the entry - + # Save the updated hosts file - save_success, save_message = self.manager.save_hosts_file(self.hosts_file) + save_success, save_message = self.manager.save_hosts_file( + self.hosts_file + ) if save_success: # Update UI - use direct calls since we're in the same async context self.table_handler.populate_entries_table() self.details_handler.update_entry_details() - self.update_status(f"✅ DNS resolved: {entry.dns_name} → {resolution.resolved_ip} (entry activated)") + self.update_status( + f"✅ DNS resolved: {entry.dns_name} → {resolution.resolved_ip} (entry activated)" + ) else: - self.update_status(f"❌ DNS resolved but save failed: {save_message}") + self.update_status( + f"❌ DNS resolved but save failed: {save_message}" + ) break else: # Resolution failed, update status but keep entry inactive for hosts_entry in self.hosts_file.entries: - if (hasattr(hosts_entry, 'dns_name') and - hosts_entry.dns_name == entry.dns_name and - hosts_entry.hostnames == entry.hostnames): - + if ( + hasattr(hosts_entry, "dns_name") + and hosts_entry.dns_name == entry.dns_name + and hosts_entry.hostnames == entry.hostnames + ): hosts_entry.dns_resolution_status = resolution.status.value hosts_entry.last_resolved = resolution.resolved_at break - - self.update_status(f"❌ DNS resolution failed for {entry.dns_name}: {resolution.error_message or 'Unknown error'}") - + + self.update_status( + f"❌ DNS resolution failed for {entry.dns_name}: {resolution.error_message or 'Unknown error'}" + ) + except Exception as e: - self.update_status(f"❌ DNS resolution error for {entry.dns_name}: {str(e)}") - + self.update_status( + f"❌ DNS resolution error for {entry.dns_name}: {str(e)}" + ) + # Start the resolution in background self.run_worker(resolve_and_activate(), exclusive=False) diff --git a/src/hosts/tui/details_handler.py b/src/hosts/tui/details_handler.py index 12023ff..6a2b63b 100644 --- a/src/hosts/tui/details_handler.py +++ b/src/hosts/tui/details_handler.py @@ -138,8 +138,10 @@ class DetailsHandler: # Get the three separate DNS input fields dns_name_input = self.app.query_one("#details-dns-name-input", Input) dns_status_input = self.app.query_one("#details-dns-status-input", Input) - dns_resolved_input = self.app.query_one("#details-dns-resolved-input", Input) - + dns_resolved_input = self.app.query_one( + "#details-dns-resolved-input", Input + ) + if not entry.has_dns_name(): # Clear all DNS fields if no DNS information dns_name_input.value = "" @@ -162,13 +164,17 @@ class DetailsHandler: "resolved": "Resolved", "failed": "Resolution failed", "match": "IP matches DNS", - "mismatch": "IP differs from DNS" + "mismatch": "IP differs from DNS", }.get(entry.dns_resolution_status, entry.dns_resolution_status) - + # Add resolved IP to status if available - if entry.resolved_ip and entry.dns_resolution_status in ["resolved", "match", "mismatch"]: + if entry.resolved_ip and entry.dns_resolution_status in [ + "resolved", + "match", + "mismatch", + ]: status_text += f" ({entry.resolved_ip})" - + dns_status_input.value = status_text dns_status_input.placeholder = "" else: diff --git a/src/hosts/tui/dns_status_widget.py b/src/hosts/tui/dns_status_widget.py index c7b0cf0..342b1c4 100644 --- a/src/hosts/tui/dns_status_widget.py +++ b/src/hosts/tui/dns_status_widget.py @@ -14,7 +14,7 @@ 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. """ @@ -61,12 +61,12 @@ class DNSStatusWidget(Static): 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") @@ -95,14 +95,20 @@ class DNSStatusWidget(Static): 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'): + 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"]) - + 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 @@ -117,7 +123,7 @@ class DNSStatusWidget(Static): self.dns_service.stop() else: self.dns_service.start() - + self.dns_enabled = self.dns_service.enabled self.update_status() @@ -133,7 +139,7 @@ class DNSStatusWidget(Static): 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: diff --git a/src/hosts/tui/edit_handler.py b/src/hosts/tui/edit_handler.py index d71bcb6..3723059 100644 --- a/src/hosts/tui/edit_handler.py +++ b/src/hosts/tui/edit_handler.py @@ -24,11 +24,11 @@ class EditHandler: self.app.hosts_file.entries ): return "ip" # Default to IP type - + entry = self.app.hosts_file.entries[self.app.selected_entry_index] - + # Check if entry has a DNS name field and it's not empty - if hasattr(entry, 'dns_name') and entry.dns_name: + if hasattr(entry, "dns_name") and entry.dns_name: return "dns" else: return "ip" @@ -38,32 +38,35 @@ class EditHandler: if entry_type == "ip": # Show IP section, hide DNS section self.update_field_visibility(show_ip=True, show_dns=False) - + # Focus IP input try: ip_input = self.app.query_one("#ip-input", Input) ip_input.focus() except Exception: pass - + elif entry_type == "dns": # Show DNS section, hide IP section self.update_field_visibility(show_ip=False, show_dns=True) - + # Populate DNS field if we have existing entry data try: - if (self.app.entry_edit_mode and - self.app.hosts_file.entries and - self.app.selected_entry_index < len(self.app.hosts_file.entries)): - + if ( + self.app.entry_edit_mode + and self.app.hosts_file.entries + and self.app.selected_entry_index < len(self.app.hosts_file.entries) + ): entry = self.app.hosts_file.entries[self.app.selected_entry_index] dns_input = self.app.query_one("#dns-name-input", Input) - + # Populate with existing DNS name if available - dns_name = getattr(entry, 'dns_name', '') or '' - if dns_name and not dns_input.value: # Only populate if field is empty + dns_name = getattr(entry, "dns_name", "") or "" + if ( + dns_name and not dns_input.value + ): # Only populate if field is empty dns_input.value = dns_name - + # Focus DNS input dns_input.focus() else: @@ -78,17 +81,17 @@ class EditHandler: try: ip_section = self.app.query_one("#edit-ip-section") dns_section = self.app.query_one("#edit-dns-section") - + if show_ip: ip_section.remove_class("hidden") else: ip_section.add_class("hidden") - + if show_dns: dns_section.remove_class("hidden") else: dns_section.add_class("hidden") - + except Exception: # Sections not found, ignore silently pass @@ -97,7 +100,7 @@ class EditHandler: """Initialize edit form with correct radio button state and field visibility.""" if not self.app.entry_edit_mode: return - + # Use a timer to delay radio button setup to allow widgets to initialize self.app.set_timer(0.1, self._delayed_radio_setup) @@ -105,36 +108,36 @@ class EditHandler: """Set up radio buttons after a small delay to ensure widgets are ready.""" if not self.app.entry_edit_mode: return - + # Determine current entry type entry_type = self.get_current_entry_type() - + try: # Get current entry for DNS field population entry = self.app.hosts_file.entries[self.app.selected_entry_index] - + # Get radio buttons ip_radio = self.app.query_one("#edit-ip-entry-radio") dns_radio = self.app.query_one("#edit-dns-entry-radio") - + # Set radio button values - let RadioSet manage pressed_button automatically if entry_type == "ip": # Clear DNS radio first, then set IP radio dns_radio.value = False ip_radio.value = True else: - # Clear IP radio first, then set DNS radio + # Clear IP radio first, then set DNS radio ip_radio.value = False dns_radio.value = True - + # Update field visibility self.handle_entry_type_change(entry_type) - + # Populate DNS name field for DNS entries (after field is visible) if entry_type == "dns": dns_input = self.app.query_one("#dns-name-input", Input) - dns_input.value = getattr(entry, 'dns_name', '') or '' - + dns_input.value = getattr(entry, "dns_name", "") or "" + except Exception as e: # Debug: Show what went wrong self.app.update_status(f"Debug: populate_edit_form error: {e}") @@ -149,7 +152,7 @@ class EditHandler: hostname_input = self.app.query_one("#hostname-input", Input) comment_input = self.app.query_one("#comment-input", Input) active_checkbox = self.app.query_one("#active-checkbox", Checkbox) - + # Try to get DNS input - may not exist in all contexts try: dns_input = self.app.query_one("#dns-name-input", Input) @@ -219,7 +222,7 @@ class EditHandler: hostname_input = self.app.query_one("#hostname-input", Input) comment_input = self.app.query_one("#comment-input", Input) active_checkbox = self.app.query_one("#active-checkbox", Checkbox) - + # Try to get DNS input - may not exist in all contexts try: dns_input = self.app.query_one("#dns-name-input", Input) @@ -237,7 +240,7 @@ class EditHandler: dns_name = self.app.original_entry_values.get("dns_name") ip_radio = self.app.query_one("#edit-ip-entry-radio") dns_radio = self.app.query_one("#edit-dns-entry-radio") - + if dns_name: # Was DNS entry - set DNS radio and show DNS field ip_radio.value = False @@ -254,11 +257,13 @@ class EditHandler: def validate_entry_by_type(self, entry_type: str) -> bool: """Type-specific validation for IP or DNS entries.""" hostname_input = self.app.query_one("#hostname-input", Input) - + # Validate hostname(s) - common to both types hostnames = [h.strip() for h in hostname_input.value.split(",") if h.strip()] if not hostnames: - self.app.update_status("❌ At least one hostname is required - changes not saved") + self.app.update_status( + "❌ At least one hostname is required - changes not saved" + ) return False hostname_pattern = re.compile( @@ -267,16 +272,20 @@ class EditHandler: for hostname in hostnames: if not hostname_pattern.match(hostname): - self.app.update_status(f"❌ Invalid hostname: {hostname} - changes not saved") + self.app.update_status( + f"❌ Invalid hostname: {hostname} - changes not saved" + ) return False - + if entry_type == "ip": # Validate IP address try: ip_input = self.app.query_one("#ip-input", Input) ip_address = ip_input.value.strip() if not ip_address: - self.app.update_status("❌ IP address is required - changes not saved") + self.app.update_status( + "❌ IP address is required - changes not saved" + ) return False ipaddress.ip_address(ip_address) except ValueError: @@ -288,9 +297,11 @@ class EditHandler: dns_input = self.app.query_one("#dns-name-input", Input) dns_name = dns_input.value.strip() if not dns_name: - self.app.update_status("❌ DNS name is required - changes not saved") + self.app.update_status( + "❌ DNS name is required - changes not saved" + ) return False - + # Basic DNS name validation if ( " " in dns_name @@ -299,12 +310,16 @@ class EditHandler: or dns_name.endswith(".") or ".." in dns_name ): - self.app.update_status("❌ Invalid DNS name format - changes not saved") + self.app.update_status( + "❌ Invalid DNS name format - changes not saved" + ) return False except Exception: - self.app.update_status("❌ DNS name validation failed - changes not saved") + self.app.update_status( + "❌ DNS name validation failed - changes not saved" + ) return False - + return True def validate_and_save_entry_changes(self) -> bool: @@ -348,11 +363,11 @@ class EditHandler: entry.ip_address = ip_input.value.strip() entry.dns_name = None # Clear DNS name when converting to IP # Clear DNS-related fields - if hasattr(entry, 'resolved_ip'): + if hasattr(entry, "resolved_ip"): entry.resolved_ip = None - if hasattr(entry, 'last_resolved'): + if hasattr(entry, "last_resolved"): entry.last_resolved = None - if hasattr(entry, 'dns_resolution_status'): + if hasattr(entry, "dns_resolution_status"): entry.dns_resolution_status = None else: # DNS entry - update DNS name and set placeholder IP @@ -360,12 +375,13 @@ class EditHandler: entry.dns_name = dns_input.value.strip() entry.ip_address = "0.0.0.0" # Placeholder IP for DNS entries # Initialize DNS fields if they don't exist - if not hasattr(entry, 'resolved_ip'): + if not hasattr(entry, "resolved_ip"): entry.resolved_ip = None - if not hasattr(entry, 'last_resolved'): + if not hasattr(entry, "last_resolved"): entry.last_resolved = None - if not hasattr(entry, 'dns_resolution_status'): + if not hasattr(entry, "dns_resolution_status"): from ..core.dns import DNSResolutionStatus + entry.dns_resolution_status = DNSResolutionStatus.NOT_RESOLVED # Update common fields @@ -385,10 +401,12 @@ class EditHandler: ) if table.row_count > 0 and display_index < table.row_count: table.move_cursor(row=display_index) - + # Provide appropriate success message if entry_type == "dns": - self.app.update_status("DNS entry saved successfully - DNS resolution can be triggered manually") + self.app.update_status( + "DNS entry saved successfully - DNS resolution can be triggered manually" + ) else: self.app.update_status("Entry saved successfully") return True @@ -407,10 +425,10 @@ class EditHandler: hostname_input = self.app.query_one("#hostname-input", Input) comment_input = self.app.query_one("#comment-input", Input) active_checkbox = self.app.query_one("#active-checkbox", Checkbox) - + # Build field list based on current entry type fields = [radio_set] - + # Add IP or DNS field based on visibility try: ip_section = self.app.query_one("#edit-ip-section") @@ -419,7 +437,7 @@ class EditHandler: fields.append(ip_input) except Exception: pass - + try: dns_section = self.app.query_one("#edit-dns-section") if not dns_section.has_class("hidden"): @@ -427,7 +445,7 @@ class EditHandler: fields.append(dns_input) except Exception: pass - + # Add remaining fields fields.extend([hostname_input, comment_input, active_checkbox]) @@ -437,7 +455,7 @@ class EditHandler: next_field = fields[(i + 1) % len(fields)] next_field.focus() break - + except Exception: # Fallback to original navigation if widgets not ready pass @@ -453,10 +471,10 @@ class EditHandler: hostname_input = self.app.query_one("#hostname-input", Input) comment_input = self.app.query_one("#comment-input", Input) active_checkbox = self.app.query_one("#active-checkbox", Checkbox) - + # Build field list based on current entry type fields = [radio_set] - + # Add IP or DNS field based on visibility try: ip_section = self.app.query_one("#edit-ip-section") @@ -465,7 +483,7 @@ class EditHandler: fields.append(ip_input) except Exception: pass - + try: dns_section = self.app.query_one("#edit-dns-section") if not dns_section.has_class("hidden"): @@ -473,7 +491,7 @@ class EditHandler: fields.append(dns_input) except Exception: pass - + # Add remaining fields fields.extend([hostname_input, comment_input, active_checkbox]) @@ -483,7 +501,7 @@ class EditHandler: prev_field = fields[(i - 1) % len(fields)] prev_field.focus() break - + except Exception: # Fallback to original navigation if widgets not ready pass diff --git a/src/hosts/tui/filter_modal.py b/src/hosts/tui/filter_modal.py index 5837ab9..22dc350 100644 --- a/src/hosts/tui/filter_modal.py +++ b/src/hosts/tui/filter_modal.py @@ -8,8 +8,15 @@ filtering options including status, type, resolution status, and search filterin 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 + Static, + Button, + Checkbox, + Input, + Select, + Label, + RadioSet, + RadioButton, + Collapsible, ) from textual.screen import ModalScreen from textual.reactive import reactive @@ -21,7 +28,7 @@ from ..core.filters import FilterOptions, EntryFilter class FilterModal(ModalScreen[Optional[FilterOptions]]): """Advanced filtering configuration modal.""" - + DEFAULT_CSS = """ FilterModal { align: center middle; @@ -140,17 +147,20 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): 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): + + 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 @@ -161,12 +171,12 @@ class FilterModal(ModalScreen[Optional[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): @@ -174,97 +184,159 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): 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()], + [ + (name, name) + for name in self.entry_filter.get_preset_names() + ], value=self.current_options.preset_name, id="preset-select", - classes="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") - + 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") + 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 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") + 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 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") - + 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" + 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") - + 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 @@ -274,10 +346,10 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): 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 @@ -287,60 +359,71 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): 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): + 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-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.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) - + 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() @@ -351,9 +434,9 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): ) 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 @@ -371,7 +454,7 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): 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": @@ -387,18 +470,38 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): 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 + 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 + ( + 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 + ( + 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 + ( + 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 @@ -406,14 +509,14 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): 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, @@ -434,40 +537,40 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): search_in_hostnames=search_hostnames, search_in_comments=search_comments, search_in_ips=search_ips, - case_sensitive=case_sensitive + 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.""" @@ -478,7 +581,7 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): 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.""" @@ -487,12 +590,14 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): 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.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.""" @@ -501,5 +606,7 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): 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.set_options( + [(name, name) for name in self.entry_filter.get_preset_names()] + ) preset_select.value = Select.BLANK diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index a8d5bbc..9b6e752 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -44,8 +44,20 @@ HOSTS_MANAGER_BINDINGS = [ Binding("shift+down", "move_entry_down", "Move entry down", show=False), Binding("ctrl+z", "undo", "Undo", show=False, id="left:undo"), Binding("ctrl+y", "redo", "Redo", show=False, id="left:redo"), - Binding("R", "refresh_dns", "Update all DNS based Entries", show=False, id="left:refresh_dns"), - Binding("r", "update_single_dns", "Update selected DNS based Entry", show=False, id="left:update_single_dns"), + Binding( + "R", + "refresh_dns", + "Update all DNS based Entries", + show=False, + id="left:refresh_dns", + ), + Binding( + "r", + "update_single_dns", + "Update selected DNS based Entry", + show=False, + id="left:update_single_dns", + ), Binding("escape", "exit_edit_entry", "Exit edit mode", show=False), Binding("tab", "next_field", "Next field", show=False), Binding("shift+tab", "prev_field", "Previous field", show=False), diff --git a/src/hosts/tui/navigation_handler.py b/src/hosts/tui/navigation_handler.py index 4bee749..4322dbc 100644 --- a/src/hosts/tui/navigation_handler.py +++ b/src/hosts/tui/navigation_handler.py @@ -49,7 +49,9 @@ class NavigationHandler: ), ) self.app.details_handler.update_entry_details() - self.app.update_status(f"{result.message} - Changes saved automatically") + self.app.update_status( + f"{result.message} - Changes saved automatically" + ) else: self.app.update_status(f"Entry toggled but save failed: {save_message}") else: @@ -89,7 +91,9 @@ class NavigationHandler: if table.row_count > 0 and display_index < table.row_count: table.move_cursor(row=display_index) self.app.details_handler.update_entry_details() - self.app.update_status(f"{result.message} - Changes saved automatically") + self.app.update_status( + f"{result.message} - Changes saved automatically" + ) else: self.app.update_status(f"Entry moved but save failed: {save_message}") else: @@ -129,7 +133,9 @@ class NavigationHandler: if table.row_count > 0 and display_index < table.row_count: table.move_cursor(row=display_index) self.app.details_handler.update_entry_details() - self.app.update_status(f"{result.message} - Changes saved automatically") + self.app.update_status( + f"{result.message} - Changes saved automatically" + ) else: self.app.update_status(f"Entry moved but save failed: {save_message}") else: diff --git a/src/hosts/tui/table_handler.py b/src/hosts/tui/table_handler.py index 1eb1476..e7b0683 100644 --- a/src/hosts/tui/table_handler.py +++ b/src/hosts/tui/table_handler.py @@ -35,8 +35,12 @@ class TableHandler: all_entries.append(entry) # Apply advanced filtering if enabled - if hasattr(self.app, 'entry_filter') and hasattr(self.app, 'current_filter_options'): - filtered_entries = self.app.entry_filter.apply_filters(all_entries, self.app.current_filter_options) + if hasattr(self.app, "entry_filter") and hasattr( + self.app, "current_filter_options" + ): + filtered_entries = self.app.entry_filter.apply_filters( + all_entries, self.app.current_filter_options + ) else: # Fallback to legacy search filtering for backward compatibility filtered_entries = self._apply_legacy_search_filter(all_entries) @@ -45,7 +49,7 @@ class TableHandler: def _apply_legacy_search_filter(self, entries: List[HostEntry]) -> List[HostEntry]: """Apply legacy search filter for backward compatibility.""" - if not hasattr(self.app, 'search_term') or not self.app.search_term: + if not hasattr(self.app, "search_term") or not self.app.search_term: return entries search_term_lower = self.app.search_term.lower() @@ -251,10 +255,10 @@ class TableHandler: # Start with the DNS name dns_display = entry.dns_name - + # Add status indicator based on resolution status dns_status = entry.dns_resolution_status or "not_resolved" - + if dns_status == "not_resolved": status_icon = "⏳" style = "dim yellow" @@ -276,7 +280,7 @@ class TableHandler: else: status_icon = "" style = "dim white" - + return Text(f"{status_icon} {dns_display}", style=style) def sort_entries_by_hostname(self) -> None: diff --git a/tests/test_add_entry_modal.py b/tests/test_add_entry_modal.py index ba1066a..8cf7063 100644 --- a/tests/test_add_entry_modal.py +++ b/tests/test_add_entry_modal.py @@ -29,7 +29,7 @@ class TestAddEntryModalDNSSupport: # Test that the compose method exists and can be called # We can't test the actual widget creation without mounting the modal # in a Textual app context, so we just verify the method exists - assert hasattr(self.modal, 'compose') + assert hasattr(self.modal, "compose") assert callable(self.modal.compose) def test_validate_input_ip_entry_valid(self): @@ -39,7 +39,7 @@ class TestAddEntryModalDNSSupport: ip_address="192.168.1.1", dns_name="", hostnames_str="example.com", - is_dns_entry=False + is_dns_entry=False, ) assert result is True @@ -47,12 +47,9 @@ class TestAddEntryModalDNSSupport: """Test validation for IP entry with missing IP address.""" # Mock the error display method self.modal._show_error = Mock() - + result = self.modal._validate_input( - ip_address="", - dns_name="", - hostnames_str="example.com", - is_dns_entry=False + ip_address="", dns_name="", hostnames_str="example.com", is_dns_entry=False ) assert result is False self.modal._show_error.assert_called_with("ip-error", "IP address is required") @@ -63,7 +60,7 @@ class TestAddEntryModalDNSSupport: ip_address="", dns_name="example.com", hostnames_str="www.example.com", - is_dns_entry=True + is_dns_entry=True, ) assert result is True @@ -71,12 +68,9 @@ class TestAddEntryModalDNSSupport: """Test validation for DNS entry with missing DNS name.""" # Mock the error display method self.modal._show_error = Mock() - + result = self.modal._validate_input( - ip_address="", - dns_name="", - hostnames_str="example.com", - is_dns_entry=True + ip_address="", dns_name="", hostnames_str="example.com", is_dns_entry=True ) assert result is False self.modal._show_error.assert_called_with("dns-error", "DNS name is required") @@ -85,55 +79,58 @@ class TestAddEntryModalDNSSupport: """Test validation for DNS entry with invalid DNS name format.""" # Mock the error display method self.modal._show_error = Mock() - + # Test various invalid DNS name formats invalid_dns_names = [ "example .com", # Contains space ".example.com", # Starts with dot "example.com.", # Ends with dot "example..com", # Double dots - "ex@mple.com", # Invalid characters + "ex@mple.com", # Invalid characters ] - + for invalid_dns in invalid_dns_names: result = self.modal._validate_input( ip_address="", dns_name=invalid_dns, hostnames_str="example.com", - is_dns_entry=True + is_dns_entry=True, ) assert result is False - self.modal._show_error.assert_called_with("dns-error", "Invalid DNS name format") + self.modal._show_error.assert_called_with( + "dns-error", "Invalid DNS name format" + ) def test_validate_input_missing_hostnames(self): """Test validation for entries with missing hostnames.""" # Mock the error display method self.modal._show_error = Mock() - + # Test IP entry without hostnames result = self.modal._validate_input( - ip_address="192.168.1.1", - dns_name="", - hostnames_str="", - is_dns_entry=False + ip_address="192.168.1.1", dns_name="", hostnames_str="", is_dns_entry=False ) assert result is False - self.modal._show_error.assert_called_with("hostnames-error", "At least one hostname is required") + self.modal._show_error.assert_called_with( + "hostnames-error", "At least one hostname is required" + ) def test_validate_input_invalid_hostnames(self): """Test validation for entries with invalid hostnames.""" # Mock the error display method self.modal._show_error = Mock() - + # Test with invalid hostname containing spaces result = self.modal._validate_input( ip_address="192.168.1.1", dns_name="", hostnames_str="invalid hostname", - is_dns_entry=False + is_dns_entry=False, ) assert result is False - self.modal._show_error.assert_called_with("hostnames-error", "Invalid hostname format: invalid hostname") + self.modal._show_error.assert_called_with( + "hostnames-error", "Invalid hostname format: invalid hostname" + ) def test_clear_errors_includes_dns_error(self): """Test that clear_errors method includes DNS error clearing.""" @@ -141,7 +138,7 @@ class TestAddEntryModalDNSSupport: mock_ip_error = Mock(spec=Static) mock_dns_error = Mock(spec=Static) mock_hostnames_error = Mock(spec=Static) - + def mock_query_one(selector, widget_type): if selector == "#ip-error": return mock_ip_error @@ -150,12 +147,12 @@ class TestAddEntryModalDNSSupport: elif selector == "#hostnames-error": return mock_hostnames_error return Mock() - + self.modal.query_one = Mock(side_effect=mock_query_one) - + # Call clear_errors self.modal._clear_errors() - + # Verify all error widgets were cleared mock_ip_error.update.assert_called_with("") mock_dns_error.update.assert_called_with("") @@ -166,10 +163,10 @@ class TestAddEntryModalDNSSupport: # Mock the query_one method to return a mock widget mock_error_widget = Mock(spec=Static) self.modal.query_one = Mock(return_value=mock_error_widget) - + # Test showing an error self.modal._show_error("dns-error", "Test error message") - + # Verify the error widget was updated self.modal.query_one.assert_called_with("#dns-error", Static) mock_error_widget.update.assert_called_with("Test error message") @@ -178,7 +175,7 @@ class TestAddEntryModalDNSSupport: """Test that show_error handles missing widgets gracefully.""" # Mock query_one to raise an exception self.modal.query_one = Mock(side_effect=Exception("Widget not found")) - + # This should not raise an exception try: self.modal._show_error("dns-error", "Test error message") @@ -199,7 +196,7 @@ class TestAddEntryModalRadioButtonLogic: mock_ip_section = Mock() mock_dns_section = Mock() mock_ip_input = Mock(spec=Input) - + def mock_query_one(selector, widget_type=None): if selector == "#ip-section": return mock_ip_section @@ -208,25 +205,25 @@ class TestAddEntryModalRadioButtonLogic: elif selector == "#ip-address-input": return mock_ip_input return Mock() - + self.modal.query_one = Mock(side_effect=mock_query_one) - + # Create mock event mock_radio = Mock() mock_radio.id = "ip-entry-radio" mock_radio_set = Mock() mock_radio_set.id = "entry-type-radio" - + class MockEvent: def __init__(self): self.radio_set = mock_radio_set self.pressed = mock_radio - + event = MockEvent() - + # Call the event handler self.modal.on_radio_set_changed(event) - + # Verify IP section is shown and DNS section is hidden mock_ip_section.remove_class.assert_called_with("hidden") mock_dns_section.add_class.assert_called_with("hidden") @@ -238,7 +235,7 @@ class TestAddEntryModalRadioButtonLogic: mock_ip_section = Mock() mock_dns_section = Mock() mock_dns_input = Mock(spec=Input) - + def mock_query_one(selector, widget_type=None): if selector == "#ip-section": return mock_ip_section @@ -247,25 +244,25 @@ class TestAddEntryModalRadioButtonLogic: elif selector == "#dns-name-input": return mock_dns_input return Mock() - + self.modal.query_one = Mock(side_effect=mock_query_one) - + # Create mock event mock_radio = Mock() mock_radio.id = "dns-entry-radio" mock_radio_set = Mock() mock_radio_set.id = "entry-type-radio" - + class MockEvent: def __init__(self): self.radio_set = mock_radio_set self.pressed = mock_radio - + event = MockEvent() - + # Call the event handler self.modal.on_radio_set_changed(event) - + # Verify DNS section is shown and IP section is hidden mock_ip_section.add_class.assert_called_with("hidden") mock_dns_section.remove_class.assert_called_with("hidden") @@ -285,26 +282,26 @@ class TestAddEntryModalSaveLogic: self.modal._validate_input = Mock(return_value=True) self.modal._clear_errors = Mock() self.modal.dismiss = Mock() - + # Mock form widgets mock_radio_set = Mock(spec=RadioSet) mock_radio_set.pressed_button = None # IP entry mode - + mock_ip_input = Mock(spec=Input) mock_ip_input.value = "192.168.1.1" - + mock_dns_input = Mock(spec=Input) mock_dns_input.value = "" - + mock_hostnames_input = Mock(spec=Input) mock_hostnames_input.value = "example.com, www.example.com" - + mock_comment_input = Mock(spec=Input) mock_comment_input.value = "Test comment" - + mock_active_checkbox = Mock(spec=Checkbox) mock_active_checkbox.value = True - + def mock_query_one(selector, widget_type): if selector == "#entry-type-radio": return mock_radio_set @@ -319,17 +316,17 @@ class TestAddEntryModalSaveLogic: elif selector == "#active-checkbox": return mock_active_checkbox return Mock() - + self.modal.query_one = Mock(side_effect=mock_query_one) - + # Call action_save self.modal.action_save() - + # Verify validation was called self.modal._validate_input.assert_called_once_with( "192.168.1.1", "", "example.com, www.example.com", None ) - + # Verify modal was dismissed with a HostEntry self.modal.dismiss.assert_called_once() created_entry = self.modal.dismiss.call_args[0][0] @@ -345,28 +342,28 @@ class TestAddEntryModalSaveLogic: self.modal._validate_input = Mock(return_value=True) self.modal._clear_errors = Mock() self.modal.dismiss = Mock() - + # Mock form widgets mock_radio_button = Mock() mock_radio_button.id = "dns-entry-radio" mock_radio_set = Mock(spec=RadioSet) mock_radio_set.pressed_button = mock_radio_button - + mock_ip_input = Mock(spec=Input) mock_ip_input.value = "" - + mock_dns_input = Mock(spec=Input) mock_dns_input.value = "example.com" - + mock_hostnames_input = Mock(spec=Input) mock_hostnames_input.value = "www.example.com" - + mock_comment_input = Mock(spec=Input) mock_comment_input.value = "" - + mock_active_checkbox = Mock(spec=Checkbox) mock_active_checkbox.value = True - + def mock_query_one(selector, widget_type): if selector == "#entry-type-radio": return mock_radio_set @@ -381,23 +378,23 @@ class TestAddEntryModalSaveLogic: elif selector == "#active-checkbox": return mock_active_checkbox return Mock() - + self.modal.query_one = Mock(side_effect=mock_query_one) - + # Call action_save self.modal.action_save() - + # Verify validation was called self.modal._validate_input.assert_called_once_with( "", "example.com", "www.example.com", True ) - + # Verify modal was dismissed with a DNS HostEntry self.modal.dismiss.assert_called_once() created_entry = self.modal.dismiss.call_args[0][0] assert isinstance(created_entry, HostEntry) assert created_entry.ip_address == "0.0.0.0" # Placeholder IP for DNS entries - assert hasattr(created_entry, 'dns_name') + assert hasattr(created_entry, "dns_name") assert created_entry.dns_name == "example.com" assert created_entry.hostnames == ["www.example.com"] assert created_entry.comment is None @@ -409,21 +406,21 @@ class TestAddEntryModalSaveLogic: self.modal._validate_input = Mock(return_value=False) self.modal._clear_errors = Mock() self.modal.dismiss = Mock() - + # Mock form widgets (minimal setup since validation fails) mock_radio_set = Mock(spec=RadioSet) mock_radio_set.pressed_button = None - + def mock_query_one(selector, widget_type): if selector == "#entry-type-radio": return mock_radio_set return Mock(spec=Input, value="") - + self.modal.query_one = Mock(side_effect=mock_query_one) - + # Call action_save self.modal.action_save() - + # Verify validation was called and modal was not dismissed self.modal._validate_input.assert_called_once() self.modal.dismiss.assert_not_called() @@ -434,30 +431,33 @@ class TestAddEntryModalSaveLogic: self.modal._validate_input = Mock(return_value=True) self.modal._clear_errors = Mock() self.modal._show_error = Mock() - + # Mock form widgets mock_radio_set = Mock(spec=RadioSet) mock_radio_set.pressed_button = None - + mock_input = Mock(spec=Input) mock_input.value = "invalid" - + def mock_query_one(selector, widget_type): if selector == "#entry-type-radio": return mock_radio_set return mock_input - + self.modal.query_one = Mock(side_effect=mock_query_one) - + # Mock HostEntry to raise ValueError with pytest.MonkeyPatch.context() as m: + def mock_host_entry(*args, **kwargs): raise ValueError("Invalid IP address") - + m.setattr("src.hosts.tui.add_entry_modal.HostEntry", mock_host_entry) - + # Call action_save self.modal.action_save() - + # Verify error was shown - self.modal._show_error.assert_called_once_with("hostnames-error", "Invalid IP address") + self.modal._show_error.assert_called_once_with( + "hostnames-error", "Invalid IP address" + ) diff --git a/tests/test_commands.py b/tests/test_commands.py index 6202735..bc38fad 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -179,13 +179,13 @@ class TestUndoRedoHistory: """Test that executing a new command clears the redo stack.""" history = UndoRedoHistory() hosts_file = HostsFile() - + # Execute and undo a command command1 = Mock(spec=Command) command1.execute.return_value = OperationResult(True, "Command 1") command1.undo.return_value = OperationResult(True, "Undo 1") command1.get_description.return_value = "Command 1" - + history.execute_command(command1, hosts_file) history.undo(hosts_file) assert history.can_redo() @@ -194,7 +194,7 @@ class TestUndoRedoHistory: command2 = Mock(spec=Command) command2.execute.return_value = OperationResult(True, "Command 2") command2.get_description.return_value = "Command 2" - + history.execute_command(command2, hosts_file) assert not history.can_redo() # Redo stack should be cleared @@ -205,7 +205,9 @@ class TestToggleEntryCommand: def test_toggle_active_to_inactive(self): """Test toggling an active entry to inactive.""" hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=True) + entry = HostEntry( + ip_address="192.168.1.1", hostnames=["test.local"], is_active=True + ) hosts_file.entries.append(entry) command = ToggleEntryCommand(0) @@ -218,7 +220,9 @@ class TestToggleEntryCommand: def test_toggle_inactive_to_active(self): """Test toggling an inactive entry to active.""" hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=False) + entry = HostEntry( + ip_address="192.168.1.1", hostnames=["test.local"], is_active=False + ) hosts_file.entries.append(entry) command = ToggleEntryCommand(0) @@ -231,7 +235,9 @@ class TestToggleEntryCommand: def test_toggle_undo(self): """Test undoing a toggle operation.""" hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=True) + entry = HostEntry( + ip_address="192.168.1.1", hostnames=["test.local"], is_active=True + ) hosts_file.entries.append(entry) command = ToggleEntryCommand(0) @@ -454,10 +460,17 @@ class TestUpdateEntryCommand: def test_update_entry(self): """Test updating an entry.""" hosts_file = HostsFile() - old_entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], comment="old comment", is_active=True) + old_entry = HostEntry( + ip_address="192.168.1.1", + hostnames=["test.local"], + comment="old comment", + is_active=True, + ) hosts_file.entries.append(old_entry) - command = UpdateEntryCommand(0, "192.168.1.2", ["updated.local"], "new comment", False) + command = UpdateEntryCommand( + 0, "192.168.1.2", ["updated.local"], "new comment", False + ) result = command.execute(hosts_file) assert result.success is True @@ -470,10 +483,17 @@ class TestUpdateEntryCommand: def test_update_entry_undo(self): """Test undoing an update operation.""" hosts_file = HostsFile() - old_entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], comment="old comment", is_active=True) + old_entry = HostEntry( + ip_address="192.168.1.1", + hostnames=["test.local"], + comment="old comment", + is_active=True, + ) hosts_file.entries.append(old_entry) - command = UpdateEntryCommand(0, "192.168.1.2", ["updated.local"], "new comment", False) + command = UpdateEntryCommand( + 0, "192.168.1.2", ["updated.local"], "new comment", False + ) command.execute(hosts_file) result = command.undo(hosts_file) @@ -507,7 +527,7 @@ class TestHostsManagerIntegration: def test_manager_undo_redo_properties(self): """Test undo/redo availability properties.""" manager = HostsManager() - + # Initially no undo/redo available assert not manager.can_undo() assert not manager.can_redo() @@ -519,7 +539,7 @@ class TestHostsManagerIntegration: manager = HostsManager() hosts_file = HostsFile() result = manager.undo_last_operation(hosts_file) - + assert result.success is False assert "Not in edit mode" in result.message @@ -528,46 +548,48 @@ class TestHostsManagerIntegration: manager = HostsManager() hosts_file = HostsFile() result = manager.redo_last_operation(hosts_file) - + assert result.success is False assert "Not in edit mode" in result.message - @patch('src.hosts.core.manager.HostsManager.enter_edit_mode') + @patch("src.hosts.core.manager.HostsManager.enter_edit_mode") def test_manager_execute_toggle_command(self, mock_enter_edit): """Test executing a toggle command through the manager.""" mock_enter_edit.return_value = (True, "Edit mode enabled") - + manager = HostsManager() manager.edit_mode = True # Simulate edit mode - + hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=True) + entry = HostEntry( + ip_address="192.168.1.1", hostnames=["test.local"], is_active=True + ) hosts_file.entries.append(entry) result = manager.execute_toggle_command(hosts_file, 0) - + assert result.success is True assert not hosts_file.entries[0].is_active - @patch('src.hosts.core.manager.HostsManager.enter_edit_mode') + @patch("src.hosts.core.manager.HostsManager.enter_edit_mode") def test_manager_execute_move_command(self, mock_enter_edit): """Test executing a move command through the manager.""" mock_enter_edit.return_value = (True, "Edit mode enabled") - + manager = HostsManager() manager.edit_mode = True # Simulate edit mode - + hosts_file = HostsFile() entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"]) entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"]) hosts_file.entries.extend([entry1, entry2]) result = manager.execute_move_command(hosts_file, 1, "up") - + assert result.success is True assert hosts_file.entries[0] == entry2 - @patch('src.hosts.core.manager.HostsManager.enter_edit_mode') + @patch("src.hosts.core.manager.HostsManager.enter_edit_mode") def test_manager_command_not_in_edit_mode(self, mock_enter_edit): """Test executing commands when not in edit mode.""" manager = HostsManager() @@ -575,7 +597,7 @@ class TestHostsManagerIntegration: entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"]) result = manager.execute_add_command(hosts_file, entry) - + assert result.success is False assert "Not in edit mode" in result.message diff --git a/tests/test_dns.py b/tests/test_dns.py index 48700b6..b471ac9 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -115,9 +115,10 @@ class TestResolveHostname: @pytest.mark.asyncio async def test_timeout_resolution(self): """Test hostname resolution timeout.""" + async def mock_wait_for(*args, **kwargs): raise asyncio.TimeoutError() - + with patch("asyncio.wait_for", side_effect=mock_wait_for) as mock_wait_for: resolution = await resolve_hostname("slow.example", timeout=1.0) @@ -142,9 +143,10 @@ class TestResolveHostname: @pytest.mark.asyncio async def test_empty_result_resolution(self): """Test hostname resolution with empty result.""" + async def mock_wait_for(*args, **kwargs): return [] - + with patch("asyncio.get_event_loop") as mock_loop: mock_event_loop = AsyncMock() mock_loop.return_value = mock_event_loop @@ -167,7 +169,9 @@ class TestResolveHostnamesBatch: """Test successful batch hostname resolution.""" hostnames = ["example.com", "test.example"] - with patch("src.hosts.core.dns.resolve_hostname", new_callable=AsyncMock) as mock_resolve: + with patch( + "src.hosts.core.dns.resolve_hostname", new_callable=AsyncMock + ) as mock_resolve: # Mock successful resolutions mock_resolve.side_effect = [ DNSResolution( @@ -214,9 +218,8 @@ class TestResolveHostnamesBatch: resolved_at=datetime.now(), error_message="Name not found", ) - - with patch("src.hosts.core.dns.resolve_hostname", mock_resolve_hostname): + with patch("src.hosts.core.dns.resolve_hostname", mock_resolve_hostname): resolutions = await resolve_hostnames_batch(hostnames) assert len(resolutions) == 2 @@ -278,16 +281,20 @@ class TestDNSService: """Test async resolution when service is enabled.""" service = DNSService(enabled=True) - with patch("src.hosts.core.dns.resolve_hostname", new_callable=AsyncMock) as mock_resolve: + with patch( + "src.hosts.core.dns.resolve_hostname", new_callable=AsyncMock + ) as mock_resolve: mock_resolution = DNSResolution( hostname="example.com", resolved_ip="192.0.2.1", status=DNSResolutionStatus.RESOLVED, resolved_at=datetime.now(), ) + # Use proper async setup async def mock_side_effect(hostname, timeout=5.0): return mock_resolution + mock_resolve.side_effect = mock_side_effect resolution = await service.resolve_entry_async("example.com") @@ -312,16 +319,20 @@ class TestDNSService: """Test manual entry refresh.""" service = DNSService(enabled=True) - with patch("src.hosts.core.dns.resolve_hostname", new_callable=AsyncMock) as mock_resolve: + with patch( + "src.hosts.core.dns.resolve_hostname", new_callable=AsyncMock + ) as mock_resolve: mock_resolution = DNSResolution( hostname="example.com", resolved_ip="192.0.2.1", status=DNSResolutionStatus.RESOLVED, resolved_at=datetime.now(), ) + # Use proper async setup async def mock_side_effect(hostname, timeout=5.0): return mock_resolution + mock_resolve.side_effect = mock_side_effect result = await service.refresh_entry("example.com") diff --git a/tests/test_filters.py b/tests/test_filters.py index d556653..9174d41 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -10,6 +10,7 @@ from datetime import datetime, timedelta from src.hosts.core.filters import EntryFilter, FilterOptions from src.hosts.core.models import HostEntry + class TestFilterOptions: """Test FilterOptions dataclass.""" @@ -40,7 +41,7 @@ class TestFilterOptions: active_only=True, dns_only=True, search_term="test", - preset_name="Active DNS Only" + preset_name="Active DNS Only", ) assert options.active_only is True assert options.dns_only is True @@ -50,60 +51,58 @@ class TestFilterOptions: def test_to_dict(self): """Test converting FilterOptions to dictionary.""" options = FilterOptions( - active_only=True, - search_term="test", - preset_name="Test Preset" + active_only=True, search_term="test", preset_name="Test Preset" ) result = options.to_dict() - + expected = { - 'show_active': True, - 'show_inactive': True, - 'active_only': True, - 'inactive_only': False, - 'show_dns_entries': True, - 'show_ip_entries': True, - 'dns_only': False, - 'ip_only': False, - 'show_resolved': True, - 'show_unresolved': True, - 'show_resolving': True, - 'show_failed': True, - 'show_mismatched': True, - 'mismatch_only': False, - 'resolved_only': False, - 'search_term': 'test', - 'search_in_hostnames': True, - 'search_in_comments': True, - 'search_in_ips': True, - 'case_sensitive': False, - 'preset_name': 'Test Preset' + "show_active": True, + "show_inactive": True, + "active_only": True, + "inactive_only": False, + "show_dns_entries": True, + "show_ip_entries": True, + "dns_only": False, + "ip_only": False, + "show_resolved": True, + "show_unresolved": True, + "show_resolving": True, + "show_failed": True, + "show_mismatched": True, + "mismatch_only": False, + "resolved_only": False, + "search_term": "test", + "search_in_hostnames": True, + "search_in_comments": True, + "search_in_ips": True, + "case_sensitive": False, + "preset_name": "Test Preset", } - + assert result == expected def test_from_dict(self): """Test creating FilterOptions from dictionary.""" data = { - 'active_only': True, - 'dns_only': True, - 'search_term': 'test', - 'preset_name': 'Test Preset' + "active_only": True, + "dns_only": True, + "search_term": "test", + "preset_name": "Test Preset", } - + options = FilterOptions.from_dict(data) assert options.active_only is True assert options.dns_only is True - assert options.search_term == 'test' - assert options.preset_name == 'Test Preset' + assert options.search_term == "test" + assert options.preset_name == "Test Preset" # Verify missing keys use defaults assert options.inactive_only is False def test_from_dict_partial(self): """Test creating FilterOptions from partial dictionary.""" - data = {'active_only': True} + data = {"active_only": True} options = FilterOptions.from_dict(data) - + assert options.active_only is True assert options.inactive_only is False # Default value assert options.search_term is None # Default value @@ -113,15 +112,16 @@ class TestFilterOptions: # Default options should be empty options = FilterOptions() assert options.is_empty() is True - + # Options with search term should not be empty options = FilterOptions(search_term="test") assert options.is_empty() is False - + # Options with any filter enabled should not be empty options = FilterOptions(active_only=True) assert options.is_empty() is False + class TestEntryFilter: """Test EntryFilter class.""" @@ -129,45 +129,45 @@ class TestEntryFilter: def sample_entries(self): """Create sample entries for testing.""" entries = [] - + # Active IP entry entry1 = HostEntry("192.168.1.1", ["example.com"], "Test entry", True) entries.append(entry1) - + # Inactive IP entry entry2 = HostEntry("192.168.1.2", ["inactive.com"], "Inactive entry", False) entries.append(entry2) - + # Active DNS entry - create with temporary IP then convert to DNS entry entry3 = HostEntry("1.1.1.1", ["dns-only.com"], "DNS only entry", True) entry3.ip_address = "" # Remove IP after creation entry3.dns_name = "dns-only.com" # Set DNS name entries.append(entry3) - + # Inactive DNS entry - create with temporary IP then convert to DNS entry entry4 = HostEntry("1.1.1.1", ["inactive-dns.com"], "Inactive DNS entry", False) entry4.ip_address = "" # Remove IP after creation entry4.dns_name = "inactive-dns.com" # Set DNS name entries.append(entry4) - + # Entry with DNS resolution data entry5 = HostEntry("10.0.0.1", ["resolved.com"], "Resolved entry", True) entry5.resolved_ip = "10.0.0.1" entry5.last_resolved = datetime.now() entry5.dns_resolution_status = "IP_MATCH" entries.append(entry5) - + # Entry with mismatched DNS entry6 = HostEntry("10.0.0.2", ["mismatch.com"], "Mismatch entry", True) entry6.resolved_ip = "10.0.0.3" # Different from IP address entry6.last_resolved = datetime.now() entry6.dns_resolution_status = "IP_MISMATCH" entries.append(entry6) - + # Entry without DNS resolution entry7 = HostEntry("10.0.0.4", ["unresolved.com"], "Unresolved entry", True) entries.append(entry7) - + return entries @pytest.fixture @@ -186,7 +186,7 @@ class TestEntryFilter: """Test filtering by active status only.""" options = FilterOptions(active_only=True) result = entry_filter.filter_by_status(sample_entries, options) - + active_entries = [e for e in result if e.is_active] assert len(active_entries) == len(result) assert all(entry.is_active for entry in result) @@ -195,7 +195,7 @@ class TestEntryFilter: """Test filtering by inactive status only.""" options = FilterOptions(inactive_only=True) result = entry_filter.filter_by_status(sample_entries, options) - + assert all(not entry.is_active for entry in result) assert len(result) == 2 # entry2 and entry4 @@ -203,7 +203,7 @@ class TestEntryFilter: """Test filtering by DNS entries only.""" options = FilterOptions(dns_only=True) result = entry_filter.filter_by_dns_type(sample_entries, options) - + assert all(entry.dns_name is not None for entry in result) assert len(result) == 2 # entry3 and entry4 @@ -211,7 +211,7 @@ class TestEntryFilter: """Test filtering by IP entries only.""" options = FilterOptions(ip_only=True) result = entry_filter.filter_by_dns_type(sample_entries, options) - + assert all(not entry.has_dns_name() for entry in result) # Should exclude DNS-only entries (entry3, entry4) expected_count = len(sample_entries) - 2 @@ -221,8 +221,10 @@ class TestEntryFilter: """Test filtering by resolved entries only.""" options = FilterOptions(resolved_only=True) result = entry_filter.filter_by_resolution_status(sample_entries, options) - - assert all(entry.dns_resolution_status in ["IP_MATCH", "RESOLVED"] for entry in result) + + assert all( + entry.dns_resolution_status in ["IP_MATCH", "RESOLVED"] for entry in result + ) assert len(result) == 1 # Only entry5 has resolved status def test_filter_by_resolution_status_unresolved(self, entry_filter, sample_entries): @@ -231,18 +233,20 @@ class TestEntryFilter: show_resolved=False, show_resolving=False, show_failed=False, - show_mismatched=False + show_mismatched=False, ) result = entry_filter.filter_by_resolution_status(sample_entries, options) - - assert all(entry.dns_resolution_status in [None, "NOT_RESOLVED"] for entry in result) + + assert all( + entry.dns_resolution_status in [None, "NOT_RESOLVED"] for entry in result + ) assert len(result) == 5 # All except entry5 and entry6 def test_filter_by_resolution_status_mismatch(self, entry_filter, sample_entries): """Test filtering by DNS mismatch entries only.""" options = FilterOptions(mismatch_only=True) result = entry_filter.filter_by_resolution_status(sample_entries, options) - + # Should only return entry6 (mismatch between IP and resolved_ip) assert len(result) == 1 assert result[0].hostnames[0] == "mismatch.com" @@ -251,7 +255,7 @@ class TestEntryFilter: """Test filtering by search term in hostname.""" options = FilterOptions(search_term="example") result = entry_filter.filter_by_search(sample_entries, options) - + assert len(result) == 1 assert result[0].hostnames[0] == "example.com" @@ -259,14 +263,14 @@ class TestEntryFilter: """Test filtering by search term in IP address.""" options = FilterOptions(search_term="192.168") result = entry_filter.filter_by_search(sample_entries, options) - + assert len(result) == 2 # entry1 and entry2 def test_filter_by_search_comment(self, entry_filter, sample_entries): """Test filtering by search term in comment.""" options = FilterOptions(search_term="DNS only") result = entry_filter.filter_by_search(sample_entries, options) - + assert len(result) == 1 assert result[0].comment == "DNS only entry" @@ -274,20 +278,16 @@ class TestEntryFilter: """Test search is case insensitive.""" options = FilterOptions(search_term="EXAMPLE") result = entry_filter.filter_by_search(sample_entries, options) - + assert len(result) == 1 assert result[0].hostnames[0] == "example.com" def test_combined_filters(self, entry_filter, sample_entries): """Test applying multiple filters together.""" # Filter for active DNS entries containing "dns" - options = FilterOptions( - active_only=True, - dns_only=True, - search_term="dns" - ) + options = FilterOptions(active_only=True, dns_only=True, search_term="dns") result = entry_filter.apply_filters(sample_entries, options) - + # Should only return entry3 (active DNS entry with "dns" in hostname) assert len(result) == 1 assert result[0].hostnames[0] == "dns-only.com" @@ -298,14 +298,14 @@ class TestEntryFilter: """Test counting filtered entries.""" options = FilterOptions(active_only=True) counts = entry_filter.count_filtered_entries(sample_entries, options) - - assert counts['total'] == len(sample_entries) - assert counts['filtered'] == 5 # 5 active entries + + assert counts["total"] == len(sample_entries) + assert counts["filtered"] == 5 # 5 active entries def test_get_default_presets(self, entry_filter): """Test getting default filter presets.""" presets = entry_filter.get_default_presets() - + # Check that default presets exist assert "All Entries" in presets assert "Active Only" in presets @@ -315,7 +315,7 @@ class TestEntryFilter: assert "DNS Mismatches" in presets assert "Resolved Entries" in presets assert "Unresolved Entries" in presets - + # Check that presets have correct structure for preset_name, options in presets.items(): assert isinstance(options, FilterOptions) @@ -324,18 +324,16 @@ class TestEntryFilter: """Test saving and loading custom presets.""" # Create custom filter options custom_options = FilterOptions( - active_only=True, - search_term="test", - preset_name="My Custom Filter" + active_only=True, search_term="test", preset_name="My Custom Filter" ) - + # Save preset entry_filter.save_preset("My Custom Filter", custom_options) - + # Check it was saved presets = entry_filter.get_saved_presets() assert "My Custom Filter" in presets - + # Load and verify loaded_options = presets["My Custom Filter"] assert loaded_options.active_only is True @@ -347,19 +345,19 @@ class TestEntryFilter: # Save a preset first custom_options = FilterOptions(active_only=True) entry_filter.save_preset("To Delete", custom_options) - + # Verify it exists presets = entry_filter.get_saved_presets() assert "To Delete" in presets - + # Delete it result = entry_filter.delete_preset("To Delete") assert result is True - + # Verify it's gone presets = entry_filter.get_saved_presets() assert "To Delete" not in presets - + # Try to delete non-existent preset result = entry_filter.delete_preset("Non Existent") assert result is False @@ -370,7 +368,7 @@ class TestEntryFilter: empty_options = FilterOptions() result = entry_filter.apply_filters([], empty_options) assert result == [] - + # None entries in list - filtering should handle None values gracefully entries_with_none = [None, HostEntry("192.168.1.1", ["test.com"], "", True)] # Filter out None values before applying filters @@ -382,9 +380,14 @@ class TestEntryFilter: def test_search_multiple_hostnames(self, entry_filter): """Test search across multiple hostnames in single entry.""" # Create entry with multiple hostnames - entry = HostEntry("192.168.1.1", ["primary.com", "secondary.com", "alias.org"], "Multi-hostname entry", True) + entry = HostEntry( + "192.168.1.1", + ["primary.com", "secondary.com", "alias.org"], + "Multi-hostname entry", + True, + ) entries = [entry] - + # Search for each hostname for hostname in ["primary", "secondary", "alias"]: options = FilterOptions(search_term=hostname) @@ -397,7 +400,7 @@ class TestEntryFilter: # Modify sample entries to have different resolution times old_time = datetime.now() - timedelta(days=1) recent_time = datetime.now() - timedelta(minutes=5) - + # Make one entry have old resolution for entry in sample_entries: if entry.resolved_ip: @@ -405,7 +408,7 @@ class TestEntryFilter: entry.last_resolved = recent_time else: entry.last_resolved = old_time - + # Test that entries are still found regardless of age # (Age filtering might be added in future versions) options = FilterOptions(resolved_only=True) @@ -414,14 +417,11 @@ class TestEntryFilter: def test_preset_name_preservation(self, entry_filter): """Test that preset names are preserved in FilterOptions.""" - preset_options = FilterOptions( - active_only=True, - preset_name="Active Only" - ) - + preset_options = FilterOptions(active_only=True, preset_name="Active Only") + # Apply filters and check preset name is preserved sample_entry = HostEntry("192.168.1.1", ["test.com"], "Test", True) entry_filter.apply_filters([sample_entry], preset_options) - + # The original preset name should be accessible assert preset_options.preset_name == "Active Only" diff --git a/tests/test_import_export.py b/tests/test_import_export.py index 5ba8447..0e19217 100644 --- a/tests/test_import_export.py +++ b/tests/test_import_export.py @@ -12,10 +12,14 @@ import tempfile from pathlib import Path from datetime import datetime from src.hosts.core.import_export import ( - ImportExportService, ImportResult, ExportFormat, ImportFormat + ImportExportService, + ImportResult, + ExportFormat, + ImportFormat, ) from src.hosts.core.models import HostEntry, HostsFile + class TestImportExportService: """Test ImportExportService class.""" @@ -31,16 +35,16 @@ class TestImportExportService: HostEntry("127.0.0.1", ["localhost"], "Local host", True), HostEntry("192.168.1.1", ["router.local"], "Home router", True), HostEntry("1.1.1.1", ["dns-only.com"], "DNS only entry", False), # Temp IP - HostEntry("10.0.0.1", ["test.example.com"], "Test server", True) + HostEntry("10.0.0.1", ["test.example.com"], "Test server", True), ] - + # Convert to DNS entry and set DNS data for some entries entries[2].ip_address = "" # Remove IP after creation entries[2].dns_name = "dns-only.com" entries[3].resolved_ip = "10.0.0.1" entries[3].last_resolved = datetime(2024, 1, 15, 12, 0, 0) entries[3].dns_resolution_status = "IP_MATCH" - + hosts_file = HostsFile() hosts_file.entries = entries return hosts_file @@ -63,7 +67,7 @@ class TestImportExportService: """Test getting supported formats.""" export_formats = service.get_supported_export_formats() import_formats = service.get_supported_import_formats() - + assert len(export_formats) == 3 assert len(import_formats) == 3 assert ExportFormat.HOSTS in export_formats @@ -74,15 +78,15 @@ class TestImportExportService: def test_export_hosts_format(self, service, sample_hosts_file, temp_dir): """Test exporting to hosts format.""" export_path = temp_dir / "test_hosts.txt" - + result = service.export_hosts_format(sample_hosts_file, export_path) - + assert result.success is True assert result.entries_exported == 4 assert len(result.errors) == 0 assert result.format == ExportFormat.HOSTS assert export_path.exists() - + # Verify content content = export_path.read_text() assert "127.0.0.1" in content @@ -92,30 +96,30 @@ class TestImportExportService: def test_export_json_format(self, service, sample_hosts_file, temp_dir): """Test exporting to JSON format.""" export_path = temp_dir / "test_export.json" - + result = service.export_json_format(sample_hosts_file, export_path) - + assert result.success is True assert result.entries_exported == 4 assert len(result.errors) == 0 assert result.format == ExportFormat.JSON assert export_path.exists() - + # Verify JSON structure - with open(export_path, 'r') as f: + with open(export_path, "r") as f: data = json.load(f) - + assert "metadata" in data assert "entries" in data assert data["metadata"]["total_entries"] == 4 assert len(data["entries"]) == 4 - + # Check first entry first_entry = data["entries"][0] assert first_entry["ip_address"] == "127.0.0.1" assert first_entry["hostnames"] == ["localhost"] assert first_entry["is_active"] is True - + # Check DNS entry dns_entry = next((e for e in data["entries"] if e.get("dns_name")), None) assert dns_entry is not None @@ -124,29 +128,35 @@ class TestImportExportService: def test_export_csv_format(self, service, sample_hosts_file, temp_dir): """Test exporting to CSV format.""" export_path = temp_dir / "test_export.csv" - + result = service.export_csv_format(sample_hosts_file, export_path) - + assert result.success is True assert result.entries_exported == 4 assert len(result.errors) == 0 assert result.format == ExportFormat.CSV assert export_path.exists() - + # Verify CSV structure - with open(export_path, 'r') as f: + with open(export_path, "r") as f: reader = csv.DictReader(f) rows = list(reader) - + assert len(rows) == 4 - + # Check header expected_fields = [ - 'ip_address', 'hostnames', 'comment', 'is_active', - 'dns_name', 'resolved_ip', 'last_resolved', 'dns_resolution_status' + "ip_address", + "hostnames", + "comment", + "is_active", + "dns_name", + "resolved_ip", + "last_resolved", + "dns_resolution_status", ] assert reader.fieldnames == expected_fields - + # Check first row first_row = rows[0] assert first_row["ip_address"] == "127.0.0.1" @@ -156,9 +166,9 @@ class TestImportExportService: def test_export_invalid_path(self, service, sample_hosts_file): """Test export with invalid path.""" invalid_path = Path("/invalid/path/test.json") - + result = service.export_json_format(sample_hosts_file, invalid_path) - + assert result.success is False assert result.entries_exported == 0 assert len(result.errors) > 0 @@ -176,17 +186,19 @@ class TestImportExportService: """ hosts_path = temp_dir / "test_hosts.txt" hosts_path.write_text(hosts_content) - + result = service.import_hosts_format(hosts_path) - + assert result.success is True assert result.total_processed >= 2 assert result.successfully_imported >= 2 assert len(result.errors) == 0 - + # Check imported entries assert len(result.entries) >= 2 - localhost_entry = next((e for e in result.entries if "localhost" in e.hostnames), None) + localhost_entry = next( + (e for e in result.entries if "localhost" in e.hostnames), None + ) assert localhost_entry is not None assert localhost_entry.ip_address == "127.0.0.1" assert localhost_entry.is_active is True @@ -198,21 +210,21 @@ class TestImportExportService: "metadata": { "exported_at": "2024-01-15T12:00:00", "total_entries": 3, - "version": "1.0" + "version": "1.0", }, "entries": [ { "ip_address": "127.0.0.1", "hostnames": ["localhost"], "comment": "Local host", - "is_active": True + "is_active": True, }, { "ip_address": "", "hostnames": ["dns-only.com"], "comment": "DNS only", "is_active": False, - "dns_name": "dns-only.com" + "dns_name": "dns-only.com", }, { "ip_address": "10.0.0.1", @@ -221,29 +233,29 @@ class TestImportExportService: "is_active": True, "resolved_ip": "10.0.0.1", "last_resolved": "2024-01-15T12:00:00", - "dns_resolution_status": "IP_MATCH" - } - ] + "dns_resolution_status": "IP_MATCH", + }, + ], } - + json_path = temp_dir / "test_import.json" - with open(json_path, 'w') as f: + with open(json_path, "w") as f: json.dump(json_data, f) - + result = service.import_json_format(json_path) - + assert result.success is True assert result.total_processed == 3 assert result.successfully_imported == 3 assert len(result.errors) == 0 assert len(result.entries) == 3 - + # Check DNS entry dns_entry = next((e for e in result.entries if e.dns_name), None) assert dns_entry is not None assert dns_entry.dns_name == "dns-only.com" assert dns_entry.ip_address == "" - + # Check resolved entry resolved_entry = next((e for e in result.entries if e.resolved_ip), None) assert resolved_entry is not None @@ -254,35 +266,51 @@ class TestImportExportService: """Test importing from CSV format.""" # Create test CSV file csv_path = temp_dir / "test_import.csv" - with open(csv_path, 'w', newline='') as f: + with open(csv_path, "w", newline="") as f: writer = csv.writer(f) - writer.writerow([ - 'ip_address', 'hostnames', 'comment', 'is_active', - 'dns_name', 'resolved_ip', 'last_resolved', 'dns_resolution_status' - ]) - writer.writerow([ - '127.0.0.1', 'localhost', 'Local host', 'true', - '', '', '', '' - ]) - writer.writerow([ - '', 'dns-only.com', 'DNS only', 'false', - 'dns-only.com', '', '', '' - ]) - writer.writerow([ - '10.0.0.1', 'test.com example.com', 'Test server', 'true', - '', '10.0.0.1', '2024-01-15T12:00:00', 'IP_MATCH' - ]) - + writer.writerow( + [ + "ip_address", + "hostnames", + "comment", + "is_active", + "dns_name", + "resolved_ip", + "last_resolved", + "dns_resolution_status", + ] + ) + writer.writerow( + ["127.0.0.1", "localhost", "Local host", "true", "", "", "", ""] + ) + writer.writerow( + ["", "dns-only.com", "DNS only", "false", "dns-only.com", "", "", ""] + ) + writer.writerow( + [ + "10.0.0.1", + "test.com example.com", + "Test server", + "true", + "", + "10.0.0.1", + "2024-01-15T12:00:00", + "IP_MATCH", + ] + ) + result = service.import_csv_format(csv_path) - + assert result.success is True assert result.total_processed == 3 assert result.successfully_imported == 3 assert len(result.errors) == 0 assert len(result.entries) == 3 - + # Check multiple hostnames entry - multi_hostname_entry = next((e for e in result.entries if "test.com" in e.hostnames), None) + multi_hostname_entry = next( + (e for e in result.entries if "test.com" in e.hostnames), None + ) assert multi_hostname_entry is not None assert "example.com" in multi_hostname_entry.hostnames assert len(multi_hostname_entry.hostnames) == 2 @@ -292,11 +320,11 @@ class TestImportExportService: # Create invalid JSON file invalid_json = {"invalid": "format", "no_entries": True} json_path = temp_dir / "invalid.json" - with open(json_path, 'w') as f: + with open(json_path, "w") as f: json.dump(invalid_json, f) - + result = service.import_json_format(json_path) - + assert result.success is False assert result.total_processed == 0 assert result.successfully_imported == 0 @@ -307,9 +335,9 @@ class TestImportExportService: """Test importing malformed JSON.""" json_path = temp_dir / "malformed.json" json_path.write_text("{invalid json content") - + result = service.import_json_format(json_path) - + assert result.success is False assert result.total_processed == 0 assert result.successfully_imported == 0 @@ -319,13 +347,13 @@ class TestImportExportService: def test_import_csv_missing_required_columns(self, service, temp_dir): """Test importing CSV with missing required columns.""" csv_path = temp_dir / "missing_columns.csv" - with open(csv_path, 'w', newline='') as f: + with open(csv_path, "w", newline="") as f: writer = csv.writer(f) - writer.writerow(['ip_address', 'comment']) # Missing 'hostnames' - writer.writerow(['127.0.0.1', 'test']) - + writer.writerow(["ip_address", "comment"]) # Missing 'hostnames' + writer.writerow(["127.0.0.1", "test"]) + result = service.import_csv_format(csv_path) - + assert result.success is False assert result.total_processed == 0 assert result.successfully_imported == 0 @@ -341,17 +369,17 @@ class TestImportExportService: "hostnames": ["localhost"], "comment": "Test", "is_active": True, - "last_resolved": "invalid-date-format" + "last_resolved": "invalid-date-format", } ] } - + json_path = temp_dir / "warnings.json" - with open(json_path, 'w') as f: + with open(json_path, "w") as f: json.dump(json_data, f) - + result = service.import_json_format(json_path) - + assert result.success is True assert result.total_processed == 1 assert result.successfully_imported == 1 @@ -361,9 +389,9 @@ class TestImportExportService: def test_import_nonexistent_file(self, service): """Test importing non-existent file.""" nonexistent_path = Path("/nonexistent/file.json") - + result = service.import_json_format(nonexistent_path) - + assert result.success is False assert result.total_processed == 0 assert result.successfully_imported == 0 @@ -377,11 +405,11 @@ class TestImportExportService: csv_file = temp_dir / "test.csv" hosts_file = temp_dir / "hosts" txt_file = temp_dir / "test.txt" - + # Create empty files for f in [json_file, csv_file, hosts_file, txt_file]: f.touch() - + assert service.detect_file_format(json_file) == ImportFormat.JSON assert service.detect_file_format(csv_file) == ImportFormat.CSV assert service.detect_file_format(hosts_file) == ImportFormat.HOSTS @@ -393,15 +421,15 @@ class TestImportExportService: json_file = temp_dir / "no_extension" json_file.write_text('{"entries": []}') assert service.detect_file_format(json_file) == ImportFormat.JSON - + # CSV content csv_file = temp_dir / "csv_no_ext" - csv_file.write_text('ip_address,hostnames,comment') + csv_file.write_text("ip_address,hostnames,comment") assert service.detect_file_format(csv_file) == ImportFormat.CSV - + # Hosts content hosts_file = temp_dir / "hosts_no_ext" - hosts_file.write_text('127.0.0.1 localhost') + hosts_file.write_text("127.0.0.1 localhost") assert service.detect_file_format(hosts_file) == ImportFormat.HOSTS def test_detect_file_format_nonexistent(self, service): @@ -415,13 +443,13 @@ class TestImportExportService: valid_path = temp_dir / "export.json" warnings = service.validate_export_path(valid_path, ExportFormat.JSON) assert len(warnings) == 0 - + # Existing file existing_file = temp_dir / "existing.json" existing_file.touch() warnings = service.validate_export_path(existing_file, ExportFormat.JSON) assert any("already exists" in w for w in warnings) - + # Wrong extension wrong_ext = temp_dir / "file.txt" warnings = service.validate_export_path(wrong_ext, ExportFormat.JSON) @@ -438,22 +466,22 @@ class TestImportExportService: def test_export_import_roundtrip_json(self, service, sample_hosts_file, temp_dir): """Test export-import roundtrip for JSON format.""" export_path = temp_dir / "roundtrip.json" - + # Export export_result = service.export_json_format(sample_hosts_file, export_path) assert export_result.success is True - + # Import import_result = service.import_json_format(export_path) assert import_result.success is True assert import_result.successfully_imported == len(sample_hosts_file.entries) - + # Verify data integrity original_entries = sample_hosts_file.entries imported_entries = import_result.entries - + assert len(imported_entries) == len(original_entries) - + # Check specific entries for orig, imported in zip(original_entries, imported_entries): assert orig.ip_address == imported.ip_address @@ -465,11 +493,11 @@ class TestImportExportService: def test_export_import_roundtrip_csv(self, service, sample_hosts_file, temp_dir): """Test export-import roundtrip for CSV format.""" export_path = temp_dir / "roundtrip.csv" - + # Export export_result = service.export_csv_format(sample_hosts_file, export_path) assert export_result.success is True - + # Import import_result = service.import_csv_format(export_path) assert import_result.success is True @@ -484,11 +512,11 @@ class TestImportExportService: errors=["Error 1", "Error 2"], warnings=[], total_processed=5, - successfully_imported=0 + successfully_imported=0, ) assert result_with_errors.has_errors is True assert result_with_errors.has_warnings is False - + # Result with warnings result_with_warnings = ImportResult( success=True, @@ -496,7 +524,7 @@ class TestImportExportService: errors=[], warnings=["Warning 1"], total_processed=5, - successfully_imported=5 + successfully_imported=5, ) assert result_with_warnings.has_errors is False assert result_with_warnings.has_warnings is True @@ -505,15 +533,15 @@ class TestImportExportService: """Test exporting empty hosts file.""" empty_hosts_file = HostsFile() export_path = temp_dir / "empty.json" - + result = service.export_json_format(empty_hosts_file, export_path) - + assert result.success is True assert result.entries_exported == 0 assert export_path.exists() - + # Verify empty file structure - with open(export_path, 'r') as f: + with open(export_path, "r") as f: data = json.load(f) assert data["metadata"]["total_entries"] == 0 assert len(data["entries"]) == 0 @@ -521,24 +549,24 @@ class TestImportExportService: def test_large_hostnames_list_csv(self, service, temp_dir): """Test CSV export/import with large hostnames list.""" entry = HostEntry( - "192.168.1.1", + "192.168.1.1", ["host1.com", "host2.com", "host3.com", "host4.com", "host5.com"], "Multiple hostnames", - True + True, ) hosts_file = HostsFile() hosts_file.entries = [entry] - + export_path = temp_dir / "multi_hostnames.csv" - + # Export export_result = service.export_csv_format(hosts_file, export_path) assert export_result.success is True - + # Import import_result = service.import_csv_format(export_path) assert import_result.success is True - + imported_entry = import_result.entries[0] assert len(imported_entry.hostnames) == 5 assert "host1.com" in imported_entry.hostnames diff --git a/tests/test_main.py b/tests/test_main.py index 14de1fd..be80570 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -604,7 +604,7 @@ class TestHostsManagerApp: mock_radio_set.id = "edit-entry-type-radio" mock_pressed_radio = Mock() mock_pressed_radio.id = "edit-ip-entry-radio" - + event = Mock() event.radio_set = mock_radio_set event.pressed = mock_pressed_radio @@ -631,7 +631,7 @@ class TestHostsManagerApp: mock_radio_set.id = "edit-entry-type-radio" mock_pressed_radio = Mock() mock_pressed_radio.id = "edit-dns-entry-radio" - + event = Mock() event.radio_set = mock_radio_set event.pressed = mock_pressed_radio @@ -812,12 +812,12 @@ class TestHostsManagerApp: mock_radio_set = Mock() mock_ip_radio = Mock() mock_dns_radio = Mock() - + # Use a simple object to track value assignment class MockDNSInput: def __init__(self): self.value = "" - + mock_dns_input = MockDNSInput() def mock_query_one(selector, widget_type=None): @@ -833,14 +833,16 @@ class TestHostsManagerApp: app.query_one = mock_query_one app.edit_handler.handle_entry_type_change = Mock() - + # Mock the set_timer method to avoid event loop issues in tests - with patch.object(app, 'set_timer') as mock_set_timer: + with patch.object(app, "set_timer") as mock_set_timer: app.edit_handler.populate_edit_form_with_type_detection() - + # Verify timer was set with the correct callback - mock_set_timer.assert_called_once_with(0.1, app.edit_handler._delayed_radio_setup) - + mock_set_timer.assert_called_once_with( + 0.1, app.edit_handler._delayed_radio_setup + ) + # Manually call the delayed setup to test the actual logic app.edit_handler._delayed_radio_setup() @@ -946,21 +948,24 @@ class TestHostsManagerApp: app.manager.save_hosts_file = Mock(return_value=(True, "Success")) app.table_handler.populate_entries_table = Mock() app.details_handler.update_entry_details = Mock() - + # Create a mock that properly handles and closes coroutines def consume_coro(coro, **kwargs): # If it's a coroutine, close it to prevent warnings - if hasattr(coro, 'close'): + if hasattr(coro, "close"): coro.close() return None - + app.run_worker = Mock(side_effect=consume_coro) # Test action_refresh_dns in edit mode - should proceed app.action_refresh_dns() # Should not show error message about read-only mode - error_calls = [call for call in app.update_status.call_args_list - if "read-only mode" in str(call)] + error_calls = [ + call + for call in app.update_status.call_args_list + if "read-only mode" in str(call) + ] assert len(error_calls) == 0 # Should start DNS resolution app.run_worker.assert_called() @@ -972,8 +977,11 @@ class TestHostsManagerApp: # Test action_update_single_dns in edit mode - should proceed app.action_update_single_dns() # Should not show error message about read-only mode - error_calls = [call for call in app.update_status.call_args_list - if "read-only mode" in str(call)] + error_calls = [ + call + for call in app.update_status.call_args_list + if "read-only mode" in str(call) + ] assert len(error_calls) == 0 # Should start DNS resolution app.run_worker.assert_called()