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.
This commit is contained in:
Philip Henning 2026-09-03 12:47:11 +02:00
parent 0710d10fac
commit 7872991e0b
42 changed files with 2376 additions and 2735 deletions

View file

@ -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.

80
AGENTS.md Normal file
View file

@ -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 <path>` 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`.

View file

@ -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 <package>` - Add new dependency to project
- `uv run <command>` - 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_<module>.py` - Run specific test module
- `uv run pytest -k <test_name>` - 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_<module>.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.

65
CONTEXT.md Normal file
View file

@ -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

117
CONTRIBUTING.md Normal file
View file

@ -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.

21
LICENSE Normal file
View file

@ -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.

346
README.md
View file

@ -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)** - Browses, searches, and sorts active and inactive Host Entries.
- **Two-pane interface**: List view with detailed entry information - Hides or shows protected localhost and broadcasthost Default Entries.
- **Smart parsing**: Handles all real-world hosts file formats - Adds, edits, deletes, activates, deactivates, and reorders Host Entries.
- **Sorting capabilities**: Sort by IP address or hostname - Creates DNS Entries and manually refreshes their resolved IP addresses.
- **Filtering options**: Hide/show system default entries - Gates every `/etc/hosts` mutation behind an explicit Privileged Mode.
- **Search functionality**: Find entries by hostname, IP, or comment - Creates a timestamped Pre-edit Backup before Privileged Mode begins.
- **Configuration management**: Persistent settings with modal interface - Supports undo and redo during the current Privileged Mode session.
- **Live reload**: Automatically refresh when hosts file changes
### ✏️ **Edit Mode (Permission-Protected)** The application starts in Read-only Mode. Default Entries cannot be edited,
- **Safe editing**: Automatic backups before any modifications deleted, activated, deactivated, or moved.
- **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
### 🛡️ **Safety & Reliability** ## Requirements
- **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
## Installation - macOS or Linux with `/etc/hosts` and `sudo`
- Python 3.13 or newer
- [uv](https://docs.astral.sh/uv/)
### Prerequisites The project is designed for macOS and Linux, but does not yet publish a tested
- Python 3.13 or higher platform matrix.
- [uv](https://docs.astral.sh/uv/) package manager
## Run
The normal user entry point runs the current revision from the canonical
Forgejo repository:
### Run with uv
```bash ```bash
uvx git+https://git.s1q.dev/phg/hosts.git 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 ```bash
# Install uv if not already installed git clone https://git.s1q.dev/phg/hosts.git
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
cd hosts cd hosts
# Install development dependencies
uv sync uv sync
uv run hosts
# Run tests
uv run pytest
# Run linting
uv run ruff check
uv run ruff format
``` ```
### 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: Report bugs and request features in the
[Forgejo issue tracker](https://git.s1q.dev/phg/hosts/issues).
```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
## License ## License
This project is licensed under the MIT License - see the LICENSE file for details. Licensed under the [MIT License](./LICENSE).
## 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.

View file

@ -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.

View file

@ -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.

36
docs/agents/domain.md Normal file
View file

@ -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.

View file

@ -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 <number> -R phg/hosts`
- **Read an issue as JSON**:
`fgj --hostname git.s1q.dev issue view <number> -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 <number> -R phg/hosts --body "..."`
- **Apply or remove labels**:
`fgj --hostname git.s1q.dev issue edit <number> -R phg/hosts --add-label "..."`
or `--remove-label "..."`
- **Close an issue**:
`fgj --hostname git.s1q.dev issue close <number> -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 <number>` 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 #<map>` and whose
type is recorded with a `wayfinder:<type>` label (`research`, `prototype`,
`grilling`, or `task`).
- **Blocking**: put `Blocked by: #<number>, #<number>` 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.

View file

@ -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.

253
docs/user-guide.md Normal file
View file

@ -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.<timestamp>`:
```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.

15
favicon.svg Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" version="1.1" id="XMLID_104_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24" xml:space="preserve">
<g id="host-maintenance">
<g>
<polygon points="10,24 0,24 0,0 18,0 18,11 16,11 16,2 2,2 2,7 16,7 16,9 2,9 2,12 15.1,12 15.1,14 2,14 2,17 13,17 13,19 2,19 2,22 10,22 "/>
</g>
<g>
<rect x="12" y="3" width="3" height="3"/>
</g>
<g>
<path d="M22.7,14.7l-2,2l-1.4-1.4l2-2c-0.4-0.2-0.9-0.3-1.3-0.3c-0.8,0-1.5,0.3-2.1,0.9c-0.9,0.9-1.1,2.3-0.6,3.4l-5.1,5.1 l1.4,1.4l5.1-5.1c1.1,0.5,2.5,0.3,3.4-0.6c0.6-0.6,0.9-1.4,0.9-2.1C23,15.5,22.9,15.1,22.7,14.7z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 773 B

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -21,6 +21,7 @@ class OperationResult:
message: Human-readable description of the result message: Human-readable description of the result
data: Optional additional data about the operation data: Optional additional data about the operation
""" """
success: bool success: bool
message: str message: str
data: Optional[Dict[str, Any]] = None data: Optional[Dict[str, Any]] = None
@ -85,7 +86,9 @@ class UndoRedoHistory:
self.undo_stack: list[Command] = [] self.undo_stack: list[Command] = []
self.redo_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. """Execute a command and add it to the undo stack.
Args: Args:
@ -118,10 +121,7 @@ class UndoRedoHistory:
OperationResult from undo operation OperationResult from undo operation
""" """
if not self.can_undo(): if not self.can_undo():
return OperationResult( return OperationResult(success=False, message="No operations to undo")
success=False,
message="No operations to undo"
)
command = self.undo_stack.pop() command = self.undo_stack.pop()
result = command.undo(hosts_file) result = command.undo(hosts_file)
@ -149,10 +149,7 @@ class UndoRedoHistory:
OperationResult from redo operation OperationResult from redo operation
""" """
if not self.can_redo(): if not self.can_redo():
return OperationResult( return OperationResult(success=False, message="No operations to redo")
success=False,
message="No operations to redo"
)
command = self.redo_stack.pop() command = self.redo_stack.pop()
result = command.execute(hosts_file) result = command.execute(hosts_file)
@ -224,8 +221,7 @@ class ToggleEntryCommand(Command):
"""Toggle the entry's active state.""" """Toggle the entry's active state."""
if self.index < 0 or self.index >= len(hosts_file.entries): if self.index < 0 or self.index >= len(hosts_file.entries):
return OperationResult( return OperationResult(
success=False, success=False, message=f"Invalid entry index: {self.index}"
message=f"Invalid entry index: {self.index}"
) )
entry = hosts_file.entries[self.index] entry = hosts_file.entries[self.index]
@ -236,21 +232,19 @@ class ToggleEntryCommand(Command):
return OperationResult( return OperationResult(
success=True, success=True,
message=f"Entry {action}: {entry.ip_address} {' '.join(entry.hostnames)}", 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: def undo(self, hosts_file: "HostsFile") -> OperationResult:
"""Restore the entry's original active state.""" """Restore the entry's original active state."""
if self.original_state is None: if self.original_state is None:
return OperationResult( return OperationResult(
success=False, success=False, message="Cannot undo: original state not saved"
message="Cannot undo: original state not saved"
) )
if self.index < 0 or self.index >= len(hosts_file.entries): if self.index < 0 or self.index >= len(hosts_file.entries):
return OperationResult( return OperationResult(
success=False, success=False, message=f"Cannot undo: invalid entry index: {self.index}"
message=f"Cannot undo: invalid entry index: {self.index}"
) )
entry = hosts_file.entries[self.index] entry = hosts_file.entries[self.index]
@ -260,7 +254,7 @@ class ToggleEntryCommand(Command):
return OperationResult( return OperationResult(
success=True, success=True,
message=f"Undid toggle: entry {action}", 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: def get_description(self) -> str:
@ -283,18 +277,22 @@ class MoveEntryCommand(Command):
def execute(self, hosts_file: "HostsFile") -> OperationResult: def execute(self, hosts_file: "HostsFile") -> OperationResult:
"""Move the entry from one position to another.""" """Move the entry from one position to another."""
if (self.from_index < 0 or self.from_index >= len(hosts_file.entries) or if (
self.to_index < 0 or self.to_index >= len(hosts_file.entries)): 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( return OperationResult(
success=False, 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: if self.from_index == self.to_index:
return OperationResult( return OperationResult(
success=True, success=True,
message="No movement needed", 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 # Move the entry
@ -305,16 +303,23 @@ class MoveEntryCommand(Command):
return OperationResult( return OperationResult(
success=True, success=True,
message=f"Moved entry {direction}: {entry.ip_address} {' '.join(entry.hostnames)}", 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: def undo(self, hosts_file: "HostsFile") -> OperationResult:
"""Move the entry back to its original position.""" """Move the entry back to its original position."""
if (self.to_index < 0 or self.to_index >= len(hosts_file.entries) or if (
self.from_index < 0 or self.from_index >= len(hosts_file.entries)): 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( return OperationResult(
success=False, success=False, message="Cannot undo move: invalid indices"
message="Cannot undo move: invalid indices"
) )
# Move back: from to_index back to from_index # Move back: from to_index back to from_index
@ -325,7 +330,7 @@ class MoveEntryCommand(Command):
return OperationResult( return OperationResult(
success=True, success=True,
message=f"Undid move: moved entry {direction}", message=f"Undid move: moved entry {direction}",
data={"restored_index": self.from_index} data={"restored_index": self.from_index},
) )
def get_description(self) -> str: def get_description(self) -> str:
@ -358,8 +363,7 @@ class AddEntryCommand(Command):
# Insert at specific position # Insert at specific position
if self.index < 0 or self.index > len(hosts_file.entries): if self.index < 0 or self.index > len(hosts_file.entries):
return OperationResult( return OperationResult(
success=False, success=False, message=f"Invalid insertion index: {self.index}"
message=f"Invalid insertion index: {self.index}"
) )
hosts_file.entries.insert(self.index, self.entry) hosts_file.entries.insert(self.index, self.entry)
self.actual_index = self.index self.actual_index = self.index
@ -367,30 +371,31 @@ class AddEntryCommand(Command):
return OperationResult( return OperationResult(
success=True, success=True,
message=f"Added entry: {self.entry.ip_address} {' '.join(self.entry.hostnames)}", 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: def undo(self, hosts_file: "HostsFile") -> OperationResult:
"""Remove the added entry.""" """Remove the added entry."""
if self.actual_index is None: if self.actual_index is None:
return OperationResult( return OperationResult(
success=False, success=False, message="Cannot undo: entry index not recorded"
message="Cannot undo: entry index not recorded"
) )
if self.actual_index < 0 or self.actual_index >= len(hosts_file.entries): if self.actual_index < 0 or self.actual_index >= len(hosts_file.entries):
return OperationResult( return OperationResult(
success=False, 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 # Verify we're removing the right entry
entry_to_remove = hosts_file.entries[self.actual_index] entry_to_remove = hosts_file.entries[self.actual_index]
if (entry_to_remove.ip_address != self.entry.ip_address or if (
entry_to_remove.hostnames != self.entry.hostnames): entry_to_remove.ip_address != self.entry.ip_address
or entry_to_remove.hostnames != self.entry.hostnames
):
return OperationResult( return OperationResult(
success=False, 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) hosts_file.entries.pop(self.actual_index)
@ -398,7 +403,7 @@ class AddEntryCommand(Command):
return OperationResult( return OperationResult(
success=True, success=True,
message=f"Undid add: removed entry {self.entry.ip_address}", 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: def get_description(self) -> str:
@ -422,8 +427,7 @@ class DeleteEntryCommand(Command):
"""Delete the entry from the hosts file.""" """Delete the entry from the hosts file."""
if self.index < 0 or self.index >= len(hosts_file.entries): if self.index < 0 or self.index >= len(hosts_file.entries):
return OperationResult( return OperationResult(
success=False, success=False, message=f"Invalid entry index: {self.index}"
message=f"Invalid entry index: {self.index}"
) )
self.deleted_entry = hosts_file.entries.pop(self.index) self.deleted_entry = hosts_file.entries.pop(self.index)
@ -431,21 +435,20 @@ class DeleteEntryCommand(Command):
return OperationResult( return OperationResult(
success=True, success=True,
message=f"Deleted entry: {self.deleted_entry.ip_address} {' '.join(self.deleted_entry.hostnames)}", 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: def undo(self, hosts_file: "HostsFile") -> OperationResult:
"""Restore the deleted entry.""" """Restore the deleted entry."""
if self.deleted_entry is None: if self.deleted_entry is None:
return OperationResult( return OperationResult(
success=False, success=False, message="Cannot undo: deleted entry not saved"
message="Cannot undo: deleted entry not saved"
) )
if self.index < 0 or self.index > len(hosts_file.entries): if self.index < 0 or self.index > len(hosts_file.entries):
return OperationResult( return OperationResult(
success=False, 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) hosts_file.entries.insert(self.index, self.deleted_entry)
@ -453,7 +456,7 @@ class DeleteEntryCommand(Command):
return OperationResult( return OperationResult(
success=True, success=True,
message=f"Undid delete: restored entry {self.deleted_entry.ip_address}", 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: def get_description(self) -> str:
@ -466,8 +469,14 @@ class DeleteEntryCommand(Command):
class UpdateEntryCommand(Command): class UpdateEntryCommand(Command):
"""Command to update an entry.""" """Command to update an entry."""
def __init__(self, index: int, new_ip: str, new_hostnames: list[str], def __init__(
new_comment: Optional[str], new_active: bool): self,
index: int,
new_ip: str,
new_hostnames: list[str],
new_comment: Optional[str],
new_active: bool,
):
"""Initialize the update command. """Initialize the update command.
Args: Args:
@ -488,18 +497,18 @@ class UpdateEntryCommand(Command):
"""Update the entry with new values.""" """Update the entry with new values."""
if self.index < 0 or self.index >= len(hosts_file.entries): if self.index < 0 or self.index >= len(hosts_file.entries):
return OperationResult( return OperationResult(
success=False, success=False, message=f"Invalid entry index: {self.index}"
message=f"Invalid entry index: {self.index}"
) )
# Save original entry for undo # Save original entry for undo
from .models import HostEntry from .models import HostEntry
original = hosts_file.entries[self.index] original = hosts_file.entries[self.index]
self.original_entry = HostEntry( self.original_entry = HostEntry(
ip_address=original.ip_address, ip_address=original.ip_address,
hostnames=original.hostnames.copy(), hostnames=original.hostnames.copy(),
comment=original.comment, comment=original.comment,
is_active=original.is_active is_active=original.is_active,
) )
# Update the entry # Update the entry
@ -512,21 +521,19 @@ class UpdateEntryCommand(Command):
return OperationResult( return OperationResult(
success=True, success=True,
message=f"Updated entry: {entry.ip_address} {' '.join(entry.hostnames)}", 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: def undo(self, hosts_file: "HostsFile") -> OperationResult:
"""Restore the entry's original values.""" """Restore the entry's original values."""
if self.original_entry is None: if self.original_entry is None:
return OperationResult( return OperationResult(
success=False, success=False, message="Cannot undo: original entry not saved"
message="Cannot undo: original entry not saved"
) )
if self.index < 0 or self.index >= len(hosts_file.entries): if self.index < 0 or self.index >= len(hosts_file.entries):
return OperationResult( return OperationResult(
success=False, success=False, message=f"Cannot undo: invalid entry index: {self.index}"
message=f"Cannot undo: invalid entry index: {self.index}"
) )
# Restore original values # Restore original values
@ -539,7 +546,7 @@ class UpdateEntryCommand(Command):
return OperationResult( return OperationResult(
success=True, success=True,
message=f"Undid update: restored entry {entry.ip_address}", 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: def get_description(self) -> str:

View file

@ -201,7 +201,9 @@ class Config:
def get_export_directory(self) -> str: def get_export_directory(self) -> str:
"""Get default export directory.""" """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: def set_default_export_format(self, format_name: str) -> None:
"""Set default export format.""" """Set default export format."""

View file

@ -18,6 +18,7 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class DNSResolutionStatus(Enum): class DNSResolutionStatus(Enum):
"""Status of DNS resolution for an entry.""" """Status of DNS resolution for an entry."""
NOT_RESOLVED = "not_resolved" NOT_RESOLVED = "not_resolved"
RESOLVING = "resolving" RESOLVING = "resolving"
RESOLVED = "resolved" RESOLVED = "resolved"
@ -29,6 +30,7 @@ class DNSResolutionStatus(Enum):
@dataclass @dataclass
class DNSResolution: class DNSResolution:
"""Result of DNS resolution for a hostname.""" """Result of DNS resolution for a hostname."""
hostname: str hostname: str
resolved_ip: Optional[str] resolved_ip: Optional[str]
status: DNSResolutionStatus status: DNSResolutionStatus
@ -37,7 +39,9 @@ class DNSResolution:
def is_success(self) -> bool: def is_success(self) -> bool:
"""Check if resolution was successful.""" """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: def get_age_seconds(self) -> float:
"""Get age of resolution in seconds.""" """Get age of resolution in seconds."""
@ -60,8 +64,7 @@ async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution
# Use asyncio DNS resolution with timeout # Use asyncio DNS resolution with timeout
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
result = await asyncio.wait_for( result = await asyncio.wait_for(
loop.getaddrinfo(hostname, None, family=socket.AF_UNSPEC), loop.getaddrinfo(hostname, None, family=socket.AF_UNSPEC), timeout=timeout
timeout=timeout
) )
if result: if result:
@ -71,7 +74,7 @@ async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution
hostname=hostname, hostname=hostname,
resolved_ip=ip_address, resolved_ip=ip_address,
status=DNSResolutionStatus.RESOLVED, status=DNSResolutionStatus.RESOLVED,
resolved_at=start_time resolved_at=start_time,
) )
else: else:
return DNSResolution( return DNSResolution(
@ -79,7 +82,7 @@ async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution
resolved_ip=None, resolved_ip=None,
status=DNSResolutionStatus.RESOLUTION_FAILED, status=DNSResolutionStatus.RESOLUTION_FAILED,
resolved_at=start_time, resolved_at=start_time,
error_message="No address found" error_message="No address found",
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
@ -88,7 +91,7 @@ async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution
resolved_ip=None, resolved_ip=None,
status=DNSResolutionStatus.RESOLUTION_FAILED, status=DNSResolutionStatus.RESOLUTION_FAILED,
resolved_at=start_time, resolved_at=start_time,
error_message=f"Timeout after {timeout}s" error_message=f"Timeout after {timeout}s",
) )
except Exception as e: except Exception as e:
return DNSResolution( return DNSResolution(
@ -96,11 +99,13 @@ async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution
resolved_ip=None, resolved_ip=None,
status=DNSResolutionStatus.RESOLUTION_FAILED, status=DNSResolutionStatus.RESOLUTION_FAILED,
resolved_at=start_time, 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. """Resolve multiple hostnames concurrently.
Args: Args:
@ -120,13 +125,15 @@ async def resolve_hostnames_batch(hostnames: List[str], timeout: float = 5.0) ->
resolutions = [] resolutions = []
for i, result in enumerate(results): for i, result in enumerate(results):
if isinstance(result, Exception): if isinstance(result, Exception):
resolutions.append(DNSResolution( resolutions.append(
DNSResolution(
hostname=hostnames[i], hostname=hostnames[i],
resolved_ip=None, resolved_ip=None,
status=DNSResolutionStatus.RESOLUTION_FAILED, status=DNSResolutionStatus.RESOLUTION_FAILED,
resolved_at=datetime.now(), resolved_at=datetime.now(),
error_message=str(result) error_message=str(result),
)) )
)
else: else:
resolutions.append(result) resolutions.append(result)
@ -136,11 +143,7 @@ async def resolve_hostnames_batch(hostnames: List[str], timeout: float = 5.0) ->
class DNSService: class DNSService:
"""DNS resolution service for hosts entries.""" """DNS resolution service for hosts entries."""
def __init__( def __init__(self, enabled: bool = True, timeout: float = 5.0):
self,
enabled: bool = True,
timeout: float = 5.0
):
"""Initialize DNS service. """Initialize DNS service.
Args: Args:
@ -165,7 +168,7 @@ class DNSService:
resolved_ip=None, resolved_ip=None,
status=DNSResolutionStatus.NOT_RESOLVED, status=DNSResolutionStatus.NOT_RESOLVED,
resolved_at=datetime.now(), resolved_at=datetime.now(),
error_message="DNS resolution is disabled" error_message="DNS resolution is disabled",
) )
return await resolve_hostname(hostname, self.timeout) return await resolve_hostname(hostname, self.timeout)
@ -197,7 +200,7 @@ class DNSService:
resolved_ip=None, resolved_ip=None,
status=DNSResolutionStatus.NOT_RESOLVED, status=DNSResolutionStatus.NOT_RESOLVED,
resolved_at=datetime.now(), resolved_at=datetime.now(),
error_message="DNS resolution is disabled" error_message="DNS resolution is disabled",
) )
for hostname in hostnames for hostname in hostnames
] ]

View file

@ -14,6 +14,7 @@ from .models import HostEntry
class FilterType(Enum): class FilterType(Enum):
"""Filter type enumeration.""" """Filter type enumeration."""
STATUS = "status" STATUS = "status"
DNS_TYPE = "dns_type" DNS_TYPE = "dns_type"
RESOLUTION_STATUS = "resolution_status" RESOLUTION_STATUS = "resolution_status"
@ -23,6 +24,7 @@ class FilterType(Enum):
@dataclass @dataclass
class FilterOptions: class FilterOptions:
"""Configuration options for filtering entries.""" """Configuration options for filtering entries."""
# Status filtering # Status filtering
show_active: bool = True show_active: bool = True
show_inactive: bool = True show_inactive: bool = True
@ -57,67 +59,75 @@ class FilterOptions:
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert FilterOptions to dictionary.""" """Convert FilterOptions to dictionary."""
return { return {
'show_active': self.show_active, "show_active": self.show_active,
'show_inactive': self.show_inactive, "show_inactive": self.show_inactive,
'active_only': self.active_only, "active_only": self.active_only,
'inactive_only': self.inactive_only, "inactive_only": self.inactive_only,
'show_dns_entries': self.show_dns_entries, "show_dns_entries": self.show_dns_entries,
'show_ip_entries': self.show_ip_entries, "show_ip_entries": self.show_ip_entries,
'dns_only': self.dns_only, "dns_only": self.dns_only,
'ip_only': self.ip_only, "ip_only": self.ip_only,
'show_resolved': self.show_resolved, "show_resolved": self.show_resolved,
'show_unresolved': self.show_unresolved, "show_unresolved": self.show_unresolved,
'show_resolving': self.show_resolving, "show_resolving": self.show_resolving,
'show_failed': self.show_failed, "show_failed": self.show_failed,
'show_mismatched': self.show_mismatched, "show_mismatched": self.show_mismatched,
'mismatch_only': self.mismatch_only, "mismatch_only": self.mismatch_only,
'resolved_only': self.resolved_only, "resolved_only": self.resolved_only,
'search_term': self.search_term or "", "search_term": self.search_term or "",
'search_in_hostnames': self.search_in_hostnames, "search_in_hostnames": self.search_in_hostnames,
'search_in_comments': self.search_in_comments, "search_in_comments": self.search_in_comments,
'search_in_ips': self.search_in_ips, "search_in_ips": self.search_in_ips,
'case_sensitive': self.case_sensitive, "case_sensitive": self.case_sensitive,
'preset_name': self.preset_name "preset_name": self.preset_name,
} }
@classmethod @classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'FilterOptions': def from_dict(cls, data: Dict[str, Any]) -> "FilterOptions":
"""Create FilterOptions from dictionary.""" """Create FilterOptions from dictionary."""
return cls( return cls(
show_active=data.get('show_active', True), show_active=data.get("show_active", True),
show_inactive=data.get('show_inactive', True), show_inactive=data.get("show_inactive", True),
active_only=data.get('active_only', False), active_only=data.get("active_only", False),
inactive_only=data.get('inactive_only', False), inactive_only=data.get("inactive_only", False),
show_dns_entries=data.get('show_dns_entries', True), show_dns_entries=data.get("show_dns_entries", True),
show_ip_entries=data.get('show_ip_entries', True), show_ip_entries=data.get("show_ip_entries", True),
dns_only=data.get('dns_only', False), dns_only=data.get("dns_only", False),
ip_only=data.get('ip_only', False), ip_only=data.get("ip_only", False),
show_resolved=data.get('show_resolved', True), show_resolved=data.get("show_resolved", True),
show_unresolved=data.get('show_unresolved', True), show_unresolved=data.get("show_unresolved", True),
show_resolving=data.get('show_resolving', True), show_resolving=data.get("show_resolving", True),
show_failed=data.get('show_failed', True), show_failed=data.get("show_failed", True),
show_mismatched=data.get('show_mismatched', True), show_mismatched=data.get("show_mismatched", True),
mismatch_only=data.get('mismatch_only', False), mismatch_only=data.get("mismatch_only", False),
resolved_only=data.get('resolved_only', False), resolved_only=data.get("resolved_only", False),
search_term=data.get('search_term', None), search_term=data.get("search_term", None),
search_in_hostnames=data.get('search_in_hostnames', True), search_in_hostnames=data.get("search_in_hostnames", True),
search_in_comments=data.get('search_in_comments', True), search_in_comments=data.get("search_in_comments", True),
search_in_ips=data.get('search_in_ips', True), search_in_ips=data.get("search_in_ips", True),
case_sensitive=data.get('case_sensitive', False), case_sensitive=data.get("case_sensitive", False),
preset_name=data.get('preset_name', None) preset_name=data.get("preset_name", None),
) )
def is_empty(self) -> bool: def is_empty(self) -> bool:
"""Check if filter options represent no filtering (default state).""" """Check if filter options represent no filtering (default state)."""
return ( return (
self.show_active and self.show_inactive and self.show_active
not self.active_only and not self.inactive_only and and self.show_inactive
self.show_dns_entries and self.show_ip_entries and and not self.active_only
not self.dns_only and not self.ip_only and and not self.inactive_only
self.show_resolved and self.show_unresolved and and self.show_dns_entries
self.show_resolving and self.show_failed and self.show_mismatched and and self.show_ip_entries
not self.mismatch_only and not self.resolved_only and and not self.dns_only
not self.search_term 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
) )
@ -133,39 +143,25 @@ class EntryFilter:
"""Load default filter presets.""" """Load default filter presets."""
self.presets = { self.presets = {
"All Entries": FilterOptions(), "All Entries": FilterOptions(),
"Active Only": FilterOptions( "Active Only": FilterOptions(show_inactive=False, active_only=True),
show_inactive=False, "Inactive Only": FilterOptions(show_active=False, inactive_only=True),
active_only=True "DNS Entries Only": FilterOptions(show_ip_entries=False, dns_only=True),
), "IP Entries Only": FilterOptions(show_dns_entries=False, ip_only=True),
"Inactive Only": FilterOptions( "DNS Mismatches": FilterOptions(mismatch_only=True),
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( "Resolution Failed": FilterOptions(
show_resolved=False, show_resolved=False,
show_unresolved=False, show_unresolved=False,
show_resolving=False, show_resolving=False,
show_mismatched=False show_mismatched=False,
), ),
"Needs Resolution": FilterOptions( "Needs Resolution": FilterOptions(
show_resolved=False, show_resolved=False, show_failed=False, show_mismatched=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. Apply all filter criteria to the list of entries.
@ -179,16 +175,30 @@ class EntryFilter:
filtered_entries = entries.copy() filtered_entries = entries.copy()
# Apply status filtering # 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) filtered_entries = self.filter_by_status(filtered_entries, options)
# Apply DNS type filtering # 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) filtered_entries = self.filter_by_dns_type(filtered_entries, options)
# Apply DNS resolution status filtering # Apply DNS resolution status filtering
if options.mismatch_only or options.resolved_only or not self._all_resolution_status_shown(options): if (
filtered_entries = self.filter_by_resolution_status(filtered_entries, options) 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 # Apply search filtering
if options.search_term: if options.search_term:
@ -196,7 +206,9 @@ class EntryFilter:
return filtered_entries 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. Filter entries by active/inactive status.
@ -221,7 +233,9 @@ class EntryFilter:
filtered.append(entry) filtered.append(entry)
return filtered 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. Filter entries by DNS name vs IP address type.
@ -246,7 +260,9 @@ class EntryFilter:
filtered.append(entry) filtered.append(entry)
return filtered 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. Filter entries by DNS resolution status.
@ -258,27 +274,37 @@ class EntryFilter:
Filtered list of entries Filtered list of entries
""" """
if options.mismatch_only: if options.mismatch_only:
return [entry for entry in entries return [
if entry.dns_resolution_status == "IP_MISMATCH"] entry
for entry in entries
if entry.dns_resolution_status == "IP_MISMATCH"
]
elif options.resolved_only: elif options.resolved_only:
return [entry for entry in entries return [
if entry.dns_resolution_status in ["IP_MATCH", "RESOLVED"]] entry
for entry in entries
if entry.dns_resolution_status in ["IP_MATCH", "RESOLVED"]
]
else: else:
# Show based on individual flags # Show based on individual flags
filtered = [] filtered = []
for entry in entries: for entry in entries:
status = entry.dns_resolution_status or "NOT_RESOLVED" status = entry.dns_resolution_status or "NOT_RESOLVED"
if (status == "NOT_RESOLVED" and options.show_unresolved) or \ if (
(status == "RESOLVING" and options.show_resolving) or \ (status == "NOT_RESOLVED" and options.show_unresolved)
(status in ["IP_MATCH", "RESOLVED"] and options.show_resolved) or \ or (status == "RESOLVING" and options.show_resolving)
(status == "RESOLUTION_FAILED" and options.show_failed) or \ or (status in ["IP_MATCH", "RESOLVED"] and options.show_resolved)
(status == "IP_MISMATCH" and options.show_mismatched): or (status == "RESOLUTION_FAILED" and options.show_failed)
or (status == "IP_MISMATCH" and options.show_mismatched)
):
filtered.append(entry) filtered.append(entry)
return filtered 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. Filter entries by search term.
@ -333,9 +359,13 @@ class EntryFilter:
def _all_resolution_status_shown(self, options: FilterOptions) -> bool: def _all_resolution_status_shown(self, options: FilterOptions) -> bool:
"""Check if all resolution status types are shown.""" """Check if all resolution status types are shown."""
return (options.show_resolved and options.show_unresolved and return (
options.show_resolving and options.show_failed and options.show_resolved
options.show_mismatched) 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: def save_preset(self, name: str, options: FilterOptions) -> None:
""" """
@ -367,7 +397,7 @@ class EntryFilter:
search_in_comments=options.search_in_comments, search_in_comments=options.search_in_comments,
search_in_ips=options.search_in_ips, search_in_ips=options.search_in_ips,
case_sensitive=options.case_sensitive, case_sensitive=options.case_sensitive,
preset_name=name preset_name=name,
) )
self.presets[name] = preset_options self.presets[name] = preset_options
@ -416,34 +446,18 @@ class EntryFilter:
""" """
return { return {
"All Entries": FilterOptions(), "All Entries": FilterOptions(),
"Active Only": FilterOptions( "Active Only": FilterOptions(show_inactive=False, active_only=True),
show_inactive=False, "Inactive Only": FilterOptions(show_active=False, inactive_only=True),
active_only=True "DNS Entries Only": FilterOptions(show_ip_entries=False, dns_only=True),
), "IP Entries Only": FilterOptions(show_dns_entries=False, ip_only=True),
"Inactive Only": FilterOptions( "DNS Mismatches": FilterOptions(mismatch_only=True),
show_active=False, "Resolved Entries": FilterOptions(resolved_only=True),
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( "Unresolved Entries": FilterOptions(
show_resolved=False, show_resolved=False,
show_resolving=False, show_resolving=False,
show_failed=False, show_failed=False,
show_mismatched=False show_mismatched=False,
) ),
} }
def get_saved_presets(self) -> Dict[str, FilterOptions]: def get_saved_presets(self) -> Dict[str, FilterOptions]:
@ -455,7 +469,9 @@ class EntryFilter:
""" """
return self.presets.copy() 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. Count entries by category for the given filter options.
@ -479,16 +495,33 @@ class EntryFilter:
ip_count = filtered_count - dns_count ip_count = filtered_count - dns_count
# Count by resolution status # Count by resolution status
resolved_count = len([e for e in filtered_entries resolved_count = len(
if e.dns_resolution_status in ["IP_MATCH", "RESOLVED"]]) [
unresolved_count = len([e for e in filtered_entries e
if e.dns_resolution_status in [None, "NOT_RESOLVED"]]) for e in filtered_entries
resolving_count = len([e for e in filtered_entries if e.dns_resolution_status in ["IP_MATCH", "RESOLVED"]
if e.dns_resolution_status == "RESOLVING"]) ]
failed_count = len([e for e in filtered_entries )
if e.dns_resolution_status == "RESOLUTION_FAILED"]) unresolved_count = len(
mismatch_count = len([e for e in filtered_entries [
if e.dns_resolution_status == "IP_MISMATCH"]) 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 { return {
"total": total_entries, "total": total_entries,
@ -501,5 +534,5 @@ class EntryFilter:
"unresolved": unresolved_count, "unresolved": unresolved_count,
"resolving": resolving_count, "resolving": resolving_count,
"failed": failed_count, "failed": failed_count,
"mismatched": mismatch_count "mismatched": mismatch_count,
} }

View file

@ -15,21 +15,27 @@ from datetime import datetime
from .models import HostEntry, HostsFile from .models import HostEntry, HostsFile
class ExportFormat(Enum): class ExportFormat(Enum):
"""Supported export formats.""" """Supported export formats."""
HOSTS = "hosts" HOSTS = "hosts"
JSON = "json" JSON = "json"
CSV = "csv" CSV = "csv"
class ImportFormat(Enum): class ImportFormat(Enum):
"""Supported import formats.""" """Supported import formats."""
HOSTS = "hosts" HOSTS = "hosts"
JSON = "json" JSON = "json"
CSV = "csv" CSV = "csv"
@dataclass @dataclass
class ImportResult: class ImportResult:
"""Result of an import operation.""" """Result of an import operation."""
success: bool success: bool
entries: List[HostEntry] entries: List[HostEntry]
errors: List[str] errors: List[str]
@ -47,22 +53,33 @@ class ImportResult:
"""Check if import had any warnings.""" """Check if import had any warnings."""
return len(self.warnings) > 0 return len(self.warnings) > 0
@dataclass @dataclass
class ExportResult: class ExportResult:
"""Result of an export operation.""" """Result of an export operation."""
success: bool success: bool
file_path: Path file_path: Path
entries_exported: int entries_exported: int
errors: List[str] errors: List[str]
format: ExportFormat format: ExportFormat
class ImportExportService: class ImportExportService:
"""Handle multiple file format operations for hosts entries.""" """Handle multiple file format operations for hosts entries."""
def __init__(self): def __init__(self):
"""Initialize the import/export service.""" """Initialize the import/export service."""
self.supported_export_formats = [ExportFormat.HOSTS, ExportFormat.JSON, ExportFormat.CSV] self.supported_export_formats = [
self.supported_import_formats = [ImportFormat.HOSTS, ImportFormat.JSON, ImportFormat.CSV] ExportFormat.HOSTS,
ExportFormat.JSON,
ExportFormat.CSV,
]
self.supported_import_formats = [
ImportFormat.HOSTS,
ImportFormat.JSON,
ImportFormat.CSV,
]
# Export Methods # Export Methods
@ -85,7 +102,7 @@ class ImportExportService:
content = parser.serialize(hosts_file) content = parser.serialize(hosts_file)
# Write the content to 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) f.write(content)
return ExportResult( return ExportResult(
@ -93,7 +110,7 @@ class ImportExportService:
file_path=path, file_path=path,
entries_exported=len(hosts_file.entries), entries_exported=len(hosts_file.entries),
errors=[], errors=[],
format=ExportFormat.HOSTS format=ExportFormat.HOSTS,
) )
except Exception as e: except Exception as e:
return ExportResult( return ExportResult(
@ -101,7 +118,7 @@ class ImportExportService:
file_path=path, file_path=path,
entries_exported=0, entries_exported=0,
errors=[f"Failed to export hosts format: {str(e)}"], 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: def export_json_format(self, hosts_file: HostsFile, path: Path) -> ExportResult:
@ -121,9 +138,9 @@ class ImportExportService:
"exported_at": datetime.now().isoformat(), "exported_at": datetime.now().isoformat(),
"total_entries": len(hosts_file.entries), "total_entries": len(hosts_file.entries),
"version": "1.0", "version": "1.0",
"format": "hosts_json_export" "format": "hosts_json_export",
}, },
"entries": [] "entries": [],
} }
for entry in hosts_file.entries: for entry in hosts_file.entries:
@ -131,7 +148,7 @@ class ImportExportService:
"ip_address": entry.ip_address, "ip_address": entry.ip_address,
"hostnames": entry.hostnames, "hostnames": entry.hostnames,
"comment": entry.comment, "comment": entry.comment,
"is_active": entry.is_active "is_active": entry.is_active,
} }
# Add DNS fields if present # Add DNS fields if present
@ -146,7 +163,7 @@ class ImportExportService:
export_data["entries"].append(entry_data) 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) json.dump(export_data, f, indent=2, ensure_ascii=False)
return ExportResult( return ExportResult(
@ -154,7 +171,7 @@ class ImportExportService:
file_path=path, file_path=path,
entries_exported=len(hosts_file.entries), entries_exported=len(hosts_file.entries),
errors=[], errors=[],
format=ExportFormat.JSON format=ExportFormat.JSON,
) )
except Exception as e: except Exception as e:
@ -163,7 +180,7 @@ class ImportExportService:
file_path=path, file_path=path,
entries_exported=0, entries_exported=0,
errors=[f"Failed to export JSON format: {str(e)}"], 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: def export_csv_format(self, hosts_file: HostsFile, path: Path) -> ExportResult:
@ -179,24 +196,32 @@ class ImportExportService:
""" """
try: try:
fieldnames = [ fieldnames = [
'ip_address', 'hostnames', 'comment', 'is_active', "ip_address",
'dns_name', 'resolved_ip', 'last_resolved', 'dns_resolution_status' "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 = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader() writer.writeheader()
for entry in hosts_file.entries: for entry in hosts_file.entries:
row_data = { row_data = {
'ip_address': entry.ip_address, "ip_address": entry.ip_address,
'hostnames': ' '.join(entry.hostnames), "hostnames": " ".join(entry.hostnames),
'comment': entry.comment or '', "comment": entry.comment or "",
'is_active': entry.is_active, "is_active": entry.is_active,
'dns_name': entry.dns_name or '', "dns_name": entry.dns_name or "",
'resolved_ip': entry.resolved_ip or '', "resolved_ip": entry.resolved_ip or "",
'last_resolved': entry.last_resolved.isoformat() if entry.last_resolved else '', "last_resolved": entry.last_resolved.isoformat()
'dns_resolution_status': entry.dns_resolution_status or '' if entry.last_resolved
else "",
"dns_resolution_status": entry.dns_resolution_status or "",
} }
writer.writerow(row_data) writer.writerow(row_data)
@ -205,7 +230,7 @@ class ImportExportService:
file_path=path, file_path=path,
entries_exported=len(hosts_file.entries), entries_exported=len(hosts_file.entries),
errors=[], errors=[],
format=ExportFormat.CSV format=ExportFormat.CSV,
) )
except Exception as e: except Exception as e:
@ -214,7 +239,7 @@ class ImportExportService:
file_path=path, file_path=path,
entries_exported=0, entries_exported=0,
errors=[f"Failed to export CSV format: {str(e)}"], errors=[f"Failed to export CSV format: {str(e)}"],
format=ExportFormat.CSV format=ExportFormat.CSV,
) )
# Import Methods # Import Methods
@ -241,7 +266,7 @@ class ImportExportService:
errors=[], errors=[],
warnings=[], warnings=[],
total_processed=len(hosts_file.entries), total_processed=len(hosts_file.entries),
successfully_imported=len(hosts_file.entries) successfully_imported=len(hosts_file.entries),
) )
except Exception as e: except Exception as e:
@ -251,7 +276,7 @@ class ImportExportService:
errors=[f"Failed to import hosts format: {str(e)}"], errors=[f"Failed to import hosts format: {str(e)}"],
warnings=[], warnings=[],
total_processed=0, total_processed=0,
successfully_imported=0 successfully_imported=0,
) )
def import_json_format(self, path: Path) -> ImportResult: def import_json_format(self, path: Path) -> ImportResult:
@ -265,47 +290,47 @@ class ImportExportService:
ImportResult with imported entries and any errors ImportResult with imported entries and any errors
""" """
try: try:
with open(path, 'r', encoding='utf-8') as f: with open(path, "r", encoding="utf-8") as f:
data = json.load(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( return ImportResult(
success=False, success=False,
entries=[], entries=[],
errors=["Invalid JSON format: missing 'entries' field"], errors=["Invalid JSON format: missing 'entries' field"],
warnings=[], warnings=[],
total_processed=0, total_processed=0,
successfully_imported=0 successfully_imported=0,
) )
entries = [] entries = []
errors = [] errors = []
warnings = [] warnings = []
total_processed = len(data['entries']) total_processed = len(data["entries"])
for i, entry_data in enumerate(data['entries']): for i, entry_data in enumerate(data["entries"]):
try: try:
# Validate required fields # Validate required fields
if not isinstance(entry_data, dict): 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 continue
if 'hostnames' not in entry_data or not entry_data['hostnames']: if "hostnames" not in entry_data or not entry_data["hostnames"]:
errors.append(f"Entry {i + 1}: Missing hostnames field") errors.append(f"Entry {i + 1}: Missing hostnames field")
continue continue
# Handle DNS vs IP entries # Handle DNS vs IP entries
dns_name = entry_data.get('dns_name', '') dns_name = entry_data.get("dns_name", "")
ip_address = entry_data.get('ip_address', '') ip_address = entry_data.get("ip_address", "")
# Create entry with temporary IP if it's a DNS-only entry # Create entry with temporary IP if it's a DNS-only entry
if dns_name and not ip_address: if dns_name and not ip_address:
# Create with temporary IP, then convert to DNS entry # Create with temporary IP, then convert to DNS entry
entry = HostEntry( entry = HostEntry(
ip_address="127.0.0.1", # Temporary IP ip_address="127.0.0.1", # Temporary IP
hostnames=entry_data['hostnames'], hostnames=entry_data["hostnames"],
comment=entry_data.get('comment', ''), comment=entry_data.get("comment", ""),
is_active=entry_data.get('is_active', True) is_active=entry_data.get("is_active", True),
) )
# Convert to DNS entry # Convert to DNS entry
entry.ip_address = "" entry.ip_address = ""
@ -314,22 +339,28 @@ class ImportExportService:
# Regular IP entry # Regular IP entry
entry = HostEntry( entry = HostEntry(
ip_address=ip_address, ip_address=ip_address,
hostnames=entry_data['hostnames'], hostnames=entry_data["hostnames"],
comment=entry_data.get('comment', ''), comment=entry_data.get("comment", ""),
is_active=entry_data.get('is_active', True) is_active=entry_data.get("is_active", True),
) )
# Set DNS name if present for IP entries # Set DNS name if present for IP entries
if dns_name: if dns_name:
entry.dns_name = dns_name entry.dns_name = dns_name
if 'resolved_ip' in entry_data: if "resolved_ip" in entry_data:
entry.resolved_ip = entry_data['resolved_ip'] entry.resolved_ip = entry_data["resolved_ip"]
if 'last_resolved' in entry_data and entry_data['last_resolved']: if "last_resolved" in entry_data and entry_data["last_resolved"]:
try: try:
entry.last_resolved = datetime.fromisoformat(entry_data['last_resolved']) entry.last_resolved = datetime.fromisoformat(
entry_data["last_resolved"]
)
except ValueError: except ValueError:
warnings.append(f"Entry {i+1}: Invalid last_resolved date format") warnings.append(
if 'dns_resolution_status' in entry_data: f"Entry {i + 1}: Invalid last_resolved date format"
entry.dns_resolution_status = entry_data['dns_resolution_status'] )
if "dns_resolution_status" in entry_data:
entry.dns_resolution_status = entry_data[
"dns_resolution_status"
]
entries.append(entry) entries.append(entry)
@ -344,7 +375,7 @@ class ImportExportService:
errors=errors, errors=errors,
warnings=warnings, warnings=warnings,
total_processed=total_processed, total_processed=total_processed,
successfully_imported=len(entries) successfully_imported=len(entries),
) )
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
@ -354,7 +385,7 @@ class ImportExportService:
errors=[f"Invalid JSON file: {str(e)}"], errors=[f"Invalid JSON file: {str(e)}"],
warnings=[], warnings=[],
total_processed=0, total_processed=0,
successfully_imported=0 successfully_imported=0,
) )
except Exception as e: except Exception as e:
return ImportResult( return ImportResult(
@ -363,7 +394,7 @@ class ImportExportService:
errors=[f"Failed to import JSON format: {str(e)}"], errors=[f"Failed to import JSON format: {str(e)}"],
warnings=[], warnings=[],
total_processed=0, total_processed=0,
successfully_imported=0 successfully_imported=0,
) )
def import_csv_format(self, path: Path) -> ImportResult: def import_csv_format(self, path: Path) -> ImportResult:
@ -382,7 +413,7 @@ class ImportExportService:
warnings = [] warnings = []
total_processed = 0 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 # Try to detect the dialect
sample = csvfile.read(1024) sample = csvfile.read(1024)
csvfile.seek(0) csvfile.seek(0)
@ -391,8 +422,10 @@ class ImportExportService:
reader = csv.DictReader(csvfile, dialect=dialect) reader = csv.DictReader(csvfile, dialect=dialect)
# Validate required columns # Validate required columns
required_columns = ['hostnames'] required_columns = ["hostnames"]
missing_columns = [col for col in required_columns if col not in reader.fieldnames] missing_columns = [
col for col in required_columns if col not in reader.fieldnames
]
if missing_columns: if missing_columns:
return ImportResult( return ImportResult(
success=False, success=False,
@ -400,14 +433,14 @@ class ImportExportService:
errors=[f"Missing required columns: {missing_columns}"], errors=[f"Missing required columns: {missing_columns}"],
warnings=[], warnings=[],
total_processed=0, total_processed=0,
successfully_imported=0 successfully_imported=0,
) )
for row_num, row in enumerate(reader, start=2): # Start at 2 for header for row_num, row in enumerate(reader, start=2): # Start at 2 for header
total_processed += 1 total_processed += 1
try: try:
# Parse hostnames # Parse hostnames
hostnames_str = row.get('hostnames', '').strip() hostnames_str = row.get("hostnames", "").strip()
if not hostnames_str: if not hostnames_str:
errors.append(f"Row {row_num}: Empty hostnames field") errors.append(f"Row {row_num}: Empty hostnames field")
continue continue
@ -418,12 +451,12 @@ class ImportExportService:
continue continue
# Parse is_active # Parse is_active
is_active_str = row.get('is_active', 'true').lower() is_active_str = row.get("is_active", "true").lower()
is_active = is_active_str in ('true', '1', 'yes', 'active') is_active = is_active_str in ("true", "1", "yes", "active")
# Handle DNS vs IP entries # Handle DNS vs IP entries
dns_name = row.get('dns_name', '').strip() dns_name = row.get("dns_name", "").strip()
ip_address = row.get('ip_address', '').strip() ip_address = row.get("ip_address", "").strip()
# Create entry with temporary IP if it's a DNS-only entry # Create entry with temporary IP if it's a DNS-only entry
if dns_name and not ip_address: if dns_name and not ip_address:
@ -431,8 +464,8 @@ class ImportExportService:
entry = HostEntry( entry = HostEntry(
ip_address="127.0.0.1", # Temporary IP ip_address="127.0.0.1", # Temporary IP
hostnames=hostnames, hostnames=hostnames,
comment=row.get('comment', '').strip(), comment=row.get("comment", "").strip(),
is_active=is_active is_active=is_active,
) )
# Convert to DNS entry # Convert to DNS entry
entry.ip_address = "" entry.ip_address = ""
@ -442,21 +475,27 @@ class ImportExportService:
entry = HostEntry( entry = HostEntry(
ip_address=ip_address, ip_address=ip_address,
hostnames=hostnames, hostnames=hostnames,
comment=row.get('comment', '').strip(), comment=row.get("comment", "").strip(),
is_active=is_active is_active=is_active,
) )
# Set DNS name if present for IP entries # Set DNS name if present for IP entries
if dns_name: if dns_name:
entry.dns_name = dns_name entry.dns_name = dns_name
if row.get('resolved_ip', '').strip(): if row.get("resolved_ip", "").strip():
entry.resolved_ip = row['resolved_ip'].strip() entry.resolved_ip = row["resolved_ip"].strip()
if row.get('last_resolved', '').strip(): if row.get("last_resolved", "").strip():
try: try:
entry.last_resolved = datetime.fromisoformat(row['last_resolved'].strip()) entry.last_resolved = datetime.fromisoformat(
row["last_resolved"].strip()
)
except ValueError: except ValueError:
warnings.append(f"Row {row_num}: Invalid last_resolved date format") warnings.append(
if row.get('dns_resolution_status', '').strip(): f"Row {row_num}: Invalid last_resolved date format"
entry.dns_resolution_status = row['dns_resolution_status'].strip() )
if row.get("dns_resolution_status", "").strip():
entry.dns_resolution_status = row[
"dns_resolution_status"
].strip()
entries.append(entry) entries.append(entry)
@ -471,7 +510,7 @@ class ImportExportService:
errors=errors, errors=errors,
warnings=warnings, warnings=warnings,
total_processed=total_processed, total_processed=total_processed,
successfully_imported=len(entries) successfully_imported=len(entries),
) )
except Exception as e: except Exception as e:
@ -481,7 +520,7 @@ class ImportExportService:
errors=[f"Failed to import CSV format: {str(e)}"], errors=[f"Failed to import CSV format: {str(e)}"],
warnings=[], warnings=[],
total_processed=0, total_processed=0,
successfully_imported=0 successfully_imported=0,
) )
# Utility Methods # Utility Methods
@ -501,24 +540,24 @@ class ImportExportService:
# Check by extension first # Check by extension first
extension = path.suffix.lower() extension = path.suffix.lower()
if extension == '.json': if extension == ".json":
return ImportFormat.JSON return ImportFormat.JSON
elif extension == '.csv': elif extension == ".csv":
return ImportFormat.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 return ImportFormat.HOSTS
# Try to detect by content # Try to detect by content
try: try:
with open(path, 'r', encoding='utf-8') as f: with open(path, "r", encoding="utf-8") as f:
first_line = f.readline().strip() first_line = f.readline().strip()
# Check for JSON # Check for JSON
if first_line.startswith('{'): if first_line.startswith("{"):
return ImportFormat.JSON return ImportFormat.JSON
# Check for CSV (look for comma separators) # 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 return ImportFormat.CSV
# Default to hosts format # Default to hosts format
@ -551,7 +590,7 @@ class ImportExportService:
# Check write permissions # Check write permissions
try: try:
path.parent.mkdir(parents=True, exist_ok=True) 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.touch()
test_file.unlink() test_file.unlink()
except Exception: except Exception:
@ -559,14 +598,18 @@ class ImportExportService:
# Check extension matches format # Check extension matches format
expected_extensions = { expected_extensions = {
ExportFormat.HOSTS: ['.hosts', '.txt', ''], ExportFormat.HOSTS: [".hosts", ".txt", ""],
ExportFormat.JSON: ['.json'], ExportFormat.JSON: [".json"],
ExportFormat.CSV: ['.csv'] ExportFormat.CSV: [".csv"],
} }
if path.suffix.lower() not in expected_extensions[format]: if path.suffix.lower() not in expected_extensions[format]:
suggested_ext = expected_extensions[format][0] if expected_extensions[format] else '' suggested_ext = (
warnings.append(f"File extension '{path.suffix}' doesn't match format {format.value}{f', suggest {suggested_ext}' if suggested_ext else ''}") 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 return warnings

View file

@ -22,6 +22,7 @@ from .commands import (
OperationResult, OperationResult,
) )
class PermissionManager: class PermissionManager:
""" """
Manages sudo permissions for hosts file editing. Manages sudo permissions for hosts file editing.
@ -120,6 +121,7 @@ class PermissionManager:
self.has_sudo = False self.has_sudo = False
self._sudo_validated = False self._sudo_validated = False
class HostsManager: class HostsManager:
""" """
Main manager for hosts file edit operations. Main manager for hosts file edit operations.
@ -422,7 +424,9 @@ class HostsManager:
return False, f"Error updating entry: {e}" return False, f"Error updating entry: {e}"
# Command-based methods for undo/redo functionality # 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. Execute a toggle command with undo/redo support.
@ -440,7 +444,9 @@ class HostsManager:
result = self.undo_redo_history.execute_command(command, hosts_file) result = self.undo_redo_history.execute_command(command, hosts_file)
return result 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. Execute a move command with undo/redo support.
@ -471,7 +477,9 @@ class HostsManager:
result = self.undo_redo_history.execute_command(command, hosts_file) result = self.undo_redo_history.execute_command(command, hosts_file)
return result 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. Execute an add command with undo/redo support.
@ -490,7 +498,9 @@ class HostsManager:
result = self.undo_redo_history.execute_command(command, hosts_file) result = self.undo_redo_history.execute_command(command, hosts_file)
return result 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. Execute a delete command with undo/redo support.
@ -509,7 +519,15 @@ class HostsManager:
result = self.undo_redo_history.execute_command(command, hosts_file) result = self.undo_redo_history.execute_command(command, hosts_file)
return result 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. Execute an update command with undo/redo support.
@ -686,16 +704,19 @@ class HostsManager:
["sudo", "chmod", "644", str(self._backup_path)], capture_output=True ["sudo", "chmod", "644", str(self._backup_path)], capture_output=True
) )
class EditModeError(Exception): class EditModeError(Exception):
"""Base exception for edit mode errors.""" """Base exception for edit mode errors."""
pass pass
class PermissionError(EditModeError): class PermissionError(EditModeError):
"""Raised when there are permission issues.""" """Raised when there are permission issues."""
pass pass
class ValidationError(EditModeError): class ValidationError(EditModeError):
"""Raised when validation fails.""" """Raised when validation fails."""

View file

@ -126,7 +126,9 @@ class HostEntry:
try: try:
ipaddress.ip_address(self.resolved_ip) ipaddress.ip_address(self.resolved_ip)
except ValueError as e: 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: def to_hosts_line(self, ip_width: int = 0, hostname_width: int = 0) -> str:
""" """
@ -269,21 +271,26 @@ class HostEntry:
if comment: if comment:
# Split comment by pipe (|) to separate DNS metadata from user 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: for part in comment_parts:
if part.startswith('DNS:'): if part.startswith("DNS:"):
# Parse DNS metadata: "DNS:example.com|Status:resolved|Last:2023-..." # Parse DNS metadata: "DNS:example.com|Status:resolved|Last:2023-..."
dns_data = part.split('|') dns_data = part.split("|")
for dns_part in dns_data: for dns_part in dns_data:
if dns_part.startswith('DNS:'): if dns_part.startswith("DNS:"):
dns_name = dns_part[4:] # Remove "DNS:" prefix dns_name = dns_part[4:] # Remove "DNS:" prefix
elif dns_part.startswith('Status:'): elif dns_part.startswith("Status:"):
dns_resolution_status = dns_part[7:] # Remove "Status:" prefix dns_resolution_status = dns_part[
elif dns_part.startswith('Last:'): 7:
] # Remove "Status:" prefix
elif dns_part.startswith("Last:"):
try: try:
from datetime import datetime 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): except (ValueError, TypeError):
pass # Invalid datetime format, ignore pass # Invalid datetime format, ignore
else: else:
@ -360,7 +367,11 @@ class HostsFile:
def get_stale_dns_entries(self, max_age_seconds: int = 300) -> List[HostEntry]: def get_stale_dns_entries(self, max_age_seconds: int = 300) -> List[HostEntry]:
"""Get all entries with stale DNS resolution.""" """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: def sort_by_ip(self, ascending: bool = True) -> None:
""" """

View file

@ -40,7 +40,9 @@ class AddEntryModal(ModalScreen):
with Vertical(classes="default-flex-section") as entry_type: with Vertical(classes="default-flex-section") as entry_type:
entry_type.border_title = "Entry Type" entry_type.border_title = "Entry Type"
with RadioSet(id="entry-type-radio", classes="default-radio-set"): 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") yield RadioButton("DNS Name Entry", id="dns-entry-radio")
# IP Address Section # IP Address Section
@ -54,7 +56,9 @@ class AddEntryModal(ModalScreen):
yield Static("", id="ip-error", classes="validation-error") yield Static("", id="ip-error", classes="validation-error")
# DNS Name Section (initially hidden) # 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)" dns_name.border_title = "DNS Name (to resolve)"
yield Input( yield Input(
placeholder="e.g., example.com", placeholder="e.g., example.com",
@ -145,7 +149,9 @@ class AddEntryModal(ModalScreen):
# Set checkbox to inactive for DNS entries (will be activated after resolution) # Set checkbox to inactive for DNS entries (will be activated after resolution)
active_checkbox.value = False active_checkbox.value = False
active_section.border_title = "Activate Entry (DNS entries activate after resolution)" active_section.border_title = (
"Activate Entry (DNS entries activate after resolution)"
)
# Focus DNS input # Focus DNS input
dns_input = self.query_one("#dns-name-input", Input) dns_input = self.query_one("#dns-name-input", Input)
@ -165,7 +171,10 @@ class AddEntryModal(ModalScreen):
# Determine entry type # Determine entry type
radio_set = self.query_one("#entry-type-radio", RadioSet) radio_set = self.query_one("#entry-type-radio", RadioSet)
is_dns_entry = 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 # Get form values
ip_address = self.query_one("#ip-address-input", Input).value.strip() ip_address = self.query_one("#ip-address-input", Input).value.strip()
@ -195,12 +204,13 @@ class AddEntryModal(ModalScreen):
new_entry.dns_name = dns_name new_entry.dns_name = dns_name
# Add resolution status fields if they don't exist # 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 new_entry.resolved_ip = None
if not hasattr(new_entry, 'last_resolved'): if not hasattr(new_entry, "last_resolved"):
new_entry.last_resolved = None 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 from ..core.dns import DNSResolutionStatus
new_entry.dns_resolution_status = DNSResolutionStatus.NOT_RESOLVED new_entry.dns_resolution_status = DNSResolutionStatus.NOT_RESOLVED
else: else:
# IP entry # IP entry
@ -227,7 +237,9 @@ class AddEntryModal(ModalScreen):
"""Cancel entry creation and close modal.""" """Cancel entry creation and close modal."""
self.dismiss(None) 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. Validate user input.

View file

@ -7,7 +7,15 @@ all the handlers and provides the primary user interface.
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical 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 textual.reactive import reactive
from ..core.parser import HostsParser from ..core.parser import HostsParser
@ -66,7 +74,7 @@ class HostsManagerApp(App):
dns_config = self.config.get("dns_resolution", {}) dns_config = self.config.get("dns_resolution", {})
self.dns_service = DNSService( self.dns_service = DNSService(
enabled=dns_config.get("enabled", True), enabled=dns_config.get("enabled", True),
timeout=dns_config.get("timeout", 5.0) timeout=dns_config.get("timeout", 5.0),
) )
# Initialize filtering system # Initialize filtering system
@ -176,14 +184,24 @@ class HostsManagerApp(App):
# Edit form (initially hidden) # Edit form (initially hidden)
with Vertical(id="entry-edit-form", classes="entry-form hidden"): with Vertical(id="entry-edit-form", classes="entry-form hidden"):
# Entry Type Selection # 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" entry_type.border_title = "Entry Type"
with RadioSet(id="edit-entry-type-radio", classes="default-radio-set"): with RadioSet(
yield RadioButton("IP Address Entry", value=True, id="edit-ip-entry-radio") id="edit-entry-type-radio", classes="default-radio-set"
yield RadioButton("DNS Name Entry", id="edit-dns-entry-radio") ):
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 # 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" ip_address.border_title = "IP Address"
yield Input( yield Input(
placeholder="Enter IP address", placeholder="Enter IP address",
@ -192,7 +210,9 @@ class HostsManagerApp(App):
) )
# DNS Name Section (initially hidden) # 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)" dns_name.border_title = "DNS Name (to resolve)"
yield Input( yield Input(
placeholder="e.g., example.com", placeholder="e.g., example.com",
@ -388,7 +408,9 @@ class HostsManagerApp(App):
# Update search term and filter entries # Update search term and filter entries
self.search_term = event.value.strip() self.search_term = event.value.strip()
# Also update the current filter options to keep them synchronized # 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.table_handler.populate_entries_table()
self.details_handler.update_entry_details() self.details_handler.update_entry_details()
else: else:
@ -404,7 +426,10 @@ class HostsManagerApp(App):
def on_radio_set_changed(self, event) -> None: def on_radio_set_changed(self, event) -> None:
"""Handle entry type radio button changes in edit mode.""" """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 pressed_radio = event.pressed
if pressed_radio and pressed_radio.id == "edit-ip-entry-radio": if pressed_radio and pressed_radio.id == "edit-ip-entry-radio":
# Handle switch to IP entry type # Handle switch to IP entry type
@ -526,7 +551,7 @@ class HostsManagerApp(App):
"hostnames": entry.hostnames.copy(), "hostnames": entry.hostnames.copy(),
"comment": entry.comment, "comment": entry.comment,
"is_active": entry.is_active, "is_active": entry.is_active,
"dns_name": getattr(entry, 'dns_name', None), "dns_name": getattr(entry, "dns_name", None),
} }
self.entry_edit_mode = True self.entry_edit_mode = True
@ -584,7 +609,9 @@ class HostsManagerApp(App):
result = self.manager.execute_add_command(self.hosts_file, new_entry) result = self.manager.execute_add_command(self.hosts_file, new_entry)
if result.success: if result.success:
# Save the changes # 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: if save_success:
# Refresh the table # Refresh the table
self.table_handler.populate_entries_table() self.table_handler.populate_entries_table()
@ -593,12 +620,16 @@ class HostsManagerApp(App):
self.table_handler.restore_cursor_position(new_entry) self.table_handler.restore_cursor_position(new_entry)
# For DNS entries, trigger resolution and provide feedback # For DNS entries, trigger resolution and provide feedback
if hasattr(new_entry, 'dns_name') and 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}") self.update_status(
f"{result.message} - Starting DNS resolution for {new_entry.dns_name}"
)
# Trigger DNS resolution in background # Trigger DNS resolution in background
self._resolve_new_dns_entry(new_entry) self._resolve_new_dns_entry(new_entry)
else: else:
self.update_status(f"{result.message} - Changes saved automatically") self.update_status(
f"{result.message} - Changes saved automatically"
)
else: else:
self.update_status(f"Entry added but save failed: {save_message}") self.update_status(f"Entry added but save failed: {save_message}")
else: else:
@ -638,16 +669,22 @@ class HostsManagerApp(App):
) )
if result.success: if result.success:
# Save the changes # 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: if save_success:
# Adjust selected index if needed # Adjust selected index if needed
if self.selected_entry_index >= len(self.hosts_file.entries): 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 # Refresh the table
self.table_handler.populate_entries_table() self.table_handler.populate_entries_table()
self.details_handler.update_entry_details() 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: else:
self.update_status(f"Entry deleted but save failed: {save_message}") self.update_status(f"Entry deleted but save failed: {save_message}")
else: else:
@ -764,9 +801,13 @@ class HostsManagerApp(App):
# Save hosts file with updated DNS information # Save hosts file with updated DNS information
if resolved_count > 0 or failed_count > 0: 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: 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 return
# Update the UI and restore cursor position # Update the UI and restore cursor position
@ -776,11 +817,17 @@ class HostsManagerApp(App):
# Provide detailed status message # Provide detailed status message
if failed_count == 0: 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: 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: 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: except Exception as e:
self.update_status(f"❌ DNS resolution failed: {e}") self.update_status(f"❌ DNS resolution failed: {e}")
@ -808,7 +855,7 @@ class HostsManagerApp(App):
entry = self.hosts_file.entries[self.selected_entry_index] entry = self.hosts_file.entries[self.selected_entry_index]
# Check if the entry has a DNS name to resolve # 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") self.update_status("❌ Selected entry has no DNS name to resolve")
return return
@ -832,9 +879,13 @@ class HostsManagerApp(App):
entry.resolved_ip = resolution.resolved_ip entry.resolved_ip = resolution.resolved_ip
# Save hosts file with updated DNS information # 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: 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 return
# Update the UI and restore cursor position # Update the UI and restore cursor position
@ -842,10 +893,14 @@ class HostsManagerApp(App):
self.table_handler.restore_cursor_position(current_entry) self.table_handler.restore_cursor_position(current_entry)
self.details_handler.update_entry_details() 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: else:
# Resolution failed, save the status update # 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: if save_success:
# Update the UI to show failed status and restore cursor position # Update the UI to show failed status and restore cursor position
self.table_handler.populate_entries_table() self.table_handler.populate_entries_table()
@ -853,7 +908,9 @@ class HostsManagerApp(App):
self.details_handler.update_entry_details() self.details_handler.update_entry_details()
error_msg = resolution.error_message or "Unknown error" 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: except Exception as e:
self.update_status(f"❌ DNS resolution error: {e}") self.update_status(f"❌ DNS resolution error: {e}")
@ -864,6 +921,7 @@ class HostsManagerApp(App):
def action_show_filters(self) -> None: def action_show_filters(self) -> None:
"""Show advanced filtering modal.""" """Show advanced filtering modal."""
def handle_filter_result(filter_options: FilterOptions) -> None: def handle_filter_result(filter_options: FilterOptions) -> None:
if filter_options is None: if filter_options is None:
# User cancelled # User cancelled
@ -896,23 +954,31 @@ class HostsManagerApp(App):
self.details_handler.update_entry_details() self.details_handler.update_entry_details()
# Get filter statistics for status message # Get filter statistics for status message
counts = self.entry_filter.count_filtered_entries(self.hosts_file.entries, filter_options) counts = self.entry_filter.count_filtered_entries(
preset_info = f" (preset: {filter_options.preset_name})" if filter_options.preset_name else "" self.hosts_file.entries, filter_options
self.update_status(f"✅ Filter applied: showing {counts['filtered']} of {counts['total']} entries{preset_info}") )
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 # Show the filter modal with current options and entries for preview
self.push_screen( self.push_screen(
FilterModal( FilterModal(
initial_options=self.current_filter_options, initial_options=self.current_filter_options,
entries=self.hosts_file.entries, 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: def _resolve_new_dns_entry(self, entry) -> None:
"""Trigger DNS resolution for a newly added DNS entry.""" """Trigger DNS resolution for a newly added DNS entry."""
if not hasattr(entry, 'dns_name') or not entry.dns_name: if not hasattr(entry, "dns_name") or not entry.dns_name:
return return
async def resolve_and_activate(): async def resolve_and_activate():
@ -923,10 +989,11 @@ class HostsManagerApp(App):
if resolution.is_success(): if resolution.is_success():
# Find the entry in the hosts file and update it # Find the entry in the hosts file and update it
for hosts_entry in self.hosts_file.entries: for hosts_entry in self.hosts_file.entries:
if (hasattr(hosts_entry, 'dns_name') and if (
hosts_entry.dns_name == entry.dns_name and hasattr(hosts_entry, "dns_name")
hosts_entry.hostnames == entry.hostnames): and hosts_entry.dns_name == entry.dns_name
and hosts_entry.hostnames == entry.hostnames
):
# Update the entry with resolved IP # Update the entry with resolved IP
hosts_entry.ip_address = resolution.resolved_ip hosts_entry.ip_address = resolution.resolved_ip
hosts_entry.resolved_ip = resolution.resolved_ip hosts_entry.resolved_ip = resolution.resolved_ip
@ -935,30 +1002,41 @@ class HostsManagerApp(App):
hosts_entry.is_active = True # Activate the entry hosts_entry.is_active = True # Activate the entry
# Save the updated hosts file # 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: if save_success:
# Update UI - use direct calls since we're in the same async context # Update UI - use direct calls since we're in the same async context
self.table_handler.populate_entries_table() self.table_handler.populate_entries_table()
self.details_handler.update_entry_details() 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: else:
self.update_status(f"❌ DNS resolved but save failed: {save_message}") self.update_status(
f"❌ DNS resolved but save failed: {save_message}"
)
break break
else: else:
# Resolution failed, update status but keep entry inactive # Resolution failed, update status but keep entry inactive
for hosts_entry in self.hosts_file.entries: for hosts_entry in self.hosts_file.entries:
if (hasattr(hosts_entry, 'dns_name') and if (
hosts_entry.dns_name == entry.dns_name and hasattr(hosts_entry, "dns_name")
hosts_entry.hostnames == entry.hostnames): and hosts_entry.dns_name == entry.dns_name
and hosts_entry.hostnames == entry.hostnames
):
hosts_entry.dns_resolution_status = resolution.status.value hosts_entry.dns_resolution_status = resolution.status.value
hosts_entry.last_resolved = resolution.resolved_at hosts_entry.last_resolved = resolution.resolved_at
break 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: 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 # Start the resolution in background
self.run_worker(resolve_and_activate(), exclusive=False) self.run_worker(resolve_and_activate(), exclusive=False)

View file

@ -138,7 +138,9 @@ class DetailsHandler:
# Get the three separate DNS input fields # Get the three separate DNS input fields
dns_name_input = self.app.query_one("#details-dns-name-input", Input) 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_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(): if not entry.has_dns_name():
# Clear all DNS fields if no DNS information # Clear all DNS fields if no DNS information
@ -162,11 +164,15 @@ class DetailsHandler:
"resolved": "Resolved", "resolved": "Resolved",
"failed": "Resolution failed", "failed": "Resolution failed",
"match": "IP matches DNS", "match": "IP matches DNS",
"mismatch": "IP differs from DNS" "mismatch": "IP differs from DNS",
}.get(entry.dns_resolution_status, entry.dns_resolution_status) }.get(entry.dns_resolution_status, entry.dns_resolution_status)
# Add resolved IP to status if available # 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})" status_text += f" ({entry.resolved_ip})"
dns_status_input.value = status_text dns_status_input.value = status_text

View file

@ -97,11 +97,17 @@ class DNSStatusWidget(Static):
self.dns_enabled = self.dns_service.enabled self.dns_enabled = self.dns_service.enabled
# Count DNS resolution states from the service # 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 cache = self.dns_service._resolution_cache
resolving = sum(1 for r in cache.values() if r.status == "RESOLVING") 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"]) resolved = sum(
failed = sum(1 for r in cache.values() if r.status in ["RESOLUTION_FAILED", "IP_MISMATCH"]) 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.resolving_count = resolving
self.resolved_count = resolved self.resolved_count = resolved

View file

@ -28,7 +28,7 @@ class EditHandler:
entry = self.app.hosts_file.entries[self.app.selected_entry_index] entry = self.app.hosts_file.entries[self.app.selected_entry_index]
# Check if entry has a DNS name field and it's not empty # 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" return "dns"
else: else:
return "ip" return "ip"
@ -52,16 +52,19 @@ class EditHandler:
# Populate DNS field if we have existing entry data # Populate DNS field if we have existing entry data
try: try:
if (self.app.entry_edit_mode and if (
self.app.hosts_file.entries and self.app.entry_edit_mode
self.app.selected_entry_index < len(self.app.hosts_file.entries)): 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] entry = self.app.hosts_file.entries[self.app.selected_entry_index]
dns_input = self.app.query_one("#dns-name-input", Input) dns_input = self.app.query_one("#dns-name-input", Input)
# Populate with existing DNS name if available # Populate with existing DNS name if available
dns_name = getattr(entry, 'dns_name', '') or '' dns_name = getattr(entry, "dns_name", "") or ""
if dns_name and not dns_input.value: # Only populate if field is empty if (
dns_name and not dns_input.value
): # Only populate if field is empty
dns_input.value = dns_name dns_input.value = dns_name
# Focus DNS input # Focus DNS input
@ -133,7 +136,7 @@ class EditHandler:
# Populate DNS name field for DNS entries (after field is visible) # Populate DNS name field for DNS entries (after field is visible)
if entry_type == "dns": if entry_type == "dns":
dns_input = self.app.query_one("#dns-name-input", Input) 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: except Exception as e:
# Debug: Show what went wrong # Debug: Show what went wrong
@ -258,7 +261,9 @@ class EditHandler:
# Validate hostname(s) - common to both types # Validate hostname(s) - common to both types
hostnames = [h.strip() for h in hostname_input.value.split(",") if h.strip()] hostnames = [h.strip() for h in hostname_input.value.split(",") if h.strip()]
if not hostnames: 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 return False
hostname_pattern = re.compile( hostname_pattern = re.compile(
@ -267,7 +272,9 @@ class EditHandler:
for hostname in hostnames: for hostname in hostnames:
if not hostname_pattern.match(hostname): 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 return False
if entry_type == "ip": if entry_type == "ip":
@ -276,7 +283,9 @@ class EditHandler:
ip_input = self.app.query_one("#ip-input", Input) ip_input = self.app.query_one("#ip-input", Input)
ip_address = ip_input.value.strip() ip_address = ip_input.value.strip()
if not ip_address: 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 return False
ipaddress.ip_address(ip_address) ipaddress.ip_address(ip_address)
except ValueError: except ValueError:
@ -288,7 +297,9 @@ class EditHandler:
dns_input = self.app.query_one("#dns-name-input", Input) dns_input = self.app.query_one("#dns-name-input", Input)
dns_name = dns_input.value.strip() dns_name = dns_input.value.strip()
if not dns_name: 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 return False
# Basic DNS name validation # Basic DNS name validation
@ -299,10 +310,14 @@ class EditHandler:
or dns_name.endswith(".") or dns_name.endswith(".")
or ".." in dns_name 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 return False
except Exception: 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 False
return True return True
@ -348,11 +363,11 @@ class EditHandler:
entry.ip_address = ip_input.value.strip() entry.ip_address = ip_input.value.strip()
entry.dns_name = None # Clear DNS name when converting to IP entry.dns_name = None # Clear DNS name when converting to IP
# Clear DNS-related fields # Clear DNS-related fields
if hasattr(entry, 'resolved_ip'): if hasattr(entry, "resolved_ip"):
entry.resolved_ip = None entry.resolved_ip = None
if hasattr(entry, 'last_resolved'): if hasattr(entry, "last_resolved"):
entry.last_resolved = None entry.last_resolved = None
if hasattr(entry, 'dns_resolution_status'): if hasattr(entry, "dns_resolution_status"):
entry.dns_resolution_status = None entry.dns_resolution_status = None
else: else:
# DNS entry - update DNS name and set placeholder IP # DNS entry - update DNS name and set placeholder IP
@ -360,12 +375,13 @@ class EditHandler:
entry.dns_name = dns_input.value.strip() entry.dns_name = dns_input.value.strip()
entry.ip_address = "0.0.0.0" # Placeholder IP for DNS entries entry.ip_address = "0.0.0.0" # Placeholder IP for DNS entries
# Initialize DNS fields if they don't exist # Initialize DNS fields if they don't exist
if not hasattr(entry, 'resolved_ip'): if not hasattr(entry, "resolved_ip"):
entry.resolved_ip = None entry.resolved_ip = None
if not hasattr(entry, 'last_resolved'): if not hasattr(entry, "last_resolved"):
entry.last_resolved = None entry.last_resolved = None
if not hasattr(entry, 'dns_resolution_status'): if not hasattr(entry, "dns_resolution_status"):
from ..core.dns import DNSResolutionStatus from ..core.dns import DNSResolutionStatus
entry.dns_resolution_status = DNSResolutionStatus.NOT_RESOLVED entry.dns_resolution_status = DNSResolutionStatus.NOT_RESOLVED
# Update common fields # Update common fields
@ -388,7 +404,9 @@ class EditHandler:
# Provide appropriate success message # Provide appropriate success message
if entry_type == "dns": 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: else:
self.app.update_status("Entry saved successfully") self.app.update_status("Entry saved successfully")
return True return True

View file

@ -8,8 +8,15 @@ filtering options including status, type, resolution status, and search filterin
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Grid, Horizontal, Container from textual.containers import Grid, Horizontal, Container
from textual.widgets import ( from textual.widgets import (
Static, Button, Checkbox, Input, Select, Label, Static,
RadioSet, RadioButton, Collapsible Button,
Checkbox,
Input,
Select,
Label,
RadioSet,
RadioButton,
Collapsible,
) )
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual.reactive import reactive from textual.reactive import reactive
@ -145,9 +152,12 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
current_options: reactive[FilterOptions] = reactive(FilterOptions()) current_options: reactive[FilterOptions] = reactive(FilterOptions())
entry_counts: reactive[Dict[str, int]] = reactive({}) entry_counts: reactive[Dict[str, int]] = reactive({})
def __init__(self, initial_options: Optional[FilterOptions] = None, def __init__(
self,
initial_options: Optional[FilterOptions] = None,
entries: Optional[List] = None, entries: Optional[List] = None,
entry_filter: Optional[EntryFilter] = None): entry_filter: Optional[EntryFilter] = None,
):
""" """
Initialize filter modal. Initialize filter modal.
@ -174,10 +184,13 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
with Horizontal(classes="preset-row"): with Horizontal(classes="preset-row"):
yield Label("Preset:", classes="filter-input-label") yield Label("Preset:", classes="filter-input-label")
yield Select( 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, value=self.current_options.preset_name,
id="preset-select", id="preset-select",
classes="preset-select" classes="preset-select",
) )
yield Button("Load", id="load-preset", variant="primary") yield Button("Load", id="load-preset", variant="primary")
yield Button("Save", id="save-preset") yield Button("Save", id="save-preset")
@ -189,44 +202,93 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
yield Static("Status Filtering", classes="filter-section-title") yield Static("Status Filtering", classes="filter-section-title")
with RadioSet(id="status-filter-type"): with RadioSet(id="status-filter-type"):
yield RadioButton("Show All", value="all", id="status-all") yield RadioButton("Show All", value="all", id="status-all")
yield RadioButton("Active Only", value="active", id="status-active") yield RadioButton(
yield RadioButton("Inactive Only", value="inactive", id="status-inactive") "Active Only", value="active", id="status-active"
yield RadioButton("Custom", value="custom", id="status-custom") )
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"): with Container(
yield Checkbox("Show Active Entries", value=True, id="show-active") classes="filter-checkboxes", id="status-custom-options"
yield Checkbox("Show Inactive Entries", value=True, id="show-inactive") ):
yield Checkbox(
"Show Active Entries", value=True, id="show-active"
)
yield Checkbox(
"Show Inactive Entries", value=True, id="show-inactive"
)
# DNS type filtering section # DNS type filtering section
with Collapsible(title="Entry Type Filtering", collapsed=False): with Collapsible(title="Entry Type Filtering", collapsed=False):
with Container(classes="filter-section"): 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"): with RadioSet(id="type-filter-type"):
yield RadioButton("Show All", value="all", id="type-all") yield RadioButton("Show All", value="all", id="type-all")
yield RadioButton("DNS Entries Only", value="dns", id="type-dns") yield RadioButton(
yield RadioButton("IP Entries Only", value="ip", id="type-ip") "DNS Entries Only", value="dns", id="type-dns"
yield RadioButton("Custom", value="custom", id="type-custom") )
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"): with Container(
yield Checkbox("Show DNS Entries", value=True, id="show-dns") 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") yield Checkbox("Show IP Entries", value=True, id="show-ip")
# DNS resolution status filtering section # DNS resolution status filtering section
with Collapsible(title="Resolution Status Filtering", collapsed=False): with Collapsible(title="Resolution Status Filtering", collapsed=False):
with Container(classes="filter-section"): 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"): with RadioSet(id="resolution-filter-type"):
yield RadioButton("Show All", value="all", id="resolution-all") yield RadioButton(
yield RadioButton("Resolved Only", value="resolved", id="resolution-resolved") "Show All", value="all", id="resolution-all"
yield RadioButton("Mismatches Only", value="mismatch", id="resolution-mismatch") )
yield RadioButton("Custom", value="custom", id="resolution-custom") 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"): with Container(
yield Checkbox("Show Resolved", value=True, id="show-resolved") classes="filter-checkboxes", id="resolution-custom-options"
yield Checkbox("Show Unresolved", value=True, id="show-unresolved") ):
yield Checkbox("Show Resolving", value=True, id="show-resolving") 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 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 # Search filtering section
with Collapsible(title="Search Filtering", collapsed=True): with Collapsible(title="Search Filtering", collapsed=True):
@ -239,14 +301,24 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
placeholder="Enter search term...", placeholder="Enter search term...",
value=self.current_options.search_term or "", value=self.current_options.search_term or "",
id="search-term", id="search-term",
classes="filter-input" classes="filter-input",
) )
with Container(classes="filter-checkboxes"): with Container(classes="filter-checkboxes"):
yield Checkbox("Search in hostnames", value=True, id="search-hostnames") yield Checkbox(
yield Checkbox("Search in comments", value=True, id="search-comments") "Search in hostnames", value=True, id="search-hostnames"
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 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 # Entry count display
yield Static("", id="count-display", classes="count-display") yield Static("", id="count-display", classes="count-display")
@ -296,8 +368,13 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
self.query_one("#resolution-resolved", RadioButton).value = True self.query_one("#resolution-resolved", RadioButton).value = True
elif options.mismatch_only: elif options.mismatch_only:
self.query_one("#resolution-mismatch", RadioButton).value = True self.query_one("#resolution-mismatch", RadioButton).value = True
elif (options.show_resolved and options.show_unresolved and elif (
options.show_resolving and options.show_failed and options.show_mismatched): 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 self.query_one("#resolution-all", RadioButton).value = True
else: else:
self.query_one("#resolution-custom", RadioButton).value = True self.query_one("#resolution-custom", RadioButton).value = True
@ -311,10 +388,14 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
# Search filtering # Search filtering
if options.search_term: if options.search_term:
self.query_one("#search-term", Input).value = 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-comments", Checkbox).value = options.search_in_comments
self.query_one("#search-ips", Checkbox).value = options.search_in_ips 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() self._update_custom_options_visibility()
@ -339,7 +420,9 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
"""Calculate entry counts for current filter options.""" """Calculate entry counts for current filter options."""
if not self.entries: if not self.entries:
return {} 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: def _update_count_display(self) -> None:
"""Update the count display with current filter results.""" """Update the count display with current filter results."""
@ -389,16 +472,36 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
dns_only, ip_only = False, False dns_only, ip_only = False, False
# Resolution status filtering # 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": if resolution_type and resolution_type.id == "resolution-resolved":
resolved_only, mismatch_only = True, False 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": elif resolution_type and resolution_type.id == "resolution-mismatch":
resolved_only, mismatch_only = False, True 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": elif resolution_type and resolution_type.id == "resolution-all":
resolved_only, mismatch_only = False, False 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 else: # custom
resolved_only, mismatch_only = False, False resolved_only, mismatch_only = False, False
show_resolved = self.query_one("#show-resolved", Checkbox).value show_resolved = self.query_one("#show-resolved", Checkbox).value
@ -434,7 +537,7 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
search_in_hostnames=search_hostnames, search_in_hostnames=search_hostnames,
search_in_comments=search_comments, search_in_comments=search_comments,
search_in_ips=search_ips, search_in_ips=search_ips,
case_sensitive=case_sensitive case_sensitive=case_sensitive,
) )
@on(RadioSet.Changed) @on(RadioSet.Changed)
@ -490,7 +593,9 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
# Update preset select with new preset # Update preset select with new preset
preset_select = self.query_one("#preset-select", Select) 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 preset_select.value = preset_name
@on(Button.Pressed, "#delete-preset") @on(Button.Pressed, "#delete-preset")
@ -501,5 +606,7 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
preset_name = str(preset_select.value) preset_name = str(preset_select.value)
if self.entry_filter.delete_preset(preset_name): if self.entry_filter.delete_preset(preset_name):
# Update preset select options # 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 preset_select.value = Select.BLANK

View file

@ -44,8 +44,20 @@ HOSTS_MANAGER_BINDINGS = [
Binding("shift+down", "move_entry_down", "Move entry down", show=False), Binding("shift+down", "move_entry_down", "Move entry down", show=False),
Binding("ctrl+z", "undo", "Undo", show=False, id="left:undo"), Binding("ctrl+z", "undo", "Undo", show=False, id="left:undo"),
Binding("ctrl+y", "redo", "Redo", show=False, id="left:redo"), 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(
Binding("r", "update_single_dns", "Update selected DNS based Entry", show=False, id="left:update_single_dns"), "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("escape", "exit_edit_entry", "Exit edit mode", show=False),
Binding("tab", "next_field", "Next field", show=False), Binding("tab", "next_field", "Next field", show=False),
Binding("shift+tab", "prev_field", "Previous field", show=False), Binding("shift+tab", "prev_field", "Previous field", show=False),

View file

@ -49,7 +49,9 @@ class NavigationHandler:
), ),
) )
self.app.details_handler.update_entry_details() 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: else:
self.app.update_status(f"Entry toggled but save failed: {save_message}") self.app.update_status(f"Entry toggled but save failed: {save_message}")
else: else:
@ -89,7 +91,9 @@ class NavigationHandler:
if table.row_count > 0 and display_index < table.row_count: if table.row_count > 0 and display_index < table.row_count:
table.move_cursor(row=display_index) table.move_cursor(row=display_index)
self.app.details_handler.update_entry_details() 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: else:
self.app.update_status(f"Entry moved but save failed: {save_message}") self.app.update_status(f"Entry moved but save failed: {save_message}")
else: else:
@ -129,7 +133,9 @@ class NavigationHandler:
if table.row_count > 0 and display_index < table.row_count: if table.row_count > 0 and display_index < table.row_count:
table.move_cursor(row=display_index) table.move_cursor(row=display_index)
self.app.details_handler.update_entry_details() 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: else:
self.app.update_status(f"Entry moved but save failed: {save_message}") self.app.update_status(f"Entry moved but save failed: {save_message}")
else: else:

View file

@ -35,8 +35,12 @@ class TableHandler:
all_entries.append(entry) all_entries.append(entry)
# Apply advanced filtering if enabled # Apply advanced filtering if enabled
if hasattr(self.app, 'entry_filter') and hasattr(self.app, 'current_filter_options'): if hasattr(self.app, "entry_filter") and hasattr(
filtered_entries = self.app.entry_filter.apply_filters(all_entries, self.app.current_filter_options) self.app, "current_filter_options"
):
filtered_entries = self.app.entry_filter.apply_filters(
all_entries, self.app.current_filter_options
)
else: else:
# Fallback to legacy search filtering for backward compatibility # Fallback to legacy search filtering for backward compatibility
filtered_entries = self._apply_legacy_search_filter(all_entries) 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]: def _apply_legacy_search_filter(self, entries: List[HostEntry]) -> List[HostEntry]:
"""Apply legacy search filter for backward compatibility.""" """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 return entries
search_term_lower = self.app.search_term.lower() search_term_lower = self.app.search_term.lower()

View file

@ -29,7 +29,7 @@ class TestAddEntryModalDNSSupport:
# Test that the compose method exists and can be called # Test that the compose method exists and can be called
# We can't test the actual widget creation without mounting the modal # We can't test the actual widget creation without mounting the modal
# in a Textual app context, so we just verify the method exists # 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) assert callable(self.modal.compose)
def test_validate_input_ip_entry_valid(self): def test_validate_input_ip_entry_valid(self):
@ -39,7 +39,7 @@ class TestAddEntryModalDNSSupport:
ip_address="192.168.1.1", ip_address="192.168.1.1",
dns_name="", dns_name="",
hostnames_str="example.com", hostnames_str="example.com",
is_dns_entry=False is_dns_entry=False,
) )
assert result is True assert result is True
@ -49,10 +49,7 @@ class TestAddEntryModalDNSSupport:
self.modal._show_error = Mock() self.modal._show_error = Mock()
result = self.modal._validate_input( result = self.modal._validate_input(
ip_address="", ip_address="", dns_name="", hostnames_str="example.com", is_dns_entry=False
dns_name="",
hostnames_str="example.com",
is_dns_entry=False
) )
assert result is False assert result is False
self.modal._show_error.assert_called_with("ip-error", "IP address is required") self.modal._show_error.assert_called_with("ip-error", "IP address is required")
@ -63,7 +60,7 @@ class TestAddEntryModalDNSSupport:
ip_address="", ip_address="",
dns_name="example.com", dns_name="example.com",
hostnames_str="www.example.com", hostnames_str="www.example.com",
is_dns_entry=True is_dns_entry=True,
) )
assert result is True assert result is True
@ -73,10 +70,7 @@ class TestAddEntryModalDNSSupport:
self.modal._show_error = Mock() self.modal._show_error = Mock()
result = self.modal._validate_input( result = self.modal._validate_input(
ip_address="", ip_address="", dns_name="", hostnames_str="example.com", is_dns_entry=True
dns_name="",
hostnames_str="example.com",
is_dns_entry=True
) )
assert result is False assert result is False
self.modal._show_error.assert_called_with("dns-error", "DNS name is required") self.modal._show_error.assert_called_with("dns-error", "DNS name is required")
@ -100,10 +94,12 @@ class TestAddEntryModalDNSSupport:
ip_address="", ip_address="",
dns_name=invalid_dns, dns_name=invalid_dns,
hostnames_str="example.com", hostnames_str="example.com",
is_dns_entry=True is_dns_entry=True,
) )
assert result is False 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): def test_validate_input_missing_hostnames(self):
"""Test validation for entries with missing hostnames.""" """Test validation for entries with missing hostnames."""
@ -112,13 +108,12 @@ class TestAddEntryModalDNSSupport:
# Test IP entry without hostnames # Test IP entry without hostnames
result = self.modal._validate_input( result = self.modal._validate_input(
ip_address="192.168.1.1", ip_address="192.168.1.1", dns_name="", hostnames_str="", is_dns_entry=False
dns_name="",
hostnames_str="",
is_dns_entry=False
) )
assert result is 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): def test_validate_input_invalid_hostnames(self):
"""Test validation for entries with invalid hostnames.""" """Test validation for entries with invalid hostnames."""
@ -130,10 +125,12 @@ class TestAddEntryModalDNSSupport:
ip_address="192.168.1.1", ip_address="192.168.1.1",
dns_name="", dns_name="",
hostnames_str="invalid hostname", hostnames_str="invalid hostname",
is_dns_entry=False is_dns_entry=False,
) )
assert result is 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): def test_clear_errors_includes_dns_error(self):
"""Test that clear_errors method includes DNS error clearing.""" """Test that clear_errors method includes DNS error clearing."""
@ -397,7 +394,7 @@ class TestAddEntryModalSaveLogic:
created_entry = self.modal.dismiss.call_args[0][0] created_entry = self.modal.dismiss.call_args[0][0]
assert isinstance(created_entry, HostEntry) assert isinstance(created_entry, HostEntry)
assert created_entry.ip_address == "0.0.0.0" # Placeholder IP for DNS entries 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.dns_name == "example.com"
assert created_entry.hostnames == ["www.example.com"] assert created_entry.hostnames == ["www.example.com"]
assert created_entry.comment is None assert created_entry.comment is None
@ -451,6 +448,7 @@ class TestAddEntryModalSaveLogic:
# Mock HostEntry to raise ValueError # Mock HostEntry to raise ValueError
with pytest.MonkeyPatch.context() as m: with pytest.MonkeyPatch.context() as m:
def mock_host_entry(*args, **kwargs): def mock_host_entry(*args, **kwargs):
raise ValueError("Invalid IP address") raise ValueError("Invalid IP address")
@ -460,4 +458,6 @@ class TestAddEntryModalSaveLogic:
self.modal.action_save() self.modal.action_save()
# Verify error was shown # 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"
)

View file

@ -205,7 +205,9 @@ class TestToggleEntryCommand:
def test_toggle_active_to_inactive(self): def test_toggle_active_to_inactive(self):
"""Test toggling an active entry to inactive.""" """Test toggling an active entry to inactive."""
hosts_file = HostsFile() 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) hosts_file.entries.append(entry)
command = ToggleEntryCommand(0) command = ToggleEntryCommand(0)
@ -218,7 +220,9 @@ class TestToggleEntryCommand:
def test_toggle_inactive_to_active(self): def test_toggle_inactive_to_active(self):
"""Test toggling an inactive entry to active.""" """Test toggling an inactive entry to active."""
hosts_file = HostsFile() 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) hosts_file.entries.append(entry)
command = ToggleEntryCommand(0) command = ToggleEntryCommand(0)
@ -231,7 +235,9 @@ class TestToggleEntryCommand:
def test_toggle_undo(self): def test_toggle_undo(self):
"""Test undoing a toggle operation.""" """Test undoing a toggle operation."""
hosts_file = HostsFile() 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) hosts_file.entries.append(entry)
command = ToggleEntryCommand(0) command = ToggleEntryCommand(0)
@ -454,10 +460,17 @@ class TestUpdateEntryCommand:
def test_update_entry(self): def test_update_entry(self):
"""Test updating an entry.""" """Test updating an entry."""
hosts_file = HostsFile() 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) 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) result = command.execute(hosts_file)
assert result.success is True assert result.success is True
@ -470,10 +483,17 @@ class TestUpdateEntryCommand:
def test_update_entry_undo(self): def test_update_entry_undo(self):
"""Test undoing an update operation.""" """Test undoing an update operation."""
hosts_file = HostsFile() 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) 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) command.execute(hosts_file)
result = command.undo(hosts_file) result = command.undo(hosts_file)
@ -532,7 +552,7 @@ class TestHostsManagerIntegration:
assert result.success is False assert result.success is False
assert "Not in edit mode" in result.message 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): def test_manager_execute_toggle_command(self, mock_enter_edit):
"""Test executing a toggle command through the manager.""" """Test executing a toggle command through the manager."""
mock_enter_edit.return_value = (True, "Edit mode enabled") mock_enter_edit.return_value = (True, "Edit mode enabled")
@ -541,7 +561,9 @@ class TestHostsManagerIntegration:
manager.edit_mode = True # Simulate edit mode manager.edit_mode = True # Simulate edit mode
hosts_file = HostsFile() 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) hosts_file.entries.append(entry)
result = manager.execute_toggle_command(hosts_file, 0) result = manager.execute_toggle_command(hosts_file, 0)
@ -549,7 +571,7 @@ class TestHostsManagerIntegration:
assert result.success is True assert result.success is True
assert not hosts_file.entries[0].is_active 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): def test_manager_execute_move_command(self, mock_enter_edit):
"""Test executing a move command through the manager.""" """Test executing a move command through the manager."""
mock_enter_edit.return_value = (True, "Edit mode enabled") mock_enter_edit.return_value = (True, "Edit mode enabled")
@ -567,7 +589,7 @@ class TestHostsManagerIntegration:
assert result.success is True assert result.success is True
assert hosts_file.entries[0] == entry2 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): def test_manager_command_not_in_edit_mode(self, mock_enter_edit):
"""Test executing commands when not in edit mode.""" """Test executing commands when not in edit mode."""
manager = HostsManager() manager = HostsManager()

View file

@ -115,6 +115,7 @@ class TestResolveHostname:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_timeout_resolution(self): async def test_timeout_resolution(self):
"""Test hostname resolution timeout.""" """Test hostname resolution timeout."""
async def mock_wait_for(*args, **kwargs): async def mock_wait_for(*args, **kwargs):
raise asyncio.TimeoutError() raise asyncio.TimeoutError()
@ -142,6 +143,7 @@ class TestResolveHostname:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_empty_result_resolution(self): async def test_empty_result_resolution(self):
"""Test hostname resolution with empty result.""" """Test hostname resolution with empty result."""
async def mock_wait_for(*args, **kwargs): async def mock_wait_for(*args, **kwargs):
return [] return []
@ -167,7 +169,9 @@ class TestResolveHostnamesBatch:
"""Test successful batch hostname resolution.""" """Test successful batch hostname resolution."""
hostnames = ["example.com", "test.example"] 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 successful resolutions
mock_resolve.side_effect = [ mock_resolve.side_effect = [
DNSResolution( DNSResolution(
@ -216,7 +220,6 @@ class TestResolveHostnamesBatch:
) )
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) resolutions = await resolve_hostnames_batch(hostnames)
assert len(resolutions) == 2 assert len(resolutions) == 2
@ -278,16 +281,20 @@ class TestDNSService:
"""Test async resolution when service is enabled.""" """Test async resolution when service is enabled."""
service = DNSService(enabled=True) 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( mock_resolution = DNSResolution(
hostname="example.com", hostname="example.com",
resolved_ip="192.0.2.1", resolved_ip="192.0.2.1",
status=DNSResolutionStatus.RESOLVED, status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime.now(), resolved_at=datetime.now(),
) )
# Use proper async setup # Use proper async setup
async def mock_side_effect(hostname, timeout=5.0): async def mock_side_effect(hostname, timeout=5.0):
return mock_resolution return mock_resolution
mock_resolve.side_effect = mock_side_effect mock_resolve.side_effect = mock_side_effect
resolution = await service.resolve_entry_async("example.com") resolution = await service.resolve_entry_async("example.com")
@ -312,16 +319,20 @@ class TestDNSService:
"""Test manual entry refresh.""" """Test manual entry refresh."""
service = DNSService(enabled=True) 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( mock_resolution = DNSResolution(
hostname="example.com", hostname="example.com",
resolved_ip="192.0.2.1", resolved_ip="192.0.2.1",
status=DNSResolutionStatus.RESOLVED, status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime.now(), resolved_at=datetime.now(),
) )
# Use proper async setup # Use proper async setup
async def mock_side_effect(hostname, timeout=5.0): async def mock_side_effect(hostname, timeout=5.0):
return mock_resolution return mock_resolution
mock_resolve.side_effect = mock_side_effect mock_resolve.side_effect = mock_side_effect
result = await service.refresh_entry("example.com") result = await service.refresh_entry("example.com")

View file

@ -10,6 +10,7 @@ from datetime import datetime, timedelta
from src.hosts.core.filters import EntryFilter, FilterOptions from src.hosts.core.filters import EntryFilter, FilterOptions
from src.hosts.core.models import HostEntry from src.hosts.core.models import HostEntry
class TestFilterOptions: class TestFilterOptions:
"""Test FilterOptions dataclass.""" """Test FilterOptions dataclass."""
@ -40,7 +41,7 @@ class TestFilterOptions:
active_only=True, active_only=True,
dns_only=True, dns_only=True,
search_term="test", search_term="test",
preset_name="Active DNS Only" preset_name="Active DNS Only",
) )
assert options.active_only is True assert options.active_only is True
assert options.dns_only is True assert options.dns_only is True
@ -50,34 +51,32 @@ class TestFilterOptions:
def test_to_dict(self): def test_to_dict(self):
"""Test converting FilterOptions to dictionary.""" """Test converting FilterOptions to dictionary."""
options = FilterOptions( options = FilterOptions(
active_only=True, active_only=True, search_term="test", preset_name="Test Preset"
search_term="test",
preset_name="Test Preset"
) )
result = options.to_dict() result = options.to_dict()
expected = { expected = {
'show_active': True, "show_active": True,
'show_inactive': True, "show_inactive": True,
'active_only': True, "active_only": True,
'inactive_only': False, "inactive_only": False,
'show_dns_entries': True, "show_dns_entries": True,
'show_ip_entries': True, "show_ip_entries": True,
'dns_only': False, "dns_only": False,
'ip_only': False, "ip_only": False,
'show_resolved': True, "show_resolved": True,
'show_unresolved': True, "show_unresolved": True,
'show_resolving': True, "show_resolving": True,
'show_failed': True, "show_failed": True,
'show_mismatched': True, "show_mismatched": True,
'mismatch_only': False, "mismatch_only": False,
'resolved_only': False, "resolved_only": False,
'search_term': 'test', "search_term": "test",
'search_in_hostnames': True, "search_in_hostnames": True,
'search_in_comments': True, "search_in_comments": True,
'search_in_ips': True, "search_in_ips": True,
'case_sensitive': False, "case_sensitive": False,
'preset_name': 'Test Preset' "preset_name": "Test Preset",
} }
assert result == expected assert result == expected
@ -85,23 +84,23 @@ class TestFilterOptions:
def test_from_dict(self): def test_from_dict(self):
"""Test creating FilterOptions from dictionary.""" """Test creating FilterOptions from dictionary."""
data = { data = {
'active_only': True, "active_only": True,
'dns_only': True, "dns_only": True,
'search_term': 'test', "search_term": "test",
'preset_name': 'Test Preset' "preset_name": "Test Preset",
} }
options = FilterOptions.from_dict(data) options = FilterOptions.from_dict(data)
assert options.active_only is True assert options.active_only is True
assert options.dns_only is True assert options.dns_only is True
assert options.search_term == 'test' assert options.search_term == "test"
assert options.preset_name == 'Test Preset' assert options.preset_name == "Test Preset"
# Verify missing keys use defaults # Verify missing keys use defaults
assert options.inactive_only is False assert options.inactive_only is False
def test_from_dict_partial(self): def test_from_dict_partial(self):
"""Test creating FilterOptions from partial dictionary.""" """Test creating FilterOptions from partial dictionary."""
data = {'active_only': True} data = {"active_only": True}
options = FilterOptions.from_dict(data) options = FilterOptions.from_dict(data)
assert options.active_only is True assert options.active_only is True
@ -122,6 +121,7 @@ class TestFilterOptions:
options = FilterOptions(active_only=True) options = FilterOptions(active_only=True)
assert options.is_empty() is False assert options.is_empty() is False
class TestEntryFilter: class TestEntryFilter:
"""Test EntryFilter class.""" """Test EntryFilter class."""
@ -222,7 +222,9 @@ class TestEntryFilter:
options = FilterOptions(resolved_only=True) options = FilterOptions(resolved_only=True)
result = entry_filter.filter_by_resolution_status(sample_entries, options) 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 assert len(result) == 1 # Only entry5 has resolved status
def test_filter_by_resolution_status_unresolved(self, entry_filter, sample_entries): def test_filter_by_resolution_status_unresolved(self, entry_filter, sample_entries):
@ -231,11 +233,13 @@ class TestEntryFilter:
show_resolved=False, show_resolved=False,
show_resolving=False, show_resolving=False,
show_failed=False, show_failed=False,
show_mismatched=False show_mismatched=False,
) )
result = entry_filter.filter_by_resolution_status(sample_entries, options) 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 assert len(result) == 5 # All except entry5 and entry6
def test_filter_by_resolution_status_mismatch(self, entry_filter, sample_entries): def test_filter_by_resolution_status_mismatch(self, entry_filter, sample_entries):
@ -281,11 +285,7 @@ class TestEntryFilter:
def test_combined_filters(self, entry_filter, sample_entries): def test_combined_filters(self, entry_filter, sample_entries):
"""Test applying multiple filters together.""" """Test applying multiple filters together."""
# Filter for active DNS entries containing "dns" # Filter for active DNS entries containing "dns"
options = FilterOptions( options = FilterOptions(active_only=True, dns_only=True, search_term="dns")
active_only=True,
dns_only=True,
search_term="dns"
)
result = entry_filter.apply_filters(sample_entries, options) result = entry_filter.apply_filters(sample_entries, options)
# Should only return entry3 (active DNS entry with "dns" in hostname) # Should only return entry3 (active DNS entry with "dns" in hostname)
@ -299,8 +299,8 @@ class TestEntryFilter:
options = FilterOptions(active_only=True) options = FilterOptions(active_only=True)
counts = entry_filter.count_filtered_entries(sample_entries, options) counts = entry_filter.count_filtered_entries(sample_entries, options)
assert counts['total'] == len(sample_entries) assert counts["total"] == len(sample_entries)
assert counts['filtered'] == 5 # 5 active entries assert counts["filtered"] == 5 # 5 active entries
def test_get_default_presets(self, entry_filter): def test_get_default_presets(self, entry_filter):
"""Test getting default filter presets.""" """Test getting default filter presets."""
@ -324,9 +324,7 @@ class TestEntryFilter:
"""Test saving and loading custom presets.""" """Test saving and loading custom presets."""
# Create custom filter options # Create custom filter options
custom_options = FilterOptions( custom_options = FilterOptions(
active_only=True, active_only=True, search_term="test", preset_name="My Custom Filter"
search_term="test",
preset_name="My Custom Filter"
) )
# Save preset # Save preset
@ -382,7 +380,12 @@ class TestEntryFilter:
def test_search_multiple_hostnames(self, entry_filter): def test_search_multiple_hostnames(self, entry_filter):
"""Test search across multiple hostnames in single entry.""" """Test search across multiple hostnames in single entry."""
# Create entry with multiple hostnames # 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] entries = [entry]
# Search for each hostname # Search for each hostname
@ -414,10 +417,7 @@ class TestEntryFilter:
def test_preset_name_preservation(self, entry_filter): def test_preset_name_preservation(self, entry_filter):
"""Test that preset names are preserved in FilterOptions.""" """Test that preset names are preserved in FilterOptions."""
preset_options = FilterOptions( preset_options = FilterOptions(active_only=True, preset_name="Active Only")
active_only=True,
preset_name="Active Only"
)
# Apply filters and check preset name is preserved # Apply filters and check preset name is preserved
sample_entry = HostEntry("192.168.1.1", ["test.com"], "Test", True) sample_entry = HostEntry("192.168.1.1", ["test.com"], "Test", True)

View file

@ -12,10 +12,14 @@ import tempfile
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from src.hosts.core.import_export import ( from src.hosts.core.import_export import (
ImportExportService, ImportResult, ExportFormat, ImportFormat ImportExportService,
ImportResult,
ExportFormat,
ImportFormat,
) )
from src.hosts.core.models import HostEntry, HostsFile from src.hosts.core.models import HostEntry, HostsFile
class TestImportExportService: class TestImportExportService:
"""Test ImportExportService class.""" """Test ImportExportService class."""
@ -31,7 +35,7 @@ class TestImportExportService:
HostEntry("127.0.0.1", ["localhost"], "Local host", True), HostEntry("127.0.0.1", ["localhost"], "Local host", True),
HostEntry("192.168.1.1", ["router.local"], "Home router", 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("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 # Convert to DNS entry and set DNS data for some entries
@ -102,7 +106,7 @@ class TestImportExportService:
assert export_path.exists() assert export_path.exists()
# Verify JSON structure # Verify JSON structure
with open(export_path, 'r') as f: with open(export_path, "r") as f:
data = json.load(f) data = json.load(f)
assert "metadata" in data assert "metadata" in data
@ -134,7 +138,7 @@ class TestImportExportService:
assert export_path.exists() assert export_path.exists()
# Verify CSV structure # Verify CSV structure
with open(export_path, 'r') as f: with open(export_path, "r") as f:
reader = csv.DictReader(f) reader = csv.DictReader(f)
rows = list(reader) rows = list(reader)
@ -142,8 +146,14 @@ class TestImportExportService:
# Check header # Check header
expected_fields = [ expected_fields = [
'ip_address', 'hostnames', 'comment', 'is_active', "ip_address",
'dns_name', 'resolved_ip', 'last_resolved', 'dns_resolution_status' "hostnames",
"comment",
"is_active",
"dns_name",
"resolved_ip",
"last_resolved",
"dns_resolution_status",
] ]
assert reader.fieldnames == expected_fields assert reader.fieldnames == expected_fields
@ -186,7 +196,9 @@ class TestImportExportService:
# Check imported entries # Check imported entries
assert len(result.entries) >= 2 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 is not None
assert localhost_entry.ip_address == "127.0.0.1" assert localhost_entry.ip_address == "127.0.0.1"
assert localhost_entry.is_active is True assert localhost_entry.is_active is True
@ -198,21 +210,21 @@ class TestImportExportService:
"metadata": { "metadata": {
"exported_at": "2024-01-15T12:00:00", "exported_at": "2024-01-15T12:00:00",
"total_entries": 3, "total_entries": 3,
"version": "1.0" "version": "1.0",
}, },
"entries": [ "entries": [
{ {
"ip_address": "127.0.0.1", "ip_address": "127.0.0.1",
"hostnames": ["localhost"], "hostnames": ["localhost"],
"comment": "Local host", "comment": "Local host",
"is_active": True "is_active": True,
}, },
{ {
"ip_address": "", "ip_address": "",
"hostnames": ["dns-only.com"], "hostnames": ["dns-only.com"],
"comment": "DNS only", "comment": "DNS only",
"is_active": False, "is_active": False,
"dns_name": "dns-only.com" "dns_name": "dns-only.com",
}, },
{ {
"ip_address": "10.0.0.1", "ip_address": "10.0.0.1",
@ -221,13 +233,13 @@ class TestImportExportService:
"is_active": True, "is_active": True,
"resolved_ip": "10.0.0.1", "resolved_ip": "10.0.0.1",
"last_resolved": "2024-01-15T12:00:00", "last_resolved": "2024-01-15T12:00:00",
"dns_resolution_status": "IP_MATCH" "dns_resolution_status": "IP_MATCH",
} },
] ],
} }
json_path = temp_dir / "test_import.json" 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) json.dump(json_data, f)
result = service.import_json_format(json_path) result = service.import_json_format(json_path)
@ -254,24 +266,38 @@ class TestImportExportService:
"""Test importing from CSV format.""" """Test importing from CSV format."""
# Create test CSV file # Create test CSV file
csv_path = temp_dir / "test_import.csv" 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 = csv.writer(f)
writer.writerow([ writer.writerow(
'ip_address', 'hostnames', 'comment', 'is_active', [
'dns_name', 'resolved_ip', 'last_resolved', 'dns_resolution_status' "ip_address",
]) "hostnames",
writer.writerow([ "comment",
'127.0.0.1', 'localhost', 'Local host', 'true', "is_active",
'', '', '', '' "dns_name",
]) "resolved_ip",
writer.writerow([ "last_resolved",
'', 'dns-only.com', 'DNS only', 'false', "dns_resolution_status",
'dns-only.com', '', '', '' ]
]) )
writer.writerow([ writer.writerow(
'10.0.0.1', 'test.com example.com', 'Test server', 'true', ["127.0.0.1", "localhost", "Local host", "true", "", "", "", ""]
'', '10.0.0.1', '2024-01-15T12:00:00', 'IP_MATCH' )
]) 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) result = service.import_csv_format(csv_path)
@ -282,7 +308,9 @@ class TestImportExportService:
assert len(result.entries) == 3 assert len(result.entries) == 3
# Check multiple hostnames entry # 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 multi_hostname_entry is not None
assert "example.com" in multi_hostname_entry.hostnames assert "example.com" in multi_hostname_entry.hostnames
assert len(multi_hostname_entry.hostnames) == 2 assert len(multi_hostname_entry.hostnames) == 2
@ -292,7 +320,7 @@ class TestImportExportService:
# Create invalid JSON file # Create invalid JSON file
invalid_json = {"invalid": "format", "no_entries": True} invalid_json = {"invalid": "format", "no_entries": True}
json_path = temp_dir / "invalid.json" 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) json.dump(invalid_json, f)
result = service.import_json_format(json_path) result = service.import_json_format(json_path)
@ -319,10 +347,10 @@ class TestImportExportService:
def test_import_csv_missing_required_columns(self, service, temp_dir): def test_import_csv_missing_required_columns(self, service, temp_dir):
"""Test importing CSV with missing required columns.""" """Test importing CSV with missing required columns."""
csv_path = temp_dir / "missing_columns.csv" 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 = csv.writer(f)
writer.writerow(['ip_address', 'comment']) # Missing 'hostnames' writer.writerow(["ip_address", "comment"]) # Missing 'hostnames'
writer.writerow(['127.0.0.1', 'test']) writer.writerow(["127.0.0.1", "test"])
result = service.import_csv_format(csv_path) result = service.import_csv_format(csv_path)
@ -341,13 +369,13 @@ class TestImportExportService:
"hostnames": ["localhost"], "hostnames": ["localhost"],
"comment": "Test", "comment": "Test",
"is_active": True, "is_active": True,
"last_resolved": "invalid-date-format" "last_resolved": "invalid-date-format",
} }
] ]
} }
json_path = temp_dir / "warnings.json" 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) json.dump(json_data, f)
result = service.import_json_format(json_path) result = service.import_json_format(json_path)
@ -396,12 +424,12 @@ class TestImportExportService:
# CSV content # CSV content
csv_file = temp_dir / "csv_no_ext" 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 assert service.detect_file_format(csv_file) == ImportFormat.CSV
# Hosts content # Hosts content
hosts_file = temp_dir / "hosts_no_ext" 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 assert service.detect_file_format(hosts_file) == ImportFormat.HOSTS
def test_detect_file_format_nonexistent(self, service): def test_detect_file_format_nonexistent(self, service):
@ -484,7 +512,7 @@ class TestImportExportService:
errors=["Error 1", "Error 2"], errors=["Error 1", "Error 2"],
warnings=[], warnings=[],
total_processed=5, total_processed=5,
successfully_imported=0 successfully_imported=0,
) )
assert result_with_errors.has_errors is True assert result_with_errors.has_errors is True
assert result_with_errors.has_warnings is False assert result_with_errors.has_warnings is False
@ -496,7 +524,7 @@ class TestImportExportService:
errors=[], errors=[],
warnings=["Warning 1"], warnings=["Warning 1"],
total_processed=5, total_processed=5,
successfully_imported=5 successfully_imported=5,
) )
assert result_with_warnings.has_errors is False assert result_with_warnings.has_errors is False
assert result_with_warnings.has_warnings is True assert result_with_warnings.has_warnings is True
@ -513,7 +541,7 @@ class TestImportExportService:
assert export_path.exists() assert export_path.exists()
# Verify empty file structure # Verify empty file structure
with open(export_path, 'r') as f: with open(export_path, "r") as f:
data = json.load(f) data = json.load(f)
assert data["metadata"]["total_entries"] == 0 assert data["metadata"]["total_entries"] == 0
assert len(data["entries"]) == 0 assert len(data["entries"]) == 0
@ -524,7 +552,7 @@ class TestImportExportService:
"192.168.1.1", "192.168.1.1",
["host1.com", "host2.com", "host3.com", "host4.com", "host5.com"], ["host1.com", "host2.com", "host3.com", "host4.com", "host5.com"],
"Multiple hostnames", "Multiple hostnames",
True True,
) )
hosts_file = HostsFile() hosts_file = HostsFile()
hosts_file.entries = [entry] hosts_file.entries = [entry]

View file

@ -835,11 +835,13 @@ class TestHostsManagerApp:
app.edit_handler.handle_entry_type_change = Mock() app.edit_handler.handle_entry_type_change = Mock()
# Mock the set_timer method to avoid event loop issues in tests # Mock the set_timer method to avoid event loop issues in tests
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() app.edit_handler.populate_edit_form_with_type_detection()
# Verify timer was set with the correct callback # 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 # Manually call the delayed setup to test the actual logic
app.edit_handler._delayed_radio_setup() app.edit_handler._delayed_radio_setup()
@ -950,7 +952,7 @@ class TestHostsManagerApp:
# Create a mock that properly handles and closes coroutines # Create a mock that properly handles and closes coroutines
def consume_coro(coro, **kwargs): def consume_coro(coro, **kwargs):
# If it's a coroutine, close it to prevent warnings # If it's a coroutine, close it to prevent warnings
if hasattr(coro, 'close'): if hasattr(coro, "close"):
coro.close() coro.close()
return None return None
@ -959,8 +961,11 @@ class TestHostsManagerApp:
# Test action_refresh_dns in edit mode - should proceed # Test action_refresh_dns in edit mode - should proceed
app.action_refresh_dns() app.action_refresh_dns()
# Should not show error message about read-only mode # Should not show error message about read-only mode
error_calls = [call for call in app.update_status.call_args_list error_calls = [
if "read-only mode" in str(call)] call
for call in app.update_status.call_args_list
if "read-only mode" in str(call)
]
assert len(error_calls) == 0 assert len(error_calls) == 0
# Should start DNS resolution # Should start DNS resolution
app.run_worker.assert_called() app.run_worker.assert_called()
@ -972,8 +977,11 @@ class TestHostsManagerApp:
# Test action_update_single_dns in edit mode - should proceed # Test action_update_single_dns in edit mode - should proceed
app.action_update_single_dns() app.action_update_single_dns()
# Should not show error message about read-only mode # Should not show error message about read-only mode
error_calls = [call for call in app.update_status.call_args_list error_calls = [
if "read-only mode" in str(call)] call
for call in app.update_status.call_args_list
if "read-only mode" in str(call)
]
assert len(error_calls) == 0 assert len(error_calls) == 0
# Should start DNS resolution # Should start DNS resolution
app.run_worker.assert_called() app.run_worker.assert_called()