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

@ -15,12 +15,13 @@ if TYPE_CHECKING:
@dataclass @dataclass
class OperationResult: class OperationResult:
"""Result of executing or undoing a command. """Result of executing or undoing a command.
Attributes: Attributes:
success: Whether the operation succeeded success: Whether the operation succeeded
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
@ -28,39 +29,39 @@ class OperationResult:
class Command(ABC): class Command(ABC):
"""Abstract base class for all edit commands. """Abstract base class for all edit commands.
All edit operations (toggle, move, add, delete, update) implement this interface All edit operations (toggle, move, add, delete, update) implement this interface
to provide consistent execute/undo capabilities for the undo/redo system. to provide consistent execute/undo capabilities for the undo/redo system.
""" """
@abstractmethod @abstractmethod
def execute(self, hosts_file: "HostsFile") -> OperationResult: def execute(self, hosts_file: "HostsFile") -> OperationResult:
"""Execute the command and return the result. """Execute the command and return the result.
Args: Args:
hosts_file: The hosts file to operate on hosts_file: The hosts file to operate on
Returns: Returns:
OperationResult indicating success/failure and details OperationResult indicating success/failure and details
""" """
pass pass
@abstractmethod @abstractmethod
def undo(self, hosts_file: "HostsFile") -> OperationResult: def undo(self, hosts_file: "HostsFile") -> OperationResult:
"""Undo the command and return the result. """Undo the command and return the result.
Args: Args:
hosts_file: The hosts file to operate on hosts_file: The hosts file to operate on
Returns: Returns:
OperationResult indicating success/failure and details OperationResult indicating success/failure and details
""" """
pass pass
@abstractmethod @abstractmethod
def get_description(self) -> str: def get_description(self) -> str:
"""Get a human-readable description of the command. """Get a human-readable description of the command.
Returns: Returns:
String description of what this command does String description of what this command does
""" """
@ -69,137 +70,133 @@ class Command(ABC):
class UndoRedoHistory: class UndoRedoHistory:
"""Manages undo/redo history with configurable limits. """Manages undo/redo history with configurable limits.
This class maintains separate stacks for undo and redo operations, This class maintains separate stacks for undo and redo operations,
executes commands while managing history, and provides methods to executes commands while managing history, and provides methods to
check availability of undo/redo operations. check availability of undo/redo operations.
""" """
def __init__(self, max_history: int = 50): def __init__(self, max_history: int = 50):
"""Initialize the history manager. """Initialize the history manager.
Args: Args:
max_history: Maximum number of commands to keep in history max_history: Maximum number of commands to keep in history
""" """
self.max_history = max_history self.max_history = max_history
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:
command: Command to execute command: Command to execute
hosts_file: The hosts file to operate on hosts_file: The hosts file to operate on
Returns: Returns:
OperationResult from command execution OperationResult from command execution
""" """
result = command.execute(hosts_file) result = command.execute(hosts_file)
if result.success: if result.success:
# Add to undo stack and clear redo stack # Add to undo stack and clear redo stack
self.undo_stack.append(command) self.undo_stack.append(command)
self.redo_stack.clear() self.redo_stack.clear()
# Enforce history limit # Enforce history limit
if len(self.undo_stack) > self.max_history: if len(self.undo_stack) > self.max_history:
self.undo_stack.pop(0) self.undo_stack.pop(0)
return result return result
def undo(self, hosts_file: "HostsFile") -> OperationResult: def undo(self, hosts_file: "HostsFile") -> OperationResult:
"""Undo the last command. """Undo the last command.
Args: Args:
hosts_file: The hosts file to operate on hosts_file: The hosts file to operate on
Returns: Returns:
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)
if result.success: if result.success:
# Move command to redo stack # Move command to redo stack
self.redo_stack.append(command) self.redo_stack.append(command)
# Enforce history limit on redo stack too # Enforce history limit on redo stack too
if len(self.redo_stack) > self.max_history: if len(self.redo_stack) > self.max_history:
self.redo_stack.pop(0) self.redo_stack.pop(0)
else: else:
# If undo failed, put command back on undo stack # If undo failed, put command back on undo stack
self.undo_stack.append(command) self.undo_stack.append(command)
return result return result
def redo(self, hosts_file: "HostsFile") -> OperationResult: def redo(self, hosts_file: "HostsFile") -> OperationResult:
"""Redo the last undone command. """Redo the last undone command.
Args: Args:
hosts_file: The hosts file to operate on hosts_file: The hosts file to operate on
Returns: Returns:
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)
if result.success: if result.success:
# Move command back to undo stack # Move command back to undo stack
self.undo_stack.append(command) self.undo_stack.append(command)
else: else:
# If redo failed, put command back on redo stack # If redo failed, put command back on redo stack
self.redo_stack.append(command) self.redo_stack.append(command)
return result return result
def can_undo(self) -> bool: def can_undo(self) -> bool:
"""Check if undo is possible. """Check if undo is possible.
Returns: Returns:
True if there are commands that can be undone True if there are commands that can be undone
""" """
return len(self.undo_stack) > 0 return len(self.undo_stack) > 0
def can_redo(self) -> bool: def can_redo(self) -> bool:
"""Check if redo is possible. """Check if redo is possible.
Returns: Returns:
True if there are commands that can be redone True if there are commands that can be redone
""" """
return len(self.redo_stack) > 0 return len(self.redo_stack) > 0
def clear_history(self) -> None: def clear_history(self) -> None:
"""Clear both undo and redo stacks.""" """Clear both undo and redo stacks."""
self.undo_stack.clear() self.undo_stack.clear()
self.redo_stack.clear() self.redo_stack.clear()
def get_undo_description(self) -> Optional[str]: def get_undo_description(self) -> Optional[str]:
"""Get description of the next command that would be undone. """Get description of the next command that would be undone.
Returns: Returns:
Description string or None if no undo available Description string or None if no undo available
""" """
if self.can_undo(): if self.can_undo():
return self.undo_stack[-1].get_description() return self.undo_stack[-1].get_description()
return None return None
def get_redo_description(self) -> Optional[str]: def get_redo_description(self) -> Optional[str]:
"""Get description of the next command that would be redone. """Get description of the next command that would be redone.
Returns: Returns:
Description string or None if no redo available Description string or None if no redo available
""" """
@ -210,59 +207,56 @@ class UndoRedoHistory:
class ToggleEntryCommand(Command): class ToggleEntryCommand(Command):
"""Command to toggle an entry's active state.""" """Command to toggle an entry's active state."""
def __init__(self, index: int): def __init__(self, index: int):
"""Initialize the toggle command. """Initialize the toggle command.
Args: Args:
index: Index of the entry to toggle index: Index of the entry to toggle
""" """
self.index = index self.index = index
self.original_state: Optional[bool] = None self.original_state: Optional[bool] = None
def execute(self, hosts_file: "HostsFile") -> OperationResult: def execute(self, hosts_file: "HostsFile") -> OperationResult:
"""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]
self.original_state = entry.is_active self.original_state = entry.is_active
entry.is_active = not entry.is_active entry.is_active = not entry.is_active
action = "activated" if entry.is_active else "deactivated" action = "activated" if entry.is_active else "deactivated"
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]
entry.is_active = self.original_state entry.is_active = self.original_state
action = "activated" if entry.is_active else "deactivated" action = "activated" if entry.is_active else "deactivated"
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:
"""Get description of this command.""" """Get description of this command."""
return f"Toggle entry at index {self.index}" return f"Toggle entry at index {self.index}"
@ -270,64 +264,75 @@ class ToggleEntryCommand(Command):
class MoveEntryCommand(Command): class MoveEntryCommand(Command):
"""Command to move an entry up or down.""" """Command to move an entry up or down."""
def __init__(self, from_index: int, to_index: int): def __init__(self, from_index: int, to_index: int):
"""Initialize the move command. """Initialize the move command.
Args: Args:
from_index: Original position of the entry from_index: Original position of the entry
to_index: Target position for the entry to_index: Target position for the entry
""" """
self.from_index = from_index self.from_index = from_index
self.to_index = to_index self.to_index = to_index
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
entry = hosts_file.entries.pop(self.from_index) entry = hosts_file.entries.pop(self.from_index)
hosts_file.entries.insert(self.to_index, entry) hosts_file.entries.insert(self.to_index, entry)
direction = "up" if self.to_index < self.from_index else "down" direction = "up" if self.to_index < self.from_index else "down"
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
entry = hosts_file.entries.pop(self.to_index) entry = hosts_file.entries.pop(self.to_index)
hosts_file.entries.insert(self.from_index, entry) hosts_file.entries.insert(self.from_index, entry)
direction = "down" if self.to_index < self.from_index else "up" direction = "down" if self.to_index < self.from_index else "up"
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:
"""Get description of this command.""" """Get description of this command."""
direction = "up" if self.to_index < self.from_index else "down" direction = "up" if self.to_index < self.from_index else "down"
@ -336,10 +341,10 @@ class MoveEntryCommand(Command):
class AddEntryCommand(Command): class AddEntryCommand(Command):
"""Command to add a new entry.""" """Command to add a new entry."""
def __init__(self, entry: "HostEntry", index: Optional[int] = None): def __init__(self, entry: "HostEntry", index: Optional[int] = None):
"""Initialize the add command. """Initialize the add command.
Args: Args:
entry: The entry to add entry: The entry to add
index: Position to insert at (None for end) index: Position to insert at (None for end)
@ -347,7 +352,7 @@ class AddEntryCommand(Command):
self.entry = entry self.entry = entry
self.index = index self.index = index
self.actual_index: Optional[int] = None self.actual_index: Optional[int] = None
def execute(self, hosts_file: "HostsFile") -> OperationResult: def execute(self, hosts_file: "HostsFile") -> OperationResult:
"""Add the entry to the hosts file.""" """Add the entry to the hosts file."""
if self.index is None: if self.index is None:
@ -358,49 +363,49 @@ 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
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)
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:
"""Get description of this command.""" """Get description of this command."""
return f"Add entry: {self.entry.ip_address} {' '.join(self.entry.hostnames)}" return f"Add entry: {self.entry.ip_address} {' '.join(self.entry.hostnames)}"
@ -408,54 +413,52 @@ class AddEntryCommand(Command):
class DeleteEntryCommand(Command): class DeleteEntryCommand(Command):
"""Command to delete an entry.""" """Command to delete an entry."""
def __init__(self, index: int): def __init__(self, index: int):
"""Initialize the delete command. """Initialize the delete command.
Args: Args:
index: Index of the entry to delete index: Index of the entry to delete
""" """
self.index = index self.index = index
self.deleted_entry: Optional["HostEntry"] = None self.deleted_entry: Optional["HostEntry"] = None
def execute(self, hosts_file: "HostsFile") -> OperationResult: def execute(self, hosts_file: "HostsFile") -> OperationResult:
"""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)
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)
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:
"""Get description of this command.""" """Get description of this command."""
if self.deleted_entry: if self.deleted_entry:
@ -465,11 +468,17 @@ 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:
index: Index of the entry to update index: Index of the entry to update
new_ip: New IP address new_ip: New IP address
@ -483,65 +492,63 @@ class UpdateEntryCommand(Command):
self.new_comment = new_comment self.new_comment = new_comment
self.new_active = new_active self.new_active = new_active
self.original_entry: Optional["HostEntry"] = None self.original_entry: Optional["HostEntry"] = None
def execute(self, hosts_file: "HostsFile") -> OperationResult: def execute(self, hosts_file: "HostsFile") -> OperationResult:
"""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
entry = hosts_file.entries[self.index] entry = hosts_file.entries[self.index]
entry.ip_address = self.new_ip entry.ip_address = self.new_ip
entry.hostnames = self.new_hostnames.copy() entry.hostnames = self.new_hostnames.copy()
entry.comment = self.new_comment entry.comment = self.new_comment
entry.is_active = self.new_active entry.is_active = self.new_active
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
entry = hosts_file.entries[self.index] entry = hosts_file.entries[self.index]
entry.ip_address = self.original_entry.ip_address entry.ip_address = self.original_entry.ip_address
entry.hostnames = self.original_entry.hostnames.copy() entry.hostnames = self.original_entry.hostnames.copy()
entry.comment = self.original_entry.comment entry.comment = self.original_entry.comment
entry.is_active = self.original_entry.is_active entry.is_active = self.original_entry.is_active
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:
"""Get description of this command.""" """Get description of this command."""
if self.original_entry: if self.original_entry:

View file

@ -37,7 +37,7 @@ class Config:
}, },
"dns_resolution": { "dns_resolution": {
"enabled": True, "enabled": True,
"timeout": 5.0, # 5 seconds timeout "timeout": 5.0, # 5 seconds timeout
}, },
"filter_settings": { "filter_settings": {
"remember_filter_state": True, "remember_filter_state": True,
@ -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."""
@ -46,24 +50,23 @@ class DNSResolution:
async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution: async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution:
"""Resolve a single hostname to IP address with timeout. """Resolve a single hostname to IP address with timeout.
Args: Args:
hostname: Hostname to resolve hostname: Hostname to resolve
timeout: Maximum time to wait for resolution in seconds timeout: Maximum time to wait for resolution in seconds
Returns: Returns:
DNSResolution with result and status DNSResolution with result and status
""" """
start_time = datetime.now() start_time = datetime.now()
try: try:
# 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:
# Get first result (usually IPv4) # Get first result (usually IPv4)
ip_address = result[0][4][0] ip_address = result[0][4][0]
@ -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,16 +82,16 @@ 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:
return DNSResolution( return DNSResolution(
hostname=hostname, hostname=hostname,
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,66 +99,66 @@ 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:
hostnames: List of hostnames to resolve hostnames: List of hostnames to resolve
timeout: Maximum time to wait for each resolution timeout: Maximum time to wait for each resolution
Returns: Returns:
List of DNSResolution results List of DNSResolution results
""" """
if not hostnames: if not hostnames:
return [] return []
tasks = [resolve_hostname(hostname, timeout) for hostname in hostnames] tasks = [resolve_hostname(hostname, timeout) for hostname in hostnames]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
# Convert exceptions to failed resolutions # Convert exceptions to failed resolutions
resolutions = [] resolutions = []
for i, result in enumerate(results): for i, result in enumerate(results):
if isinstance(result, Exception): if isinstance(result, Exception):
resolutions.append(DNSResolution( resolutions.append(
hostname=hostnames[i], DNSResolution(
resolved_ip=None, hostname=hostnames[i],
status=DNSResolutionStatus.RESOLUTION_FAILED, resolved_ip=None,
resolved_at=datetime.now(), status=DNSResolutionStatus.RESOLUTION_FAILED,
error_message=str(result) resolved_at=datetime.now(),
)) error_message=str(result),
)
)
else: else:
resolutions.append(result) resolutions.append(result)
return resolutions return resolutions
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:
enabled: Whether DNS resolution is enabled enabled: Whether DNS resolution is enabled
timeout: Timeout for individual DNS queries timeout: Timeout for individual DNS queries
""" """
self.enabled = enabled self.enabled = enabled
self.timeout = timeout self.timeout = timeout
async def resolve_entry_async(self, hostname: str) -> DNSResolution: async def resolve_entry_async(self, hostname: str) -> DNSResolution:
"""Resolve DNS for a hostname asynchronously. """Resolve DNS for a hostname asynchronously.
Args: Args:
hostname: Hostname to resolve hostname: Hostname to resolve
Returns: Returns:
DNSResolution result DNSResolution result
""" """
@ -165,28 +168,28 @@ 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)
async def refresh_entry(self, hostname: str) -> DNSResolution: async def refresh_entry(self, hostname: str) -> DNSResolution:
"""Manually refresh DNS resolution for hostname. """Manually refresh DNS resolution for hostname.
Args: Args:
hostname: Hostname to refresh hostname: Hostname to refresh
Returns: Returns:
Fresh DNSResolution result Fresh DNSResolution result
""" """
return await self.resolve_entry_async(hostname) return await self.resolve_entry_async(hostname)
async def refresh_all_entries(self, hostnames: List[str]) -> List[DNSResolution]: async def refresh_all_entries(self, hostnames: List[str]) -> List[DNSResolution]:
"""Manually refresh DNS resolution for multiple hostnames. """Manually refresh DNS resolution for multiple hostnames.
Args: Args:
hostnames: List of hostnames to refresh hostnames: List of hostnames to refresh
Returns: Returns:
List of fresh DNSResolution results List of fresh DNSResolution results
""" """
@ -197,21 +200,21 @@ 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
] ]
return await resolve_hostnames_batch(hostnames, self.timeout) return await resolve_hostnames_batch(hostnames, self.timeout)
def compare_ips(stored_ip: str, resolved_ip: str) -> DNSResolutionStatus: def compare_ips(stored_ip: str, resolved_ip: str) -> DNSResolutionStatus:
"""Compare stored IP with resolved IP to determine status. """Compare stored IP with resolved IP to determine status.
Args: Args:
stored_ip: IP address stored in hosts entry stored_ip: IP address stored in hosts entry
resolved_ip: IP address resolved from DNS resolved_ip: IP address resolved from DNS
Returns: Returns:
DNSResolutionStatus indicating match or mismatch DNSResolutionStatus indicating match or mismatch
""" """

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,18 +24,19 @@ 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
active_only: bool = False active_only: bool = False
inactive_only: bool = False inactive_only: bool = False
# DNS type filtering # DNS type filtering
show_dns_entries: bool = True show_dns_entries: bool = True
show_ip_entries: bool = True show_ip_entries: bool = True
dns_only: bool = False dns_only: bool = False
ip_only: bool = False ip_only: bool = False
# DNS resolution status filtering # DNS resolution status filtering
show_resolved: bool = True show_resolved: bool = True
show_unresolved: bool = True show_unresolved: bool = True
@ -43,167 +45,177 @@ class FilterOptions:
show_mismatched: bool = True show_mismatched: bool = True
mismatch_only: bool = False mismatch_only: bool = False
resolved_only: bool = False resolved_only: bool = False
# Search filtering # Search filtering
search_term: Optional[str] = None search_term: Optional[str] = None
search_in_hostnames: bool = True search_in_hostnames: bool = True
search_in_comments: bool = True search_in_comments: bool = True
search_in_ips: bool = True search_in_ips: bool = True
case_sensitive: bool = False case_sensitive: bool = False
# Filter preset # Filter preset
preset_name: Optional[str] = None preset_name: Optional[str] = None
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
) )
class EntryFilter: class EntryFilter:
"""Advanced filtering logic for hosts entries.""" """Advanced filtering logic for hosts entries."""
def __init__(self): def __init__(self):
"""Initialize the entry filter.""" """Initialize the entry filter."""
self.presets: Dict[str, FilterOptions] = {} self.presets: Dict[str, FilterOptions] = {}
self._load_default_presets() self._load_default_presets()
def _load_default_presets(self) -> None: def _load_default_presets(self) -> None:
"""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.
Args: Args:
entries: List of host entries to filter entries: List of host entries to filter
options: Filter configuration options options: Filter configuration options
Returns: Returns:
Filtered list of entries Filtered list of entries
""" """
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:
filtered_entries = self.filter_by_search(filtered_entries, options) filtered_entries = self.filter_by_search(filtered_entries, options)
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.
Args: Args:
entries: List of entries to filter entries: List of entries to filter
options: Filter options containing status criteria options: Filter options containing status criteria
Returns: Returns:
Filtered list of entries Filtered list of entries
""" """
@ -220,15 +232,17 @@ class EntryFilter:
elif not entry.is_active and options.show_inactive: elif not entry.is_active and options.show_inactive:
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.
Args: Args:
entries: List of entries to filter entries: List of entries to filter
options: Filter options containing DNS type criteria options: Filter options containing DNS type criteria
Returns: Returns:
Filtered list of entries Filtered list of entries
""" """
@ -245,61 +259,73 @@ class EntryFilter:
elif not entry.has_dns_name() and options.show_ip_entries: elif not entry.has_dns_name() and options.show_ip_entries:
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.
Args: Args:
entries: List of entries to filter entries: List of entries to filter
options: Filter options containing resolution status criteria options: Filter options containing resolution status criteria
Returns: Returns:
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.
Args: Args:
entries: List of entries to filter entries: List of entries to filter
options: Filter options containing search criteria options: Filter options containing search criteria
Returns: Returns:
Filtered list of entries Filtered list of entries
""" """
if not options.search_term: if not options.search_term:
return entries return entries
search_term = options.search_term search_term = options.search_term
if not options.case_sensitive: if not options.case_sensitive:
search_term = search_term.lower() search_term = search_term.lower()
filtered = [] filtered = []
for entry in entries: for entry in entries:
match_found = False match_found = False
# Search in hostnames # Search in hostnames
if options.search_in_hostnames: if options.search_in_hostnames:
hostnames_text = " ".join(entry.hostnames) hostnames_text = " ".join(entry.hostnames)
@ -307,7 +333,7 @@ class EntryFilter:
hostnames_text = hostnames_text.lower() hostnames_text = hostnames_text.lower()
if search_term in hostnames_text: if search_term in hostnames_text:
match_found = True match_found = True
# Search in comments # Search in comments
if not match_found and options.search_in_comments and entry.comment: if not match_found and options.search_in_comments and entry.comment:
comment_text = entry.comment comment_text = entry.comment
@ -315,7 +341,7 @@ class EntryFilter:
comment_text = comment_text.lower() comment_text = comment_text.lower()
if search_term in comment_text: if search_term in comment_text:
match_found = True match_found = True
# Search in IP addresses # Search in IP addresses
if not match_found and options.search_in_ips: if not match_found and options.search_in_ips:
ip_text = entry.ip_address or "" ip_text = entry.ip_address or ""
@ -325,22 +351,26 @@ class EntryFilter:
ip_text = ip_text.lower() ip_text = ip_text.lower()
if search_term in ip_text: if search_term in ip_text:
match_found = True match_found = True
if match_found: if match_found:
filtered.append(entry) filtered.append(entry)
return filtered return filtered
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:
""" """
Save filter options as a preset. Save filter options as a preset.
Args: Args:
name: Name for the preset name: Name for the preset
options: Filter options to save options: Filter options to save
@ -367,29 +397,29 @@ 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
def load_preset(self, name: str) -> Optional[FilterOptions]: def load_preset(self, name: str) -> Optional[FilterOptions]:
""" """
Load filter options from a preset. Load filter options from a preset.
Args: Args:
name: Name of the preset to load name: Name of the preset to load
Returns: Returns:
Filter options if preset exists, None otherwise Filter options if preset exists, None otherwise
""" """
return self.presets.get(name) return self.presets.get(name)
def delete_preset(self, name: str) -> bool: def delete_preset(self, name: str) -> bool:
""" """
Delete a preset. Delete a preset.
Args: Args:
name: Name of the preset to delete name: Name of the preset to delete
Returns: Returns:
True if preset was deleted, False if it didn't exist True if preset was deleted, False if it didn't exist
""" """
@ -397,99 +427,102 @@ class EntryFilter:
del self.presets[name] del self.presets[name]
return True return True
return False return False
def get_preset_names(self) -> List[str]: def get_preset_names(self) -> List[str]:
""" """
Get list of available preset names. Get list of available preset names.
Returns: Returns:
List of preset names List of preset names
""" """
return list(self.presets.keys()) return list(self.presets.keys())
def get_default_presets(self) -> Dict[str, FilterOptions]: def get_default_presets(self) -> Dict[str, FilterOptions]:
""" """
Get the default filter presets. Get the default filter presets.
Returns: Returns:
Dictionary of default presets Dictionary of default presets
""" """
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]:
""" """
Get all saved presets (both default and custom). Get all saved presets (both default and custom).
Returns: Returns:
Dictionary of all presets Dictionary of all presets
""" """
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.
Args: Args:
entries: List of entries to analyze entries: List of entries to analyze
options: Filter options to apply options: Filter options to apply
Returns: Returns:
Dictionary with count statistics Dictionary with count statistics
""" """
filtered_entries = self.apply_filters(entries, options) filtered_entries = self.apply_filters(entries, options)
total_entries = len(entries) total_entries = len(entries)
filtered_count = len(filtered_entries) filtered_count = len(filtered_entries)
# Count by status # Count by status
active_count = len([e for e in filtered_entries if e.is_active]) active_count = len([e for e in filtered_entries if e.is_active])
inactive_count = filtered_count - active_count inactive_count = filtered_count - active_count
# Count by type # Count by type
dns_count = len([e for e in filtered_entries if e.has_dns_name()]) dns_count = len([e for e in filtered_entries if e.has_dns_name()])
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,
"filtered": filtered_count, "filtered": filtered_count,
@ -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,85 +15,102 @@ 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]
warnings: List[str] warnings: List[str]
total_processed: int total_processed: int
successfully_imported: int successfully_imported: int
@property @property
def has_errors(self) -> bool: def has_errors(self) -> bool:
"""Check if import had any errors.""" """Check if import had any errors."""
return len(self.errors) > 0 return len(self.errors) > 0
@property @property
def has_warnings(self) -> bool: def has_warnings(self) -> bool:
"""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
def export_hosts_format(self, hosts_file: HostsFile, path: Path) -> ExportResult: def export_hosts_format(self, hosts_file: HostsFile, path: Path) -> ExportResult:
""" """
Export hosts file to standard hosts format. Export hosts file to standard hosts format.
Args: Args:
hosts_file: HostsFile instance to export hosts_file: HostsFile instance to export
path: Path where to save the exported file path: Path where to save the exported file
Returns: Returns:
ExportResult with operation details ExportResult with operation details
""" """
try: try:
from .parser import HostsParser from .parser import HostsParser
# Use the parser to serialize and write the hosts file # Use the parser to serialize and write the hosts file
parser = HostsParser(str(path)) parser = HostsParser(str(path))
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(
success=True, success=True,
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,17 +118,17 @@ 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:
""" """
Export hosts file to JSON format with metadata. Export hosts file to JSON format with metadata.
Args: Args:
hosts_file: HostsFile instance to export hosts_file: HostsFile instance to export
path: Path where to save the exported file path: Path where to save the exported file
Returns: Returns:
ExportResult with operation details ExportResult with operation details
""" """
@ -121,19 +138,19 @@ 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:
entry_data = { entry_data = {
"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
if entry.dns_name: if entry.dns_name:
entry_data["dns_name"] = entry.dns_name entry_data["dns_name"] = entry.dns_name
@ -143,107 +160,115 @@ class ImportExportService:
entry_data["last_resolved"] = entry.last_resolved.isoformat() entry_data["last_resolved"] = entry.last_resolved.isoformat()
if entry.dns_resolution_status: if entry.dns_resolution_status:
entry_data["dns_resolution_status"] = entry.dns_resolution_status entry_data["dns_resolution_status"] = entry.dns_resolution_status
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(
success=True, success=True,
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:
return ExportResult( return ExportResult(
success=False, success=False,
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:
""" """
Export hosts file to CSV format. Export hosts file to CSV format.
Args: Args:
hosts_file: HostsFile instance to export hosts_file: HostsFile instance to export
path: Path where to save the exported file path: Path where to save the exported file
Returns: Returns:
ExportResult with operation details ExportResult with operation details
""" """
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)
return ExportResult( return ExportResult(
success=True, success=True,
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:
return ExportResult( return ExportResult(
success=False, success=False,
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
def import_hosts_format(self, path: Path) -> ImportResult: def import_hosts_format(self, path: Path) -> ImportResult:
""" """
Import from hosts file format. Import from hosts file format.
Args: Args:
path: Path to the hosts file to import path: Path to the hosts file to import
Returns: Returns:
ImportResult with imported entries and any errors ImportResult with imported entries and any errors
""" """
try: try:
from .parser import HostsParser from .parser import HostsParser
parser = HostsParser(str(path)) parser = HostsParser(str(path))
hosts_file = parser.parse() hosts_file = parser.parse()
return ImportResult( return ImportResult(
success=True, success=True,
entries=hosts_file.entries, entries=hosts_file.entries,
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:
return ImportResult( return ImportResult(
success=False, success=False,
@ -251,61 +276,61 @@ 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:
""" """
Import from JSON format with validation. Import from JSON format with validation.
Args: Args:
path: Path to the JSON file to import path: Path to the JSON file to import
Returns: Returns:
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,39 +339,45 @@ 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)
except ValueError as e: except ValueError as e:
errors.append(f"Entry {i+1}: {str(e)}") errors.append(f"Entry {i + 1}: {str(e)}")
except Exception as e: except Exception as e:
errors.append(f"Entry {i+1}: Unexpected error - {str(e)}") errors.append(f"Entry {i + 1}: Unexpected error - {str(e)}")
return ImportResult( return ImportResult(
success=len(errors) == 0, success=len(errors) == 0,
entries=entries, entries=entries,
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:
return ImportResult( return ImportResult(
success=False, success=False,
@ -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,16 +394,16 @@ 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:
""" """
Import from CSV format with field mapping. Import from CSV format with field mapping.
Args: Args:
path: Path to the CSV file to import path: Path to the CSV file to import
Returns: Returns:
ImportResult with imported entries and any errors ImportResult with imported entries and any errors
""" """
@ -381,18 +412,20 @@ class ImportExportService:
errors = [] errors = []
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)
dialect = csv.Sniffer().sniff(sample) dialect = csv.Sniffer().sniff(sample)
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,39 +433,39 @@ 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
hostnames = [h.strip() for h in hostnames_str.split()] hostnames = [h.strip() for h in hostnames_str.split()]
if not hostnames: if not hostnames:
errors.append(f"Row {row_num}: No valid hostnames found") errors.append(f"Row {row_num}: No valid hostnames found")
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:
# 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=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,38 +475,44 @@ 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)
except ValueError as e: except ValueError as e:
errors.append(f"Row {row_num}: {str(e)}") errors.append(f"Row {row_num}: {str(e)}")
except Exception as e: except Exception as e:
errors.append(f"Row {row_num}: Unexpected error - {str(e)}") errors.append(f"Row {row_num}: Unexpected error - {str(e)}")
return ImportResult( return ImportResult(
success=len(errors) == 0, success=len(errors) == 0,
entries=entries, entries=entries,
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:
return ImportResult( return ImportResult(
success=False, success=False,
@ -481,99 +520,103 @@ 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
def detect_file_format(self, path: Path) -> Optional[ImportFormat]: def detect_file_format(self, path: Path) -> Optional[ImportFormat]:
""" """
Detect the format of a file based on extension and content. Detect the format of a file based on extension and content.
Args: Args:
path: Path to the file to analyze path: Path to the file to analyze
Returns: Returns:
Detected ImportFormat or None if unknown Detected ImportFormat or None if unknown
""" """
if not path.exists(): if not path.exists():
return None return None
# 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
return ImportFormat.HOSTS return ImportFormat.HOSTS
except Exception: except Exception:
return None return None
def validate_export_path(self, path: Path, format: ExportFormat) -> List[str]: def validate_export_path(self, path: Path, format: ExportFormat) -> List[str]:
""" """
Validate export path and return any warnings. Validate export path and return any warnings.
Args: Args:
path: Target export path path: Target export path
format: Export format format: Export format
Returns: Returns:
List of validation warnings List of validation warnings
""" """
warnings = [] warnings = []
# Check if file already exists # Check if file already exists
if path.exists(): if path.exists():
warnings.append(f"File {path} already exists and will be overwritten") warnings.append(f"File {path} already exists and will be overwritten")
# Check if directory exists # Check if directory exists
if not path.parent.exists(): if not path.parent.exists():
warnings.append(f"Directory {path.parent} does not exist") warnings.append(f"Directory {path.parent} does not exist")
# 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:
warnings.append(f"No write permission for directory {path.parent}") warnings.append(f"No write permission for directory {path.parent}")
# 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
def get_supported_export_formats(self) -> List[ExportFormat]: def get_supported_export_formats(self) -> List[ExportFormat]:
"""Get list of supported export formats.""" """Get list of supported export formats."""
return self.supported_export_formats.copy() return self.supported_export_formats.copy()
def get_supported_import_formats(self) -> List[ImportFormat]: def get_supported_import_formats(self) -> List[ImportFormat]:
"""Get list of supported import formats.""" """Get list of supported import formats."""
return self.supported_import_formats.copy() return self.supported_import_formats.copy()

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:
""" """
@ -168,7 +170,7 @@ class HostEntry:
# Build comment section (DNS metadata + user comment) # Build comment section (DNS metadata + user comment)
comment_parts = [] comment_parts = []
# Add DNS metadata if present # Add DNS metadata if present
if self.has_dns_name(): if self.has_dns_name():
dns_meta = f"DNS:{self.dns_name}" dns_meta = f"DNS:{self.dns_name}"
@ -177,11 +179,11 @@ class HostEntry:
if self.last_resolved: if self.last_resolved:
dns_meta += f"|Last:{self.last_resolved.isoformat()}" dns_meta += f"|Last:{self.last_resolved.isoformat()}"
comment_parts.append(dns_meta) comment_parts.append(dns_meta)
# Add user comment if present # Add user comment if present
if self.comment: if self.comment:
comment_parts.append(self.comment) comment_parts.append(self.comment)
# Add complete comment section # Add complete comment section
if comment_parts: if comment_parts:
if len(self.hostnames) <= 1: if len(self.hostnames) <= 1:
@ -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",
@ -119,17 +123,17 @@ class AddEntryModal(ModalScreen):
if pressed_radio and pressed_radio.id == "ip-entry-radio": if pressed_radio and pressed_radio.id == "ip-entry-radio":
# Show IP section, hide DNS section # Show IP section, hide DNS section
ip_section = self.query_one("#ip-section") ip_section = self.query_one("#ip-section")
dns_section = self.query_one("#dns-section") dns_section = self.query_one("#dns-section")
active_checkbox = self.query_one("#active-checkbox", Checkbox) active_checkbox = self.query_one("#active-checkbox", Checkbox)
active_section = self.query_one("#active-checkbox").parent active_section = self.query_one("#active-checkbox").parent
ip_section.remove_class("hidden") ip_section.remove_class("hidden")
dns_section.add_class("hidden") dns_section.add_class("hidden")
# Reset checkbox to default (active) for IP entries # Reset checkbox to default (active) for IP entries
active_checkbox.value = True active_checkbox.value = True
active_section.border_title = "Activate Entry" active_section.border_title = "Activate Entry"
# Focus IP input # Focus IP input
ip_input = self.query_one("#ip-address-input", Input) ip_input = self.query_one("#ip-address-input", Input)
ip_input.focus() ip_input.focus()
@ -139,14 +143,16 @@ class AddEntryModal(ModalScreen):
dns_section = self.query_one("#dns-section") dns_section = self.query_one("#dns-section")
active_checkbox = self.query_one("#active-checkbox", Checkbox) active_checkbox = self.query_one("#active-checkbox", Checkbox)
active_section = self.query_one("#active-checkbox").parent active_section = self.query_one("#active-checkbox").parent
ip_section.add_class("hidden") ip_section.add_class("hidden")
dns_section.remove_class("hidden") dns_section.remove_class("hidden")
# 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)
dns_input.focus() dns_input.focus()
@ -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()
@ -193,14 +202,15 @@ class AddEntryModal(ModalScreen):
) )
# Add DNS name field # Add DNS name field
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,21 +609,27 @@ 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()
# Move cursor to the newly added entry (last entry) # Move cursor to the newly added entry (last entry)
self.selected_entry_index = len(self.hosts_file.entries) - 1 self.selected_entry_index = len(self.hosts_file.entries) - 1
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:
@ -671,7 +708,7 @@ class HostsManagerApp(App):
# Get description before undoing # Get description before undoing
description = self.manager.get_undo_description() description = self.manager.get_undo_description()
# Perform undo # Perform undo
result = self.manager.undo_last_operation(self.hosts_file) result = self.manager.undo_last_operation(self.hosts_file)
if result.success: if result.success:
@ -694,7 +731,7 @@ class HostsManagerApp(App):
# Get description before redoing # Get description before redoing
description = self.manager.get_redo_description() description = self.manager.get_redo_description()
# Perform redo # Perform redo
result = self.manager.redo_last_operation(self.hosts_file) result = self.manager.redo_last_operation(self.hosts_file)
if result.success: if result.success:
@ -734,25 +771,25 @@ class HostsManagerApp(App):
try: try:
# Extract DNS names (not hostnames!) from entries # Extract DNS names (not hostnames!) from entries
dns_names = [entry.dns_name for entry in dns_entries if entry.dns_name] dns_names = [entry.dns_name for entry in dns_entries if entry.dns_name]
if not dns_names: if not dns_names:
self.update_status("No valid DNS names found to resolve") self.update_status("No valid DNS names found to resolve")
return return
resolved_count = 0 resolved_count = 0
failed_count = 0 failed_count = 0
# Resolve each DNS name and apply results back to entries # Resolve each DNS name and apply results back to entries
for dns_name in dns_names: for dns_name in dns_names:
resolution = await self.dns_service.resolve_entry_async(dns_name) resolution = await self.dns_service.resolve_entry_async(dns_name)
# Find the corresponding entry and update it # Find the corresponding entry and update it
for entry in dns_entries: for entry in dns_entries:
if entry.dns_name == dns_name: if entry.dns_name == dns_name:
# Apply resolution results to entry fields # Apply resolution results to entry fields
entry.last_resolved = resolution.resolved_at entry.last_resolved = resolution.resolved_at
entry.dns_resolution_status = resolution.status.value entry.dns_resolution_status = resolution.status.value
if resolution.is_success(): if resolution.is_success():
# Update both resolved_ip and ip_address for the hosts file # Update both resolved_ip and ip_address for the hosts file
entry.ip_address = resolution.resolved_ip entry.ip_address = resolution.resolved_ip
@ -761,27 +798,37 @@ class HostsManagerApp(App):
else: else:
failed_count += 1 failed_count += 1
break break
# 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
self.table_handler.populate_entries_table() self.table_handler.populate_entries_table()
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()
# 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}")
@ -806,9 +853,9 @@ class HostsManagerApp(App):
return return
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
@ -818,43 +865,53 @@ class HostsManagerApp(App):
async def update_single_dns(): async def update_single_dns():
try: try:
dns_name = entry.dns_name dns_name = entry.dns_name
# Resolve the DNS name # Resolve the DNS name
resolution = await self.dns_service.resolve_entry_async(dns_name) resolution = await self.dns_service.resolve_entry_async(dns_name)
# Apply resolution results to entry fields # Apply resolution results to entry fields
entry.last_resolved = resolution.resolved_at entry.last_resolved = resolution.resolved_at
entry.dns_resolution_status = resolution.status.value entry.dns_resolution_status = resolution.status.value
if resolution.is_success(): if resolution.is_success():
# Update both resolved_ip and ip_address for the hosts file # Update both resolved_ip and ip_address for the hosts file
entry.ip_address = resolution.resolved_ip entry.ip_address = resolution.resolved_ip
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
self.table_handler.populate_entries_table() self.table_handler.populate_entries_table()
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()
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()
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
@ -872,7 +930,7 @@ class HostsManagerApp(App):
# Apply the new filter options # Apply the new filter options
self.current_filter_options = filter_options self.current_filter_options = filter_options
# Update the search term from filter if it has one # Update the search term from filter if it has one
if filter_options.search_term: if filter_options.search_term:
self.search_term = filter_options.search_term self.search_term = filter_options.search_term
@ -894,72 +952,92 @@ class HostsManagerApp(App):
# Refresh the table with new filtering # Refresh the table with new filtering
self.table_handler.populate_entries_table() self.table_handler.populate_entries_table()
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():
try: try:
# Resolve the DNS name # Resolve the DNS name
resolution = await self.dns_service.resolve_entry_async(entry.dns_name) resolution = await self.dns_service.resolve_entry_async(entry.dns_name)
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
hosts_entry.last_resolved = resolution.resolved_at hosts_entry.last_resolved = resolution.resolved_at
hosts_entry.dns_resolution_status = resolution.status.value hosts_entry.dns_resolution_status = resolution.status.value
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,8 +138,10 @@ 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
dns_name_input.value = "" dns_name_input.value = ""
@ -162,13 +164,17 @@ 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
dns_status_input.placeholder = "" dns_status_input.placeholder = ""
else: else:

View file

@ -14,7 +14,7 @@ from ..core.dns import DNSService
class DNSStatusWidget(Static): class DNSStatusWidget(Static):
""" """
Widget to display DNS resolution service status. Widget to display DNS resolution service status.
Shows visual indicators for DNS service status and resolution progress. Shows visual indicators for DNS service status and resolution progress.
""" """
@ -61,12 +61,12 @@ class DNSStatusWidget(Static):
status_parts.append(f"{self.resolved_count} resolved") status_parts.append(f"{self.resolved_count} resolved")
if self.failed_count > 0: if self.failed_count > 0:
status_parts.append(f"{self.failed_count} failed") status_parts.append(f"{self.failed_count} failed")
if status_parts: if status_parts:
status_text = f"DNS: Active ({', '.join(status_parts)})" status_text = f"DNS: Active ({', '.join(status_parts)})"
else: else:
status_text = "DNS: Active" status_text = "DNS: Active"
text_widget.update(status_text) text_widget.update(status_text)
indicator.remove_class("dns-disabled") indicator.remove_class("dns-disabled")
indicator.remove_class("dns-resolving") indicator.remove_class("dns-resolving")
@ -95,14 +95,20 @@ class DNSStatusWidget(Static):
def update_from_service(self) -> None: def update_from_service(self) -> None:
"""Update status from the current DNS service state.""" """Update status from the current DNS service state."""
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
self.failed_count = failed self.failed_count = failed
@ -117,7 +123,7 @@ class DNSStatusWidget(Static):
self.dns_service.stop() self.dns_service.stop()
else: else:
self.dns_service.start() self.dns_service.start()
self.dns_enabled = self.dns_service.enabled self.dns_enabled = self.dns_service.enabled
self.update_status() self.update_status()
@ -133,7 +139,7 @@ class DNSStatusWidget(Static):
parts.append(f"{self.resolved_count} resolved") parts.append(f"{self.resolved_count} resolved")
if self.failed_count > 0: if self.failed_count > 0:
parts.append(f"{self.failed_count} failed") parts.append(f"{self.failed_count} failed")
if parts: if parts:
return f"DNS Active ({', '.join(parts)})" return f"DNS Active ({', '.join(parts)})"
else: else:

View file

@ -24,11 +24,11 @@ class EditHandler:
self.app.hosts_file.entries self.app.hosts_file.entries
): ):
return "ip" # Default to IP type return "ip" # Default to IP type
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"
@ -38,32 +38,35 @@ class EditHandler:
if entry_type == "ip": if entry_type == "ip":
# Show IP section, hide DNS section # Show IP section, hide DNS section
self.update_field_visibility(show_ip=True, show_dns=False) self.update_field_visibility(show_ip=True, show_dns=False)
# Focus IP input # Focus IP input
try: try:
ip_input = self.app.query_one("#ip-input", Input) ip_input = self.app.query_one("#ip-input", Input)
ip_input.focus() ip_input.focus()
except Exception: except Exception:
pass pass
elif entry_type == "dns": elif entry_type == "dns":
# Show DNS section, hide IP section # Show DNS section, hide IP section
self.update_field_visibility(show_ip=False, show_dns=True) self.update_field_visibility(show_ip=False, show_dns=True)
# 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
dns_input.focus() dns_input.focus()
else: else:
@ -78,17 +81,17 @@ class EditHandler:
try: try:
ip_section = self.app.query_one("#edit-ip-section") ip_section = self.app.query_one("#edit-ip-section")
dns_section = self.app.query_one("#edit-dns-section") dns_section = self.app.query_one("#edit-dns-section")
if show_ip: if show_ip:
ip_section.remove_class("hidden") ip_section.remove_class("hidden")
else: else:
ip_section.add_class("hidden") ip_section.add_class("hidden")
if show_dns: if show_dns:
dns_section.remove_class("hidden") dns_section.remove_class("hidden")
else: else:
dns_section.add_class("hidden") dns_section.add_class("hidden")
except Exception: except Exception:
# Sections not found, ignore silently # Sections not found, ignore silently
pass pass
@ -97,7 +100,7 @@ class EditHandler:
"""Initialize edit form with correct radio button state and field visibility.""" """Initialize edit form with correct radio button state and field visibility."""
if not self.app.entry_edit_mode: if not self.app.entry_edit_mode:
return return
# Use a timer to delay radio button setup to allow widgets to initialize # Use a timer to delay radio button setup to allow widgets to initialize
self.app.set_timer(0.1, self._delayed_radio_setup) self.app.set_timer(0.1, self._delayed_radio_setup)
@ -105,36 +108,36 @@ class EditHandler:
"""Set up radio buttons after a small delay to ensure widgets are ready.""" """Set up radio buttons after a small delay to ensure widgets are ready."""
if not self.app.entry_edit_mode: if not self.app.entry_edit_mode:
return return
# Determine current entry type # Determine current entry type
entry_type = self.get_current_entry_type() entry_type = self.get_current_entry_type()
try: try:
# Get current entry for DNS field population # Get current entry for DNS field population
entry = self.app.hosts_file.entries[self.app.selected_entry_index] entry = self.app.hosts_file.entries[self.app.selected_entry_index]
# Get radio buttons # Get radio buttons
ip_radio = self.app.query_one("#edit-ip-entry-radio") ip_radio = self.app.query_one("#edit-ip-entry-radio")
dns_radio = self.app.query_one("#edit-dns-entry-radio") dns_radio = self.app.query_one("#edit-dns-entry-radio")
# Set radio button values - let RadioSet manage pressed_button automatically # Set radio button values - let RadioSet manage pressed_button automatically
if entry_type == "ip": if entry_type == "ip":
# Clear DNS radio first, then set IP radio # Clear DNS radio first, then set IP radio
dns_radio.value = False dns_radio.value = False
ip_radio.value = True ip_radio.value = True
else: else:
# Clear IP radio first, then set DNS radio # Clear IP radio first, then set DNS radio
ip_radio.value = False ip_radio.value = False
dns_radio.value = True dns_radio.value = True
# Update field visibility # Update field visibility
self.handle_entry_type_change(entry_type) self.handle_entry_type_change(entry_type)
# 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
self.app.update_status(f"Debug: populate_edit_form error: {e}") self.app.update_status(f"Debug: populate_edit_form error: {e}")
@ -149,7 +152,7 @@ class EditHandler:
hostname_input = self.app.query_one("#hostname-input", Input) hostname_input = self.app.query_one("#hostname-input", Input)
comment_input = self.app.query_one("#comment-input", Input) comment_input = self.app.query_one("#comment-input", Input)
active_checkbox = self.app.query_one("#active-checkbox", Checkbox) active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
# Try to get DNS input - may not exist in all contexts # Try to get DNS input - may not exist in all contexts
try: try:
dns_input = self.app.query_one("#dns-name-input", Input) dns_input = self.app.query_one("#dns-name-input", Input)
@ -219,7 +222,7 @@ class EditHandler:
hostname_input = self.app.query_one("#hostname-input", Input) hostname_input = self.app.query_one("#hostname-input", Input)
comment_input = self.app.query_one("#comment-input", Input) comment_input = self.app.query_one("#comment-input", Input)
active_checkbox = self.app.query_one("#active-checkbox", Checkbox) active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
# Try to get DNS input - may not exist in all contexts # Try to get DNS input - may not exist in all contexts
try: try:
dns_input = self.app.query_one("#dns-name-input", Input) dns_input = self.app.query_one("#dns-name-input", Input)
@ -237,7 +240,7 @@ class EditHandler:
dns_name = self.app.original_entry_values.get("dns_name") dns_name = self.app.original_entry_values.get("dns_name")
ip_radio = self.app.query_one("#edit-ip-entry-radio") ip_radio = self.app.query_one("#edit-ip-entry-radio")
dns_radio = self.app.query_one("#edit-dns-entry-radio") dns_radio = self.app.query_one("#edit-dns-entry-radio")
if dns_name: if dns_name:
# Was DNS entry - set DNS radio and show DNS field # Was DNS entry - set DNS radio and show DNS field
ip_radio.value = False ip_radio.value = False
@ -254,11 +257,13 @@ class EditHandler:
def validate_entry_by_type(self, entry_type: str) -> bool: def validate_entry_by_type(self, entry_type: str) -> bool:
"""Type-specific validation for IP or DNS entries.""" """Type-specific validation for IP or DNS entries."""
hostname_input = self.app.query_one("#hostname-input", Input) hostname_input = self.app.query_one("#hostname-input", Input)
# 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,16 +272,20 @@ 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":
# Validate IP address # Validate IP address
try: try:
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,9 +297,11 @@ 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
if ( if (
" " in dns_name " " in dns_name
@ -299,12 +310,16 @@ 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
def validate_and_save_entry_changes(self) -> bool: def validate_and_save_entry_changes(self) -> bool:
@ -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
@ -385,10 +401,12 @@ class EditHandler:
) )
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)
# 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
@ -407,10 +425,10 @@ class EditHandler:
hostname_input = self.app.query_one("#hostname-input", Input) hostname_input = self.app.query_one("#hostname-input", Input)
comment_input = self.app.query_one("#comment-input", Input) comment_input = self.app.query_one("#comment-input", Input)
active_checkbox = self.app.query_one("#active-checkbox", Checkbox) active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
# Build field list based on current entry type # Build field list based on current entry type
fields = [radio_set] fields = [radio_set]
# Add IP or DNS field based on visibility # Add IP or DNS field based on visibility
try: try:
ip_section = self.app.query_one("#edit-ip-section") ip_section = self.app.query_one("#edit-ip-section")
@ -419,7 +437,7 @@ class EditHandler:
fields.append(ip_input) fields.append(ip_input)
except Exception: except Exception:
pass pass
try: try:
dns_section = self.app.query_one("#edit-dns-section") dns_section = self.app.query_one("#edit-dns-section")
if not dns_section.has_class("hidden"): if not dns_section.has_class("hidden"):
@ -427,7 +445,7 @@ class EditHandler:
fields.append(dns_input) fields.append(dns_input)
except Exception: except Exception:
pass pass
# Add remaining fields # Add remaining fields
fields.extend([hostname_input, comment_input, active_checkbox]) fields.extend([hostname_input, comment_input, active_checkbox])
@ -437,7 +455,7 @@ class EditHandler:
next_field = fields[(i + 1) % len(fields)] next_field = fields[(i + 1) % len(fields)]
next_field.focus() next_field.focus()
break break
except Exception: except Exception:
# Fallback to original navigation if widgets not ready # Fallback to original navigation if widgets not ready
pass pass
@ -453,10 +471,10 @@ class EditHandler:
hostname_input = self.app.query_one("#hostname-input", Input) hostname_input = self.app.query_one("#hostname-input", Input)
comment_input = self.app.query_one("#comment-input", Input) comment_input = self.app.query_one("#comment-input", Input)
active_checkbox = self.app.query_one("#active-checkbox", Checkbox) active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
# Build field list based on current entry type # Build field list based on current entry type
fields = [radio_set] fields = [radio_set]
# Add IP or DNS field based on visibility # Add IP or DNS field based on visibility
try: try:
ip_section = self.app.query_one("#edit-ip-section") ip_section = self.app.query_one("#edit-ip-section")
@ -465,7 +483,7 @@ class EditHandler:
fields.append(ip_input) fields.append(ip_input)
except Exception: except Exception:
pass pass
try: try:
dns_section = self.app.query_one("#edit-dns-section") dns_section = self.app.query_one("#edit-dns-section")
if not dns_section.has_class("hidden"): if not dns_section.has_class("hidden"):
@ -473,7 +491,7 @@ class EditHandler:
fields.append(dns_input) fields.append(dns_input)
except Exception: except Exception:
pass pass
# Add remaining fields # Add remaining fields
fields.extend([hostname_input, comment_input, active_checkbox]) fields.extend([hostname_input, comment_input, active_checkbox])
@ -483,7 +501,7 @@ class EditHandler:
prev_field = fields[(i - 1) % len(fields)] prev_field = fields[(i - 1) % len(fields)]
prev_field.focus() prev_field.focus()
break break
except Exception: except Exception:
# Fallback to original navigation if widgets not ready # Fallback to original navigation if widgets not ready
pass pass

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
@ -21,7 +28,7 @@ from ..core.filters import FilterOptions, EntryFilter
class FilterModal(ModalScreen[Optional[FilterOptions]]): class FilterModal(ModalScreen[Optional[FilterOptions]]):
"""Advanced filtering configuration modal.""" """Advanced filtering configuration modal."""
DEFAULT_CSS = """ DEFAULT_CSS = """
FilterModal { FilterModal {
align: center middle; align: center middle;
@ -140,17 +147,20 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
margin: 1 0; margin: 1 0;
} }
""" """
# Reactive properties for real-time updates # Reactive properties for real-time updates
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__(
entries: Optional[List] = None, self,
entry_filter: Optional[EntryFilter] = None): initial_options: Optional[FilterOptions] = None,
entries: Optional[List] = None,
entry_filter: Optional[EntryFilter] = None,
):
""" """
Initialize filter modal. Initialize filter modal.
Args: Args:
initial_options: Current filter options to display initial_options: Current filter options to display
entries: List of entries for count preview entries: List of entries for count preview
@ -161,12 +171,12 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
self.entries = entries or [] self.entries = entries or []
self.entry_filter = entry_filter or EntryFilter() self.entry_filter = entry_filter or EntryFilter()
self.entry_counts = self._calculate_counts() self.entry_counts = self._calculate_counts()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Compose the filter modal interface.""" """Compose the filter modal interface."""
with Grid(id="filter-dialog"): with Grid(id="filter-dialog"):
yield Static("Advanced Filtering", id="filter-header") yield Static("Advanced Filtering", id="filter-header")
with Container(id="filter-content"): with Container(id="filter-content"):
# Filter presets section # Filter presets section
with Collapsible(title="Filter Presets", collapsed=False): with Collapsible(title="Filter Presets", collapsed=False):
@ -174,97 +184,159 @@ 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")
yield Button("Delete", id="delete-preset", variant="error") yield Button("Delete", id="delete-preset", variant="error")
# Status filtering section # Status filtering section
with Collapsible(title="Status Filtering", collapsed=False): with Collapsible(title="Status Filtering", collapsed=False):
with Container(classes="filter-section"): with Container(classes="filter-section"):
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(
with Container(classes="filter-checkboxes", id="status-custom-options"): "Inactive Only", value="inactive", id="status-inactive"
yield Checkbox("Show Active Entries", value=True, id="show-active") )
yield Checkbox("Show Inactive Entries", value=True, id="show-inactive") yield RadioButton(
"Custom", value="custom", id="status-custom"
)
with Container(
classes="filter-checkboxes", id="status-custom-options"
):
yield Checkbox(
"Show Active Entries", value=True, id="show-active"
)
yield Checkbox(
"Show Inactive Entries", value=True, id="show-inactive"
)
# DNS type filtering section # 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(
with Container(classes="filter-checkboxes", id="type-custom-options"): "IP Entries Only", value="ip", id="type-ip"
yield Checkbox("Show DNS Entries", value=True, id="show-dns") )
yield RadioButton(
"Custom", value="custom", id="type-custom"
)
with Container(
classes="filter-checkboxes", id="type-custom-options"
):
yield Checkbox(
"Show DNS Entries", value=True, id="show-dns"
)
yield Checkbox("Show IP Entries", value=True, id="show-ip") 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",
with Container(classes="filter-checkboxes", id="resolution-custom-options"): value="resolved",
yield Checkbox("Show Resolved", value=True, id="show-resolved") id="resolution-resolved",
yield Checkbox("Show Unresolved", value=True, id="show-unresolved") )
yield Checkbox("Show Resolving", value=True, id="show-resolving") yield RadioButton(
"Mismatches Only",
value="mismatch",
id="resolution-mismatch",
)
yield RadioButton(
"Custom", value="custom", id="resolution-custom"
)
with Container(
classes="filter-checkboxes", id="resolution-custom-options"
):
yield Checkbox(
"Show Resolved", value=True, id="show-resolved"
)
yield Checkbox(
"Show Unresolved", value=True, id="show-unresolved"
)
yield Checkbox(
"Show Resolving", value=True, id="show-resolving"
)
yield Checkbox("Show Failed", value=True, id="show-failed") yield Checkbox("Show 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):
with Container(classes="filter-section"): with Container(classes="filter-section"):
yield Static("Search Filtering", classes="filter-section-title") yield Static("Search Filtering", classes="filter-section-title")
with Horizontal(classes="filter-input-row"): with Horizontal(classes="filter-input-row"):
yield Label("Search term:", classes="filter-input-label") yield Label("Search term:", classes="filter-input-label")
yield Input( yield Input(
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")
with Horizontal(id="filter-actions"): with Horizontal(id="filter-actions"):
yield Button("Apply", id="apply", variant="primary") yield Button("Apply", id="apply", variant="primary")
yield Button("Reset", id="reset") yield Button("Reset", id="reset")
yield Button("Cancel", id="cancel") yield Button("Cancel", id="cancel")
def on_mount(self) -> None: def on_mount(self) -> None:
"""Initialize the modal with current options.""" """Initialize the modal with current options."""
self._update_ui_from_options() self._update_ui_from_options()
self._update_count_display() self._update_count_display()
def _update_ui_from_options(self) -> None: def _update_ui_from_options(self) -> None:
"""Update UI controls to reflect current options.""" """Update UI controls to reflect current options."""
options = self.current_options options = self.current_options
# Status filtering # Status filtering
if options.active_only: if options.active_only:
self.query_one("#status-active", RadioButton).value = True self.query_one("#status-active", RadioButton).value = True
@ -274,10 +346,10 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
self.query_one("#status-all", RadioButton).value = True self.query_one("#status-all", RadioButton).value = True
else: else:
self.query_one("#status-custom", RadioButton).value = True self.query_one("#status-custom", RadioButton).value = True
self.query_one("#show-active", Checkbox).value = options.show_active self.query_one("#show-active", Checkbox).value = options.show_active
self.query_one("#show-inactive", Checkbox).value = options.show_inactive self.query_one("#show-inactive", Checkbox).value = options.show_inactive
# Type filtering # Type filtering
if options.dns_only: if options.dns_only:
self.query_one("#type-dns", RadioButton).value = True self.query_one("#type-dns", RadioButton).value = True
@ -287,60 +359,71 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
self.query_one("#type-all", RadioButton).value = True self.query_one("#type-all", RadioButton).value = True
else: else:
self.query_one("#type-custom", RadioButton).value = True self.query_one("#type-custom", RadioButton).value = True
self.query_one("#show-dns", Checkbox).value = options.show_dns_entries self.query_one("#show-dns", Checkbox).value = options.show_dns_entries
self.query_one("#show-ip", Checkbox).value = options.show_ip_entries self.query_one("#show-ip", Checkbox).value = options.show_ip_entries
# Resolution status filtering # Resolution status filtering
if options.resolved_only: if options.resolved_only:
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
self.query_one("#show-resolved", Checkbox).value = options.show_resolved self.query_one("#show-resolved", Checkbox).value = options.show_resolved
self.query_one("#show-unresolved", Checkbox).value = options.show_unresolved self.query_one("#show-unresolved", Checkbox).value = options.show_unresolved
self.query_one("#show-resolving", Checkbox).value = options.show_resolving self.query_one("#show-resolving", Checkbox).value = options.show_resolving
self.query_one("#show-failed", Checkbox).value = options.show_failed self.query_one("#show-failed", Checkbox).value = options.show_failed
self.query_one("#show-mismatched", Checkbox).value = options.show_mismatched self.query_one("#show-mismatched", Checkbox).value = options.show_mismatched
# 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()
def _update_custom_options_visibility(self) -> None: def _update_custom_options_visibility(self) -> None:
"""Show/hide custom option containers based on radio selections.""" """Show/hide custom option containers based on radio selections."""
# Status custom options # Status custom options
status_custom = self.query_one("#status-custom", RadioButton).value status_custom = self.query_one("#status-custom", RadioButton).value
status_container = self.query_one("#status-custom-options") status_container = self.query_one("#status-custom-options")
status_container.display = status_custom status_container.display = status_custom
# Type custom options # Type custom options
type_custom = self.query_one("#type-custom", RadioButton).value type_custom = self.query_one("#type-custom", RadioButton).value
type_container = self.query_one("#type-custom-options") type_container = self.query_one("#type-custom-options")
type_container.display = type_custom type_container.display = type_custom
# Resolution custom options # Resolution custom options
resolution_custom = self.query_one("#resolution-custom", RadioButton).value resolution_custom = self.query_one("#resolution-custom", RadioButton).value
resolution_container = self.query_one("#resolution-custom-options") resolution_container = self.query_one("#resolution-custom-options")
resolution_container.display = resolution_custom resolution_container.display = resolution_custom
def _calculate_counts(self) -> Dict[str, int]: def _calculate_counts(self) -> Dict[str, int]:
"""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."""
counts = self._calculate_counts() counts = self._calculate_counts()
@ -351,9 +434,9 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
) )
else: else:
count_text = "No entries to filter" count_text = "No entries to filter"
self.query_one("#count-display", Static).update(count_text) self.query_one("#count-display", Static).update(count_text)
def _get_current_options_from_ui(self) -> FilterOptions: def _get_current_options_from_ui(self) -> FilterOptions:
"""Extract current filter options from UI controls.""" """Extract current filter options from UI controls."""
# Status filtering # Status filtering
@ -371,7 +454,7 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
show_active = self.query_one("#show-active", Checkbox).value show_active = self.query_one("#show-active", Checkbox).value
show_inactive = self.query_one("#show-inactive", Checkbox).value show_inactive = self.query_one("#show-inactive", Checkbox).value
active_only, inactive_only = False, False active_only, inactive_only = False, False
# Type filtering # Type filtering
type_type = self.query_one("#type-filter-type", RadioSet).pressed_button type_type = self.query_one("#type-filter-type", RadioSet).pressed_button
if type_type and type_type.id == "type-dns": if type_type and type_type.id == "type-dns":
@ -387,18 +470,38 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
show_dns_entries = self.query_one("#show-dns", Checkbox).value show_dns_entries = self.query_one("#show-dns", Checkbox).value
show_ip_entries = self.query_one("#show-ip", Checkbox).value show_ip_entries = self.query_one("#show-ip", Checkbox).value
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
@ -406,14 +509,14 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
show_resolving = self.query_one("#show-resolving", Checkbox).value show_resolving = self.query_one("#show-resolving", Checkbox).value
show_failed = self.query_one("#show-failed", Checkbox).value show_failed = self.query_one("#show-failed", Checkbox).value
show_mismatched = self.query_one("#show-mismatched", Checkbox).value show_mismatched = self.query_one("#show-mismatched", Checkbox).value
# Search filtering # Search filtering
search_term = self.query_one("#search-term", Input).value or None search_term = self.query_one("#search-term", Input).value or None
search_hostnames = self.query_one("#search-hostnames", Checkbox).value search_hostnames = self.query_one("#search-hostnames", Checkbox).value
search_comments = self.query_one("#search-comments", Checkbox).value search_comments = self.query_one("#search-comments", Checkbox).value
search_ips = self.query_one("#search-ips", Checkbox).value search_ips = self.query_one("#search-ips", Checkbox).value
case_sensitive = self.query_one("#search-case-sensitive", Checkbox).value case_sensitive = self.query_one("#search-case-sensitive", Checkbox).value
return FilterOptions( return FilterOptions(
show_active=show_active, show_active=show_active,
show_inactive=show_inactive, show_inactive=show_inactive,
@ -434,40 +537,40 @@ 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)
def on_radio_changed(self, event: RadioSet.Changed) -> None: def on_radio_changed(self, event: RadioSet.Changed) -> None:
"""Handle radio button changes.""" """Handle radio button changes."""
self._update_custom_options_visibility() self._update_custom_options_visibility()
self.current_options = self._get_current_options_from_ui() self.current_options = self._get_current_options_from_ui()
self._update_count_display() self._update_count_display()
@on(Checkbox.Changed) @on(Checkbox.Changed)
@on(Input.Changed) @on(Input.Changed)
def on_input_changed(self) -> None: def on_input_changed(self) -> None:
"""Handle input changes for real-time preview.""" """Handle input changes for real-time preview."""
self.current_options = self._get_current_options_from_ui() self.current_options = self._get_current_options_from_ui()
self._update_count_display() self._update_count_display()
@on(Button.Pressed, "#apply") @on(Button.Pressed, "#apply")
def on_apply_pressed(self) -> None: def on_apply_pressed(self) -> None:
"""Handle apply button press.""" """Handle apply button press."""
self.dismiss(self._get_current_options_from_ui()) self.dismiss(self._get_current_options_from_ui())
@on(Button.Pressed, "#cancel") @on(Button.Pressed, "#cancel")
def on_cancel_pressed(self) -> None: def on_cancel_pressed(self) -> None:
"""Handle cancel button press.""" """Handle cancel button press."""
self.dismiss(None) self.dismiss(None)
@on(Button.Pressed, "#reset") @on(Button.Pressed, "#reset")
def on_reset_pressed(self) -> None: def on_reset_pressed(self) -> None:
"""Handle reset button press.""" """Handle reset button press."""
self.current_options = FilterOptions() self.current_options = FilterOptions()
self._update_ui_from_options() self._update_ui_from_options()
self._update_count_display() self._update_count_display()
@on(Button.Pressed, "#load-preset") @on(Button.Pressed, "#load-preset")
def on_load_preset_pressed(self) -> None: def on_load_preset_pressed(self) -> None:
"""Handle load preset button press.""" """Handle load preset button press."""
@ -478,7 +581,7 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
self.current_options = preset_options self.current_options = preset_options
self._update_ui_from_options() self._update_ui_from_options()
self._update_count_display() self._update_count_display()
@on(Button.Pressed, "#save-preset") @on(Button.Pressed, "#save-preset")
def on_save_preset_pressed(self) -> None: def on_save_preset_pressed(self) -> None:
"""Handle save preset button press.""" """Handle save preset button press."""
@ -487,12 +590,14 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
current_options = self._get_current_options_from_ui() current_options = self._get_current_options_from_ui()
preset_name = f"Custom Preset {len(self.entry_filter.presets) + 1}" preset_name = f"Custom Preset {len(self.entry_filter.presets) + 1}"
self.entry_filter.save_preset(preset_name, current_options) self.entry_filter.save_preset(preset_name, current_options)
# 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")
def on_delete_preset_pressed(self) -> None: def on_delete_preset_pressed(self) -> None:
"""Handle delete preset button press.""" """Handle delete preset button press."""
@ -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()
@ -251,10 +255,10 @@ class TableHandler:
# Start with the DNS name # Start with the DNS name
dns_display = entry.dns_name dns_display = entry.dns_name
# Add status indicator based on resolution status # Add status indicator based on resolution status
dns_status = entry.dns_resolution_status or "not_resolved" dns_status = entry.dns_resolution_status or "not_resolved"
if dns_status == "not_resolved": if dns_status == "not_resolved":
status_icon = "" status_icon = ""
style = "dim yellow" style = "dim yellow"
@ -276,7 +280,7 @@ class TableHandler:
else: else:
status_icon = "" status_icon = ""
style = "dim white" style = "dim white"
return Text(f"{status_icon} {dns_display}", style=style) return Text(f"{status_icon} {dns_display}", style=style)
def sort_entries_by_hostname(self) -> None: def sort_entries_by_hostname(self) -> None:

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
@ -47,12 +47,9 @@ class TestAddEntryModalDNSSupport:
"""Test validation for IP entry with missing IP address.""" """Test validation for IP entry with missing IP address."""
# Mock the error display method # Mock the error display method
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
@ -71,12 +68,9 @@ class TestAddEntryModalDNSSupport:
"""Test validation for DNS entry with missing DNS name.""" """Test validation for DNS entry with missing DNS name."""
# Mock the error display method # Mock the error display method
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")
@ -85,55 +79,58 @@ class TestAddEntryModalDNSSupport:
"""Test validation for DNS entry with invalid DNS name format.""" """Test validation for DNS entry with invalid DNS name format."""
# Mock the error display method # Mock the error display method
self.modal._show_error = Mock() self.modal._show_error = Mock()
# Test various invalid DNS name formats # Test various invalid DNS name formats
invalid_dns_names = [ invalid_dns_names = [
"example .com", # Contains space "example .com", # Contains space
".example.com", # Starts with dot ".example.com", # Starts with dot
"example.com.", # Ends with dot "example.com.", # Ends with dot
"example..com", # Double dots "example..com", # Double dots
"ex@mple.com", # Invalid characters "ex@mple.com", # Invalid characters
] ]
for invalid_dns in invalid_dns_names: for invalid_dns in invalid_dns_names:
result = self.modal._validate_input( result = self.modal._validate_input(
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."""
# Mock the error display method # Mock the error display method
self.modal._show_error = Mock() self.modal._show_error = Mock()
# 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."""
# Mock the error display method # Mock the error display method
self.modal._show_error = Mock() self.modal._show_error = Mock()
# Test with invalid hostname containing spaces # Test with invalid hostname containing spaces
result = self.modal._validate_input( result = self.modal._validate_input(
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."""
@ -141,7 +138,7 @@ class TestAddEntryModalDNSSupport:
mock_ip_error = Mock(spec=Static) mock_ip_error = Mock(spec=Static)
mock_dns_error = Mock(spec=Static) mock_dns_error = Mock(spec=Static)
mock_hostnames_error = Mock(spec=Static) mock_hostnames_error = Mock(spec=Static)
def mock_query_one(selector, widget_type): def mock_query_one(selector, widget_type):
if selector == "#ip-error": if selector == "#ip-error":
return mock_ip_error return mock_ip_error
@ -150,12 +147,12 @@ class TestAddEntryModalDNSSupport:
elif selector == "#hostnames-error": elif selector == "#hostnames-error":
return mock_hostnames_error return mock_hostnames_error
return Mock() return Mock()
self.modal.query_one = Mock(side_effect=mock_query_one) self.modal.query_one = Mock(side_effect=mock_query_one)
# Call clear_errors # Call clear_errors
self.modal._clear_errors() self.modal._clear_errors()
# Verify all error widgets were cleared # Verify all error widgets were cleared
mock_ip_error.update.assert_called_with("") mock_ip_error.update.assert_called_with("")
mock_dns_error.update.assert_called_with("") mock_dns_error.update.assert_called_with("")
@ -166,10 +163,10 @@ class TestAddEntryModalDNSSupport:
# Mock the query_one method to return a mock widget # Mock the query_one method to return a mock widget
mock_error_widget = Mock(spec=Static) mock_error_widget = Mock(spec=Static)
self.modal.query_one = Mock(return_value=mock_error_widget) self.modal.query_one = Mock(return_value=mock_error_widget)
# Test showing an error # Test showing an error
self.modal._show_error("dns-error", "Test error message") self.modal._show_error("dns-error", "Test error message")
# Verify the error widget was updated # Verify the error widget was updated
self.modal.query_one.assert_called_with("#dns-error", Static) self.modal.query_one.assert_called_with("#dns-error", Static)
mock_error_widget.update.assert_called_with("Test error message") mock_error_widget.update.assert_called_with("Test error message")
@ -178,7 +175,7 @@ class TestAddEntryModalDNSSupport:
"""Test that show_error handles missing widgets gracefully.""" """Test that show_error handles missing widgets gracefully."""
# Mock query_one to raise an exception # Mock query_one to raise an exception
self.modal.query_one = Mock(side_effect=Exception("Widget not found")) self.modal.query_one = Mock(side_effect=Exception("Widget not found"))
# This should not raise an exception # This should not raise an exception
try: try:
self.modal._show_error("dns-error", "Test error message") self.modal._show_error("dns-error", "Test error message")
@ -199,7 +196,7 @@ class TestAddEntryModalRadioButtonLogic:
mock_ip_section = Mock() mock_ip_section = Mock()
mock_dns_section = Mock() mock_dns_section = Mock()
mock_ip_input = Mock(spec=Input) mock_ip_input = Mock(spec=Input)
def mock_query_one(selector, widget_type=None): def mock_query_one(selector, widget_type=None):
if selector == "#ip-section": if selector == "#ip-section":
return mock_ip_section return mock_ip_section
@ -208,25 +205,25 @@ class TestAddEntryModalRadioButtonLogic:
elif selector == "#ip-address-input": elif selector == "#ip-address-input":
return mock_ip_input return mock_ip_input
return Mock() return Mock()
self.modal.query_one = Mock(side_effect=mock_query_one) self.modal.query_one = Mock(side_effect=mock_query_one)
# Create mock event # Create mock event
mock_radio = Mock() mock_radio = Mock()
mock_radio.id = "ip-entry-radio" mock_radio.id = "ip-entry-radio"
mock_radio_set = Mock() mock_radio_set = Mock()
mock_radio_set.id = "entry-type-radio" mock_radio_set.id = "entry-type-radio"
class MockEvent: class MockEvent:
def __init__(self): def __init__(self):
self.radio_set = mock_radio_set self.radio_set = mock_radio_set
self.pressed = mock_radio self.pressed = mock_radio
event = MockEvent() event = MockEvent()
# Call the event handler # Call the event handler
self.modal.on_radio_set_changed(event) self.modal.on_radio_set_changed(event)
# Verify IP section is shown and DNS section is hidden # Verify IP section is shown and DNS section is hidden
mock_ip_section.remove_class.assert_called_with("hidden") mock_ip_section.remove_class.assert_called_with("hidden")
mock_dns_section.add_class.assert_called_with("hidden") mock_dns_section.add_class.assert_called_with("hidden")
@ -238,7 +235,7 @@ class TestAddEntryModalRadioButtonLogic:
mock_ip_section = Mock() mock_ip_section = Mock()
mock_dns_section = Mock() mock_dns_section = Mock()
mock_dns_input = Mock(spec=Input) mock_dns_input = Mock(spec=Input)
def mock_query_one(selector, widget_type=None): def mock_query_one(selector, widget_type=None):
if selector == "#ip-section": if selector == "#ip-section":
return mock_ip_section return mock_ip_section
@ -247,25 +244,25 @@ class TestAddEntryModalRadioButtonLogic:
elif selector == "#dns-name-input": elif selector == "#dns-name-input":
return mock_dns_input return mock_dns_input
return Mock() return Mock()
self.modal.query_one = Mock(side_effect=mock_query_one) self.modal.query_one = Mock(side_effect=mock_query_one)
# Create mock event # Create mock event
mock_radio = Mock() mock_radio = Mock()
mock_radio.id = "dns-entry-radio" mock_radio.id = "dns-entry-radio"
mock_radio_set = Mock() mock_radio_set = Mock()
mock_radio_set.id = "entry-type-radio" mock_radio_set.id = "entry-type-radio"
class MockEvent: class MockEvent:
def __init__(self): def __init__(self):
self.radio_set = mock_radio_set self.radio_set = mock_radio_set
self.pressed = mock_radio self.pressed = mock_radio
event = MockEvent() event = MockEvent()
# Call the event handler # Call the event handler
self.modal.on_radio_set_changed(event) self.modal.on_radio_set_changed(event)
# Verify DNS section is shown and IP section is hidden # Verify DNS section is shown and IP section is hidden
mock_ip_section.add_class.assert_called_with("hidden") mock_ip_section.add_class.assert_called_with("hidden")
mock_dns_section.remove_class.assert_called_with("hidden") mock_dns_section.remove_class.assert_called_with("hidden")
@ -285,26 +282,26 @@ class TestAddEntryModalSaveLogic:
self.modal._validate_input = Mock(return_value=True) self.modal._validate_input = Mock(return_value=True)
self.modal._clear_errors = Mock() self.modal._clear_errors = Mock()
self.modal.dismiss = Mock() self.modal.dismiss = Mock()
# Mock form widgets # Mock form widgets
mock_radio_set = Mock(spec=RadioSet) mock_radio_set = Mock(spec=RadioSet)
mock_radio_set.pressed_button = None # IP entry mode mock_radio_set.pressed_button = None # IP entry mode
mock_ip_input = Mock(spec=Input) mock_ip_input = Mock(spec=Input)
mock_ip_input.value = "192.168.1.1" mock_ip_input.value = "192.168.1.1"
mock_dns_input = Mock(spec=Input) mock_dns_input = Mock(spec=Input)
mock_dns_input.value = "" mock_dns_input.value = ""
mock_hostnames_input = Mock(spec=Input) mock_hostnames_input = Mock(spec=Input)
mock_hostnames_input.value = "example.com, www.example.com" mock_hostnames_input.value = "example.com, www.example.com"
mock_comment_input = Mock(spec=Input) mock_comment_input = Mock(spec=Input)
mock_comment_input.value = "Test comment" mock_comment_input.value = "Test comment"
mock_active_checkbox = Mock(spec=Checkbox) mock_active_checkbox = Mock(spec=Checkbox)
mock_active_checkbox.value = True mock_active_checkbox.value = True
def mock_query_one(selector, widget_type): def mock_query_one(selector, widget_type):
if selector == "#entry-type-radio": if selector == "#entry-type-radio":
return mock_radio_set return mock_radio_set
@ -319,17 +316,17 @@ class TestAddEntryModalSaveLogic:
elif selector == "#active-checkbox": elif selector == "#active-checkbox":
return mock_active_checkbox return mock_active_checkbox
return Mock() return Mock()
self.modal.query_one = Mock(side_effect=mock_query_one) self.modal.query_one = Mock(side_effect=mock_query_one)
# Call action_save # Call action_save
self.modal.action_save() self.modal.action_save()
# Verify validation was called # Verify validation was called
self.modal._validate_input.assert_called_once_with( self.modal._validate_input.assert_called_once_with(
"192.168.1.1", "", "example.com, www.example.com", None "192.168.1.1", "", "example.com, www.example.com", None
) )
# Verify modal was dismissed with a HostEntry # Verify modal was dismissed with a HostEntry
self.modal.dismiss.assert_called_once() self.modal.dismiss.assert_called_once()
created_entry = self.modal.dismiss.call_args[0][0] created_entry = self.modal.dismiss.call_args[0][0]
@ -345,28 +342,28 @@ class TestAddEntryModalSaveLogic:
self.modal._validate_input = Mock(return_value=True) self.modal._validate_input = Mock(return_value=True)
self.modal._clear_errors = Mock() self.modal._clear_errors = Mock()
self.modal.dismiss = Mock() self.modal.dismiss = Mock()
# Mock form widgets # Mock form widgets
mock_radio_button = Mock() mock_radio_button = Mock()
mock_radio_button.id = "dns-entry-radio" mock_radio_button.id = "dns-entry-radio"
mock_radio_set = Mock(spec=RadioSet) mock_radio_set = Mock(spec=RadioSet)
mock_radio_set.pressed_button = mock_radio_button mock_radio_set.pressed_button = mock_radio_button
mock_ip_input = Mock(spec=Input) mock_ip_input = Mock(spec=Input)
mock_ip_input.value = "" mock_ip_input.value = ""
mock_dns_input = Mock(spec=Input) mock_dns_input = Mock(spec=Input)
mock_dns_input.value = "example.com" mock_dns_input.value = "example.com"
mock_hostnames_input = Mock(spec=Input) mock_hostnames_input = Mock(spec=Input)
mock_hostnames_input.value = "www.example.com" mock_hostnames_input.value = "www.example.com"
mock_comment_input = Mock(spec=Input) mock_comment_input = Mock(spec=Input)
mock_comment_input.value = "" mock_comment_input.value = ""
mock_active_checkbox = Mock(spec=Checkbox) mock_active_checkbox = Mock(spec=Checkbox)
mock_active_checkbox.value = True mock_active_checkbox.value = True
def mock_query_one(selector, widget_type): def mock_query_one(selector, widget_type):
if selector == "#entry-type-radio": if selector == "#entry-type-radio":
return mock_radio_set return mock_radio_set
@ -381,23 +378,23 @@ class TestAddEntryModalSaveLogic:
elif selector == "#active-checkbox": elif selector == "#active-checkbox":
return mock_active_checkbox return mock_active_checkbox
return Mock() return Mock()
self.modal.query_one = Mock(side_effect=mock_query_one) self.modal.query_one = Mock(side_effect=mock_query_one)
# Call action_save # Call action_save
self.modal.action_save() self.modal.action_save()
# Verify validation was called # Verify validation was called
self.modal._validate_input.assert_called_once_with( self.modal._validate_input.assert_called_once_with(
"", "example.com", "www.example.com", True "", "example.com", "www.example.com", True
) )
# Verify modal was dismissed with a DNS HostEntry # Verify modal was dismissed with a DNS HostEntry
self.modal.dismiss.assert_called_once() self.modal.dismiss.assert_called_once()
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
@ -409,21 +406,21 @@ class TestAddEntryModalSaveLogic:
self.modal._validate_input = Mock(return_value=False) self.modal._validate_input = Mock(return_value=False)
self.modal._clear_errors = Mock() self.modal._clear_errors = Mock()
self.modal.dismiss = Mock() self.modal.dismiss = Mock()
# Mock form widgets (minimal setup since validation fails) # Mock form widgets (minimal setup since validation fails)
mock_radio_set = Mock(spec=RadioSet) mock_radio_set = Mock(spec=RadioSet)
mock_radio_set.pressed_button = None mock_radio_set.pressed_button = None
def mock_query_one(selector, widget_type): def mock_query_one(selector, widget_type):
if selector == "#entry-type-radio": if selector == "#entry-type-radio":
return mock_radio_set return mock_radio_set
return Mock(spec=Input, value="") return Mock(spec=Input, value="")
self.modal.query_one = Mock(side_effect=mock_query_one) self.modal.query_one = Mock(side_effect=mock_query_one)
# Call action_save # Call action_save
self.modal.action_save() self.modal.action_save()
# Verify validation was called and modal was not dismissed # Verify validation was called and modal was not dismissed
self.modal._validate_input.assert_called_once() self.modal._validate_input.assert_called_once()
self.modal.dismiss.assert_not_called() self.modal.dismiss.assert_not_called()
@ -434,30 +431,33 @@ class TestAddEntryModalSaveLogic:
self.modal._validate_input = Mock(return_value=True) self.modal._validate_input = Mock(return_value=True)
self.modal._clear_errors = Mock() self.modal._clear_errors = Mock()
self.modal._show_error = Mock() self.modal._show_error = Mock()
# Mock form widgets # Mock form widgets
mock_radio_set = Mock(spec=RadioSet) mock_radio_set = Mock(spec=RadioSet)
mock_radio_set.pressed_button = None mock_radio_set.pressed_button = None
mock_input = Mock(spec=Input) mock_input = Mock(spec=Input)
mock_input.value = "invalid" mock_input.value = "invalid"
def mock_query_one(selector, widget_type): def mock_query_one(selector, widget_type):
if selector == "#entry-type-radio": if selector == "#entry-type-radio":
return mock_radio_set return mock_radio_set
return mock_input return mock_input
self.modal.query_one = Mock(side_effect=mock_query_one) self.modal.query_one = Mock(side_effect=mock_query_one)
# 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")
m.setattr("src.hosts.tui.add_entry_modal.HostEntry", mock_host_entry) m.setattr("src.hosts.tui.add_entry_modal.HostEntry", mock_host_entry)
# Call action_save # Call action_save
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

@ -179,13 +179,13 @@ class TestUndoRedoHistory:
"""Test that executing a new command clears the redo stack.""" """Test that executing a new command clears the redo stack."""
history = UndoRedoHistory() history = UndoRedoHistory()
hosts_file = HostsFile() hosts_file = HostsFile()
# Execute and undo a command # Execute and undo a command
command1 = Mock(spec=Command) command1 = Mock(spec=Command)
command1.execute.return_value = OperationResult(True, "Command 1") command1.execute.return_value = OperationResult(True, "Command 1")
command1.undo.return_value = OperationResult(True, "Undo 1") command1.undo.return_value = OperationResult(True, "Undo 1")
command1.get_description.return_value = "Command 1" command1.get_description.return_value = "Command 1"
history.execute_command(command1, hosts_file) history.execute_command(command1, hosts_file)
history.undo(hosts_file) history.undo(hosts_file)
assert history.can_redo() assert history.can_redo()
@ -194,7 +194,7 @@ class TestUndoRedoHistory:
command2 = Mock(spec=Command) command2 = Mock(spec=Command)
command2.execute.return_value = OperationResult(True, "Command 2") command2.execute.return_value = OperationResult(True, "Command 2")
command2.get_description.return_value = "Command 2" command2.get_description.return_value = "Command 2"
history.execute_command(command2, hosts_file) history.execute_command(command2, hosts_file)
assert not history.can_redo() # Redo stack should be cleared assert not history.can_redo() # Redo stack should be cleared
@ -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)
@ -507,7 +527,7 @@ class TestHostsManagerIntegration:
def test_manager_undo_redo_properties(self): def test_manager_undo_redo_properties(self):
"""Test undo/redo availability properties.""" """Test undo/redo availability properties."""
manager = HostsManager() manager = HostsManager()
# Initially no undo/redo available # Initially no undo/redo available
assert not manager.can_undo() assert not manager.can_undo()
assert not manager.can_redo() assert not manager.can_redo()
@ -519,7 +539,7 @@ class TestHostsManagerIntegration:
manager = HostsManager() manager = HostsManager()
hosts_file = HostsFile() hosts_file = HostsFile()
result = manager.undo_last_operation(hosts_file) result = manager.undo_last_operation(hosts_file)
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
@ -528,46 +548,48 @@ class TestHostsManagerIntegration:
manager = HostsManager() manager = HostsManager()
hosts_file = HostsFile() hosts_file = HostsFile()
result = manager.redo_last_operation(hosts_file) result = manager.redo_last_operation(hosts_file)
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")
manager = HostsManager() manager = HostsManager()
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)
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")
manager = HostsManager() manager = HostsManager()
manager.edit_mode = True # Simulate edit mode manager.edit_mode = True # Simulate edit mode
hosts_file = HostsFile() hosts_file = HostsFile()
entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"]) entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"])
entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"]) entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"])
hosts_file.entries.extend([entry1, entry2]) hosts_file.entries.extend([entry1, entry2])
result = manager.execute_move_command(hosts_file, 1, "up") result = manager.execute_move_command(hosts_file, 1, "up")
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()
@ -575,7 +597,7 @@ class TestHostsManagerIntegration:
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"]) entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
result = manager.execute_add_command(hosts_file, entry) result = manager.execute_add_command(hosts_file, entry)
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

View file

@ -115,9 +115,10 @@ 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()
with patch("asyncio.wait_for", side_effect=mock_wait_for) as mock_wait_for: with patch("asyncio.wait_for", side_effect=mock_wait_for) as mock_wait_for:
resolution = await resolve_hostname("slow.example", timeout=1.0) resolution = await resolve_hostname("slow.example", timeout=1.0)
@ -142,9 +143,10 @@ 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 []
with patch("asyncio.get_event_loop") as mock_loop: with patch("asyncio.get_event_loop") as mock_loop:
mock_event_loop = AsyncMock() mock_event_loop = AsyncMock()
mock_loop.return_value = mock_event_loop mock_loop.return_value = mock_event_loop
@ -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(
@ -214,9 +218,8 @@ class TestResolveHostnamesBatch:
resolved_at=datetime.now(), resolved_at=datetime.now(),
error_message="Name not found", error_message="Name not found",
) )
with patch("src.hosts.core.dns.resolve_hostname", mock_resolve_hostname):
with patch("src.hosts.core.dns.resolve_hostname", mock_resolve_hostname):
resolutions = await resolve_hostnames_batch(hostnames) 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,60 +51,58 @@ 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
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
assert options.inactive_only is False # Default value assert options.inactive_only is False # Default value
assert options.search_term is None # Default value assert options.search_term is None # Default value
@ -113,15 +112,16 @@ class TestFilterOptions:
# Default options should be empty # Default options should be empty
options = FilterOptions() options = FilterOptions()
assert options.is_empty() is True assert options.is_empty() is True
# Options with search term should not be empty # Options with search term should not be empty
options = FilterOptions(search_term="test") options = FilterOptions(search_term="test")
assert options.is_empty() is False assert options.is_empty() is False
# Options with any filter enabled should not be empty # Options with any filter enabled should not be empty
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."""
@ -129,45 +129,45 @@ class TestEntryFilter:
def sample_entries(self): def sample_entries(self):
"""Create sample entries for testing.""" """Create sample entries for testing."""
entries = [] entries = []
# Active IP entry # Active IP entry
entry1 = HostEntry("192.168.1.1", ["example.com"], "Test entry", True) entry1 = HostEntry("192.168.1.1", ["example.com"], "Test entry", True)
entries.append(entry1) entries.append(entry1)
# Inactive IP entry # Inactive IP entry
entry2 = HostEntry("192.168.1.2", ["inactive.com"], "Inactive entry", False) entry2 = HostEntry("192.168.1.2", ["inactive.com"], "Inactive entry", False)
entries.append(entry2) entries.append(entry2)
# Active DNS entry - create with temporary IP then convert to DNS entry # Active DNS entry - create with temporary IP then convert to DNS entry
entry3 = HostEntry("1.1.1.1", ["dns-only.com"], "DNS only entry", True) entry3 = HostEntry("1.1.1.1", ["dns-only.com"], "DNS only entry", True)
entry3.ip_address = "" # Remove IP after creation entry3.ip_address = "" # Remove IP after creation
entry3.dns_name = "dns-only.com" # Set DNS name entry3.dns_name = "dns-only.com" # Set DNS name
entries.append(entry3) entries.append(entry3)
# Inactive DNS entry - create with temporary IP then convert to DNS entry # Inactive DNS entry - create with temporary IP then convert to DNS entry
entry4 = HostEntry("1.1.1.1", ["inactive-dns.com"], "Inactive DNS entry", False) entry4 = HostEntry("1.1.1.1", ["inactive-dns.com"], "Inactive DNS entry", False)
entry4.ip_address = "" # Remove IP after creation entry4.ip_address = "" # Remove IP after creation
entry4.dns_name = "inactive-dns.com" # Set DNS name entry4.dns_name = "inactive-dns.com" # Set DNS name
entries.append(entry4) entries.append(entry4)
# Entry with DNS resolution data # Entry with DNS resolution data
entry5 = HostEntry("10.0.0.1", ["resolved.com"], "Resolved entry", True) entry5 = HostEntry("10.0.0.1", ["resolved.com"], "Resolved entry", True)
entry5.resolved_ip = "10.0.0.1" entry5.resolved_ip = "10.0.0.1"
entry5.last_resolved = datetime.now() entry5.last_resolved = datetime.now()
entry5.dns_resolution_status = "IP_MATCH" entry5.dns_resolution_status = "IP_MATCH"
entries.append(entry5) entries.append(entry5)
# Entry with mismatched DNS # Entry with mismatched DNS
entry6 = HostEntry("10.0.0.2", ["mismatch.com"], "Mismatch entry", True) entry6 = HostEntry("10.0.0.2", ["mismatch.com"], "Mismatch entry", True)
entry6.resolved_ip = "10.0.0.3" # Different from IP address entry6.resolved_ip = "10.0.0.3" # Different from IP address
entry6.last_resolved = datetime.now() entry6.last_resolved = datetime.now()
entry6.dns_resolution_status = "IP_MISMATCH" entry6.dns_resolution_status = "IP_MISMATCH"
entries.append(entry6) entries.append(entry6)
# Entry without DNS resolution # Entry without DNS resolution
entry7 = HostEntry("10.0.0.4", ["unresolved.com"], "Unresolved entry", True) entry7 = HostEntry("10.0.0.4", ["unresolved.com"], "Unresolved entry", True)
entries.append(entry7) entries.append(entry7)
return entries return entries
@pytest.fixture @pytest.fixture
@ -186,7 +186,7 @@ class TestEntryFilter:
"""Test filtering by active status only.""" """Test filtering by active status only."""
options = FilterOptions(active_only=True) options = FilterOptions(active_only=True)
result = entry_filter.filter_by_status(sample_entries, options) result = entry_filter.filter_by_status(sample_entries, options)
active_entries = [e for e in result if e.is_active] active_entries = [e for e in result if e.is_active]
assert len(active_entries) == len(result) assert len(active_entries) == len(result)
assert all(entry.is_active for entry in result) assert all(entry.is_active for entry in result)
@ -195,7 +195,7 @@ class TestEntryFilter:
"""Test filtering by inactive status only.""" """Test filtering by inactive status only."""
options = FilterOptions(inactive_only=True) options = FilterOptions(inactive_only=True)
result = entry_filter.filter_by_status(sample_entries, options) result = entry_filter.filter_by_status(sample_entries, options)
assert all(not entry.is_active for entry in result) assert all(not entry.is_active for entry in result)
assert len(result) == 2 # entry2 and entry4 assert len(result) == 2 # entry2 and entry4
@ -203,7 +203,7 @@ class TestEntryFilter:
"""Test filtering by DNS entries only.""" """Test filtering by DNS entries only."""
options = FilterOptions(dns_only=True) options = FilterOptions(dns_only=True)
result = entry_filter.filter_by_dns_type(sample_entries, options) result = entry_filter.filter_by_dns_type(sample_entries, options)
assert all(entry.dns_name is not None for entry in result) assert all(entry.dns_name is not None for entry in result)
assert len(result) == 2 # entry3 and entry4 assert len(result) == 2 # entry3 and entry4
@ -211,7 +211,7 @@ class TestEntryFilter:
"""Test filtering by IP entries only.""" """Test filtering by IP entries only."""
options = FilterOptions(ip_only=True) options = FilterOptions(ip_only=True)
result = entry_filter.filter_by_dns_type(sample_entries, options) result = entry_filter.filter_by_dns_type(sample_entries, options)
assert all(not entry.has_dns_name() for entry in result) assert all(not entry.has_dns_name() for entry in result)
# Should exclude DNS-only entries (entry3, entry4) # Should exclude DNS-only entries (entry3, entry4)
expected_count = len(sample_entries) - 2 expected_count = len(sample_entries) - 2
@ -221,8 +221,10 @@ class TestEntryFilter:
"""Test filtering by resolved entries only.""" """Test filtering by resolved entries only."""
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,18 +233,20 @@ 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):
"""Test filtering by DNS mismatch entries only.""" """Test filtering by DNS mismatch entries only."""
options = FilterOptions(mismatch_only=True) options = FilterOptions(mismatch_only=True)
result = entry_filter.filter_by_resolution_status(sample_entries, options) result = entry_filter.filter_by_resolution_status(sample_entries, options)
# Should only return entry6 (mismatch between IP and resolved_ip) # Should only return entry6 (mismatch between IP and resolved_ip)
assert len(result) == 1 assert len(result) == 1
assert result[0].hostnames[0] == "mismatch.com" assert result[0].hostnames[0] == "mismatch.com"
@ -251,7 +255,7 @@ class TestEntryFilter:
"""Test filtering by search term in hostname.""" """Test filtering by search term in hostname."""
options = FilterOptions(search_term="example") options = FilterOptions(search_term="example")
result = entry_filter.filter_by_search(sample_entries, options) result = entry_filter.filter_by_search(sample_entries, options)
assert len(result) == 1 assert len(result) == 1
assert result[0].hostnames[0] == "example.com" assert result[0].hostnames[0] == "example.com"
@ -259,14 +263,14 @@ class TestEntryFilter:
"""Test filtering by search term in IP address.""" """Test filtering by search term in IP address."""
options = FilterOptions(search_term="192.168") options = FilterOptions(search_term="192.168")
result = entry_filter.filter_by_search(sample_entries, options) result = entry_filter.filter_by_search(sample_entries, options)
assert len(result) == 2 # entry1 and entry2 assert len(result) == 2 # entry1 and entry2
def test_filter_by_search_comment(self, entry_filter, sample_entries): def test_filter_by_search_comment(self, entry_filter, sample_entries):
"""Test filtering by search term in comment.""" """Test filtering by search term in comment."""
options = FilterOptions(search_term="DNS only") options = FilterOptions(search_term="DNS only")
result = entry_filter.filter_by_search(sample_entries, options) result = entry_filter.filter_by_search(sample_entries, options)
assert len(result) == 1 assert len(result) == 1
assert result[0].comment == "DNS only entry" assert result[0].comment == "DNS only entry"
@ -274,20 +278,16 @@ class TestEntryFilter:
"""Test search is case insensitive.""" """Test search is case insensitive."""
options = FilterOptions(search_term="EXAMPLE") options = FilterOptions(search_term="EXAMPLE")
result = entry_filter.filter_by_search(sample_entries, options) result = entry_filter.filter_by_search(sample_entries, options)
assert len(result) == 1 assert len(result) == 1
assert result[0].hostnames[0] == "example.com" assert result[0].hostnames[0] == "example.com"
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)
assert len(result) == 1 assert len(result) == 1
assert result[0].hostnames[0] == "dns-only.com" assert result[0].hostnames[0] == "dns-only.com"
@ -298,14 +298,14 @@ class TestEntryFilter:
"""Test counting filtered entries.""" """Test counting filtered entries."""
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."""
presets = entry_filter.get_default_presets() presets = entry_filter.get_default_presets()
# Check that default presets exist # Check that default presets exist
assert "All Entries" in presets assert "All Entries" in presets
assert "Active Only" in presets assert "Active Only" in presets
@ -315,7 +315,7 @@ class TestEntryFilter:
assert "DNS Mismatches" in presets assert "DNS Mismatches" in presets
assert "Resolved Entries" in presets assert "Resolved Entries" in presets
assert "Unresolved Entries" in presets assert "Unresolved Entries" in presets
# Check that presets have correct structure # Check that presets have correct structure
for preset_name, options in presets.items(): for preset_name, options in presets.items():
assert isinstance(options, FilterOptions) assert isinstance(options, FilterOptions)
@ -324,18 +324,16 @@ 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
entry_filter.save_preset("My Custom Filter", custom_options) entry_filter.save_preset("My Custom Filter", custom_options)
# Check it was saved # Check it was saved
presets = entry_filter.get_saved_presets() presets = entry_filter.get_saved_presets()
assert "My Custom Filter" in presets assert "My Custom Filter" in presets
# Load and verify # Load and verify
loaded_options = presets["My Custom Filter"] loaded_options = presets["My Custom Filter"]
assert loaded_options.active_only is True assert loaded_options.active_only is True
@ -347,19 +345,19 @@ class TestEntryFilter:
# Save a preset first # Save a preset first
custom_options = FilterOptions(active_only=True) custom_options = FilterOptions(active_only=True)
entry_filter.save_preset("To Delete", custom_options) entry_filter.save_preset("To Delete", custom_options)
# Verify it exists # Verify it exists
presets = entry_filter.get_saved_presets() presets = entry_filter.get_saved_presets()
assert "To Delete" in presets assert "To Delete" in presets
# Delete it # Delete it
result = entry_filter.delete_preset("To Delete") result = entry_filter.delete_preset("To Delete")
assert result is True assert result is True
# Verify it's gone # Verify it's gone
presets = entry_filter.get_saved_presets() presets = entry_filter.get_saved_presets()
assert "To Delete" not in presets assert "To Delete" not in presets
# Try to delete non-existent preset # Try to delete non-existent preset
result = entry_filter.delete_preset("Non Existent") result = entry_filter.delete_preset("Non Existent")
assert result is False assert result is False
@ -370,7 +368,7 @@ class TestEntryFilter:
empty_options = FilterOptions() empty_options = FilterOptions()
result = entry_filter.apply_filters([], empty_options) result = entry_filter.apply_filters([], empty_options)
assert result == [] assert result == []
# None entries in list - filtering should handle None values gracefully # None entries in list - filtering should handle None values gracefully
entries_with_none = [None, HostEntry("192.168.1.1", ["test.com"], "", True)] entries_with_none = [None, HostEntry("192.168.1.1", ["test.com"], "", True)]
# Filter out None values before applying filters # Filter out None values before applying filters
@ -382,9 +380,14 @@ 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
for hostname in ["primary", "secondary", "alias"]: for hostname in ["primary", "secondary", "alias"]:
options = FilterOptions(search_term=hostname) options = FilterOptions(search_term=hostname)
@ -397,7 +400,7 @@ class TestEntryFilter:
# Modify sample entries to have different resolution times # Modify sample entries to have different resolution times
old_time = datetime.now() - timedelta(days=1) old_time = datetime.now() - timedelta(days=1)
recent_time = datetime.now() - timedelta(minutes=5) recent_time = datetime.now() - timedelta(minutes=5)
# Make one entry have old resolution # Make one entry have old resolution
for entry in sample_entries: for entry in sample_entries:
if entry.resolved_ip: if entry.resolved_ip:
@ -405,7 +408,7 @@ class TestEntryFilter:
entry.last_resolved = recent_time entry.last_resolved = recent_time
else: else:
entry.last_resolved = old_time entry.last_resolved = old_time
# Test that entries are still found regardless of age # Test that entries are still found regardless of age
# (Age filtering might be added in future versions) # (Age filtering might be added in future versions)
options = FilterOptions(resolved_only=True) options = FilterOptions(resolved_only=True)
@ -414,14 +417,11 @@ 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)
entry_filter.apply_filters([sample_entry], preset_options) entry_filter.apply_filters([sample_entry], preset_options)
# The original preset name should be accessible # The original preset name should be accessible
assert preset_options.preset_name == "Active Only" assert preset_options.preset_name == "Active Only"

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,16 +35,16 @@ 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
entries[2].ip_address = "" # Remove IP after creation entries[2].ip_address = "" # Remove IP after creation
entries[2].dns_name = "dns-only.com" entries[2].dns_name = "dns-only.com"
entries[3].resolved_ip = "10.0.0.1" entries[3].resolved_ip = "10.0.0.1"
entries[3].last_resolved = datetime(2024, 1, 15, 12, 0, 0) entries[3].last_resolved = datetime(2024, 1, 15, 12, 0, 0)
entries[3].dns_resolution_status = "IP_MATCH" entries[3].dns_resolution_status = "IP_MATCH"
hosts_file = HostsFile() hosts_file = HostsFile()
hosts_file.entries = entries hosts_file.entries = entries
return hosts_file return hosts_file
@ -63,7 +67,7 @@ class TestImportExportService:
"""Test getting supported formats.""" """Test getting supported formats."""
export_formats = service.get_supported_export_formats() export_formats = service.get_supported_export_formats()
import_formats = service.get_supported_import_formats() import_formats = service.get_supported_import_formats()
assert len(export_formats) == 3 assert len(export_formats) == 3
assert len(import_formats) == 3 assert len(import_formats) == 3
assert ExportFormat.HOSTS in export_formats assert ExportFormat.HOSTS in export_formats
@ -74,15 +78,15 @@ class TestImportExportService:
def test_export_hosts_format(self, service, sample_hosts_file, temp_dir): def test_export_hosts_format(self, service, sample_hosts_file, temp_dir):
"""Test exporting to hosts format.""" """Test exporting to hosts format."""
export_path = temp_dir / "test_hosts.txt" export_path = temp_dir / "test_hosts.txt"
result = service.export_hosts_format(sample_hosts_file, export_path) result = service.export_hosts_format(sample_hosts_file, export_path)
assert result.success is True assert result.success is True
assert result.entries_exported == 4 assert result.entries_exported == 4
assert len(result.errors) == 0 assert len(result.errors) == 0
assert result.format == ExportFormat.HOSTS assert result.format == ExportFormat.HOSTS
assert export_path.exists() assert export_path.exists()
# Verify content # Verify content
content = export_path.read_text() content = export_path.read_text()
assert "127.0.0.1" in content assert "127.0.0.1" in content
@ -92,30 +96,30 @@ class TestImportExportService:
def test_export_json_format(self, service, sample_hosts_file, temp_dir): def test_export_json_format(self, service, sample_hosts_file, temp_dir):
"""Test exporting to JSON format.""" """Test exporting to JSON format."""
export_path = temp_dir / "test_export.json" export_path = temp_dir / "test_export.json"
result = service.export_json_format(sample_hosts_file, export_path) result = service.export_json_format(sample_hosts_file, export_path)
assert result.success is True assert result.success is True
assert result.entries_exported == 4 assert result.entries_exported == 4
assert len(result.errors) == 0 assert len(result.errors) == 0
assert result.format == ExportFormat.JSON assert result.format == ExportFormat.JSON
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
assert "entries" in data assert "entries" in data
assert data["metadata"]["total_entries"] == 4 assert data["metadata"]["total_entries"] == 4
assert len(data["entries"]) == 4 assert len(data["entries"]) == 4
# Check first entry # Check first entry
first_entry = data["entries"][0] first_entry = data["entries"][0]
assert first_entry["ip_address"] == "127.0.0.1" assert first_entry["ip_address"] == "127.0.0.1"
assert first_entry["hostnames"] == ["localhost"] assert first_entry["hostnames"] == ["localhost"]
assert first_entry["is_active"] is True assert first_entry["is_active"] is True
# Check DNS entry # Check DNS entry
dns_entry = next((e for e in data["entries"] if e.get("dns_name")), None) dns_entry = next((e for e in data["entries"] if e.get("dns_name")), None)
assert dns_entry is not None assert dns_entry is not None
@ -124,29 +128,35 @@ class TestImportExportService:
def test_export_csv_format(self, service, sample_hosts_file, temp_dir): def test_export_csv_format(self, service, sample_hosts_file, temp_dir):
"""Test exporting to CSV format.""" """Test exporting to CSV format."""
export_path = temp_dir / "test_export.csv" export_path = temp_dir / "test_export.csv"
result = service.export_csv_format(sample_hosts_file, export_path) result = service.export_csv_format(sample_hosts_file, export_path)
assert result.success is True assert result.success is True
assert result.entries_exported == 4 assert result.entries_exported == 4
assert len(result.errors) == 0 assert len(result.errors) == 0
assert result.format == ExportFormat.CSV assert result.format == ExportFormat.CSV
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)
assert len(rows) == 4 assert len(rows) == 4
# 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
# Check first row # Check first row
first_row = rows[0] first_row = rows[0]
assert first_row["ip_address"] == "127.0.0.1" assert first_row["ip_address"] == "127.0.0.1"
@ -156,9 +166,9 @@ class TestImportExportService:
def test_export_invalid_path(self, service, sample_hosts_file): def test_export_invalid_path(self, service, sample_hosts_file):
"""Test export with invalid path.""" """Test export with invalid path."""
invalid_path = Path("/invalid/path/test.json") invalid_path = Path("/invalid/path/test.json")
result = service.export_json_format(sample_hosts_file, invalid_path) result = service.export_json_format(sample_hosts_file, invalid_path)
assert result.success is False assert result.success is False
assert result.entries_exported == 0 assert result.entries_exported == 0
assert len(result.errors) > 0 assert len(result.errors) > 0
@ -176,17 +186,19 @@ class TestImportExportService:
""" """
hosts_path = temp_dir / "test_hosts.txt" hosts_path = temp_dir / "test_hosts.txt"
hosts_path.write_text(hosts_content) hosts_path.write_text(hosts_content)
result = service.import_hosts_format(hosts_path) result = service.import_hosts_format(hosts_path)
assert result.success is True assert result.success is True
assert result.total_processed >= 2 assert result.total_processed >= 2
assert result.successfully_imported >= 2 assert result.successfully_imported >= 2
assert len(result.errors) == 0 assert len(result.errors) == 0
# 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,29 +233,29 @@ 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)
assert result.success is True assert result.success is True
assert result.total_processed == 3 assert result.total_processed == 3
assert result.successfully_imported == 3 assert result.successfully_imported == 3
assert len(result.errors) == 0 assert len(result.errors) == 0
assert len(result.entries) == 3 assert len(result.entries) == 3
# Check DNS entry # Check DNS entry
dns_entry = next((e for e in result.entries if e.dns_name), None) dns_entry = next((e for e in result.entries if e.dns_name), None)
assert dns_entry is not None assert dns_entry is not None
assert dns_entry.dns_name == "dns-only.com" assert dns_entry.dns_name == "dns-only.com"
assert dns_entry.ip_address == "" assert dns_entry.ip_address == ""
# Check resolved entry # Check resolved entry
resolved_entry = next((e for e in result.entries if e.resolved_ip), None) resolved_entry = next((e for e in result.entries if e.resolved_ip), None)
assert resolved_entry is not None assert resolved_entry is not None
@ -254,35 +266,51 @@ 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)
assert result.success is True assert result.success is True
assert result.total_processed == 3 assert result.total_processed == 3
assert result.successfully_imported == 3 assert result.successfully_imported == 3
assert len(result.errors) == 0 assert len(result.errors) == 0
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,11 +320,11 @@ 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)
assert result.success is False assert result.success is False
assert result.total_processed == 0 assert result.total_processed == 0
assert result.successfully_imported == 0 assert result.successfully_imported == 0
@ -307,9 +335,9 @@ class TestImportExportService:
"""Test importing malformed JSON.""" """Test importing malformed JSON."""
json_path = temp_dir / "malformed.json" json_path = temp_dir / "malformed.json"
json_path.write_text("{invalid json content") json_path.write_text("{invalid json content")
result = service.import_json_format(json_path) result = service.import_json_format(json_path)
assert result.success is False assert result.success is False
assert result.total_processed == 0 assert result.total_processed == 0
assert result.successfully_imported == 0 assert result.successfully_imported == 0
@ -319,13 +347,13 @@ 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)
assert result.success is False assert result.success is False
assert result.total_processed == 0 assert result.total_processed == 0
assert result.successfully_imported == 0 assert result.successfully_imported == 0
@ -341,17 +369,17 @@ 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)
assert result.success is True assert result.success is True
assert result.total_processed == 1 assert result.total_processed == 1
assert result.successfully_imported == 1 assert result.successfully_imported == 1
@ -361,9 +389,9 @@ class TestImportExportService:
def test_import_nonexistent_file(self, service): def test_import_nonexistent_file(self, service):
"""Test importing non-existent file.""" """Test importing non-existent file."""
nonexistent_path = Path("/nonexistent/file.json") nonexistent_path = Path("/nonexistent/file.json")
result = service.import_json_format(nonexistent_path) result = service.import_json_format(nonexistent_path)
assert result.success is False assert result.success is False
assert result.total_processed == 0 assert result.total_processed == 0
assert result.successfully_imported == 0 assert result.successfully_imported == 0
@ -377,11 +405,11 @@ class TestImportExportService:
csv_file = temp_dir / "test.csv" csv_file = temp_dir / "test.csv"
hosts_file = temp_dir / "hosts" hosts_file = temp_dir / "hosts"
txt_file = temp_dir / "test.txt" txt_file = temp_dir / "test.txt"
# Create empty files # Create empty files
for f in [json_file, csv_file, hosts_file, txt_file]: for f in [json_file, csv_file, hosts_file, txt_file]:
f.touch() f.touch()
assert service.detect_file_format(json_file) == ImportFormat.JSON assert service.detect_file_format(json_file) == ImportFormat.JSON
assert service.detect_file_format(csv_file) == ImportFormat.CSV assert service.detect_file_format(csv_file) == ImportFormat.CSV
assert service.detect_file_format(hosts_file) == ImportFormat.HOSTS assert service.detect_file_format(hosts_file) == ImportFormat.HOSTS
@ -393,15 +421,15 @@ class TestImportExportService:
json_file = temp_dir / "no_extension" json_file = temp_dir / "no_extension"
json_file.write_text('{"entries": []}') json_file.write_text('{"entries": []}')
assert service.detect_file_format(json_file) == ImportFormat.JSON assert service.detect_file_format(json_file) == ImportFormat.JSON
# 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):
@ -415,13 +443,13 @@ class TestImportExportService:
valid_path = temp_dir / "export.json" valid_path = temp_dir / "export.json"
warnings = service.validate_export_path(valid_path, ExportFormat.JSON) warnings = service.validate_export_path(valid_path, ExportFormat.JSON)
assert len(warnings) == 0 assert len(warnings) == 0
# Existing file # Existing file
existing_file = temp_dir / "existing.json" existing_file = temp_dir / "existing.json"
existing_file.touch() existing_file.touch()
warnings = service.validate_export_path(existing_file, ExportFormat.JSON) warnings = service.validate_export_path(existing_file, ExportFormat.JSON)
assert any("already exists" in w for w in warnings) assert any("already exists" in w for w in warnings)
# Wrong extension # Wrong extension
wrong_ext = temp_dir / "file.txt" wrong_ext = temp_dir / "file.txt"
warnings = service.validate_export_path(wrong_ext, ExportFormat.JSON) warnings = service.validate_export_path(wrong_ext, ExportFormat.JSON)
@ -438,22 +466,22 @@ class TestImportExportService:
def test_export_import_roundtrip_json(self, service, sample_hosts_file, temp_dir): def test_export_import_roundtrip_json(self, service, sample_hosts_file, temp_dir):
"""Test export-import roundtrip for JSON format.""" """Test export-import roundtrip for JSON format."""
export_path = temp_dir / "roundtrip.json" export_path = temp_dir / "roundtrip.json"
# Export # Export
export_result = service.export_json_format(sample_hosts_file, export_path) export_result = service.export_json_format(sample_hosts_file, export_path)
assert export_result.success is True assert export_result.success is True
# Import # Import
import_result = service.import_json_format(export_path) import_result = service.import_json_format(export_path)
assert import_result.success is True assert import_result.success is True
assert import_result.successfully_imported == len(sample_hosts_file.entries) assert import_result.successfully_imported == len(sample_hosts_file.entries)
# Verify data integrity # Verify data integrity
original_entries = sample_hosts_file.entries original_entries = sample_hosts_file.entries
imported_entries = import_result.entries imported_entries = import_result.entries
assert len(imported_entries) == len(original_entries) assert len(imported_entries) == len(original_entries)
# Check specific entries # Check specific entries
for orig, imported in zip(original_entries, imported_entries): for orig, imported in zip(original_entries, imported_entries):
assert orig.ip_address == imported.ip_address assert orig.ip_address == imported.ip_address
@ -465,11 +493,11 @@ class TestImportExportService:
def test_export_import_roundtrip_csv(self, service, sample_hosts_file, temp_dir): def test_export_import_roundtrip_csv(self, service, sample_hosts_file, temp_dir):
"""Test export-import roundtrip for CSV format.""" """Test export-import roundtrip for CSV format."""
export_path = temp_dir / "roundtrip.csv" export_path = temp_dir / "roundtrip.csv"
# Export # Export
export_result = service.export_csv_format(sample_hosts_file, export_path) export_result = service.export_csv_format(sample_hosts_file, export_path)
assert export_result.success is True assert export_result.success is True
# Import # Import
import_result = service.import_csv_format(export_path) import_result = service.import_csv_format(export_path)
assert import_result.success is True assert import_result.success is True
@ -484,11 +512,11 @@ 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
# Result with warnings # Result with warnings
result_with_warnings = ImportResult( result_with_warnings = ImportResult(
success=True, success=True,
@ -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
@ -505,15 +533,15 @@ class TestImportExportService:
"""Test exporting empty hosts file.""" """Test exporting empty hosts file."""
empty_hosts_file = HostsFile() empty_hosts_file = HostsFile()
export_path = temp_dir / "empty.json" export_path = temp_dir / "empty.json"
result = service.export_json_format(empty_hosts_file, export_path) result = service.export_json_format(empty_hosts_file, export_path)
assert result.success is True assert result.success is True
assert result.entries_exported == 0 assert result.entries_exported == 0
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
@ -521,24 +549,24 @@ class TestImportExportService:
def test_large_hostnames_list_csv(self, service, temp_dir): def test_large_hostnames_list_csv(self, service, temp_dir):
"""Test CSV export/import with large hostnames list.""" """Test CSV export/import with large hostnames list."""
entry = HostEntry( entry = HostEntry(
"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]
export_path = temp_dir / "multi_hostnames.csv" export_path = temp_dir / "multi_hostnames.csv"
# Export # Export
export_result = service.export_csv_format(hosts_file, export_path) export_result = service.export_csv_format(hosts_file, export_path)
assert export_result.success is True assert export_result.success is True
# Import # Import
import_result = service.import_csv_format(export_path) import_result = service.import_csv_format(export_path)
assert import_result.success is True assert import_result.success is True
imported_entry = import_result.entries[0] imported_entry = import_result.entries[0]
assert len(imported_entry.hostnames) == 5 assert len(imported_entry.hostnames) == 5
assert "host1.com" in imported_entry.hostnames assert "host1.com" in imported_entry.hostnames

View file

@ -604,7 +604,7 @@ class TestHostsManagerApp:
mock_radio_set.id = "edit-entry-type-radio" mock_radio_set.id = "edit-entry-type-radio"
mock_pressed_radio = Mock() mock_pressed_radio = Mock()
mock_pressed_radio.id = "edit-ip-entry-radio" mock_pressed_radio.id = "edit-ip-entry-radio"
event = Mock() event = Mock()
event.radio_set = mock_radio_set event.radio_set = mock_radio_set
event.pressed = mock_pressed_radio event.pressed = mock_pressed_radio
@ -631,7 +631,7 @@ class TestHostsManagerApp:
mock_radio_set.id = "edit-entry-type-radio" mock_radio_set.id = "edit-entry-type-radio"
mock_pressed_radio = Mock() mock_pressed_radio = Mock()
mock_pressed_radio.id = "edit-dns-entry-radio" mock_pressed_radio.id = "edit-dns-entry-radio"
event = Mock() event = Mock()
event.radio_set = mock_radio_set event.radio_set = mock_radio_set
event.pressed = mock_pressed_radio event.pressed = mock_pressed_radio
@ -812,12 +812,12 @@ class TestHostsManagerApp:
mock_radio_set = Mock() mock_radio_set = Mock()
mock_ip_radio = Mock() mock_ip_radio = Mock()
mock_dns_radio = Mock() mock_dns_radio = Mock()
# Use a simple object to track value assignment # Use a simple object to track value assignment
class MockDNSInput: class MockDNSInput:
def __init__(self): def __init__(self):
self.value = "" self.value = ""
mock_dns_input = MockDNSInput() mock_dns_input = MockDNSInput()
def mock_query_one(selector, widget_type=None): def mock_query_one(selector, widget_type=None):
@ -833,14 +833,16 @@ class TestHostsManagerApp:
app.query_one = mock_query_one app.query_one = mock_query_one
app.edit_handler.handle_entry_type_change = Mock() app.edit_handler.handle_entry_type_change = Mock()
# Mock the set_timer method to avoid event loop issues in tests # Mock the set_timer method to avoid event loop issues in tests
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()
@ -946,21 +948,24 @@ class TestHostsManagerApp:
app.manager.save_hosts_file = Mock(return_value=(True, "Success")) app.manager.save_hosts_file = Mock(return_value=(True, "Success"))
app.table_handler.populate_entries_table = Mock() app.table_handler.populate_entries_table = Mock()
app.details_handler.update_entry_details = Mock() app.details_handler.update_entry_details = Mock()
# 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
app.run_worker = Mock(side_effect=consume_coro) app.run_worker = Mock(side_effect=consume_coro)
# 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()