Compare commits

..

No commits in common. "d7ca9cc87f9d40c4d3f6f7909983f2d2baf8b345" and "e6f3e9f3d4db4754d1870118237aff68fde176ba" have entirely different histories.

29 changed files with 377 additions and 5975 deletions

277
README.md
View file

@ -1,277 +0,0 @@
# hosts - /etc/hosts Manager
A modern Python TUI (Text User Interface) application for managing your system's `/etc/hosts` file with ease and safety.
## Overview
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.
## Features
### 🔍 **Read-Only Mode (Default)**
- **Two-pane interface**: List view with detailed entry information
- **Smart parsing**: Handles all real-world hosts file formats
- **Sorting capabilities**: Sort by IP address or hostname
- **Filtering options**: Hide/show system default entries
- **Search functionality**: Find entries by hostname, IP, or comment
- **Configuration management**: Persistent settings with modal interface
- **Live reload**: Automatically refresh when hosts file changes
### ✏️ **Edit Mode (Permission-Protected)**
- **Safe editing**: Automatic backups before any modifications
- **Entry management**: Add, delete, and modify host entries
- **Activation control**: Toggle entries active/inactive
- **Reordering**: Move entries up/down with keyboard shortcuts
- **Undo/Redo system**: Full operation history with Ctrl+Z/Ctrl+Y
- **Atomic operations**: Safe file writing with rollback capability
- **Permission management**: Secure sudo handling for system file access
### 🛡️ **Safety & Reliability**
- **Automatic backups**: Timestamped backups before modifications
- **Change detection**: Track modifications with save confirmation
- **Input validation**: Comprehensive IP and hostname validation
- **Error handling**: Graceful error recovery with user feedback
- **File integrity**: Preserve comments and formatting
## Installation
### Prerequisites
- Python 3.13 or higher
- [uv](https://docs.astral.sh/uv/) package manager
### Run with uv
```bash
uvx git+https://git.s1q.dev/phg/hosts.git
```
### Setup alias
```bash
# Install uv if not already installed
echo "alias hosts=\"uvx git+https://git.s1q.dev/phg/hosts.git\"" >> ~/.zshrc
```
## Usage
### Basic Usage
```bash
# Launch the application
uv run hosts
# Or if installed globally
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
# Install development dependencies
uv sync
# Run tests
uv run pytest
# Run linting
uv run ruff check
uv run ruff format
```
### Testing
The project maintains comprehensive test coverage with 150+ tests:
```bash
# Run all tests
uv run pytest
# Run specific test modules
uv run pytest tests/test_models.py
uv run pytest tests/test_parser.py
# Run with coverage
uv run pytest --cov=src/hosts
```
### Test Coverage
- **Models**: Data validation and serialization (27 tests)
- **Parser**: File operations and parsing (15 tests)
- **Manager**: Edit operations and permissions (38 tests)
- **Configuration**: Settings persistence (22 tests)
- **TUI Components**: User interface (28 tests)
- **Commands**: Undo/redo system (43 tests)
- **Integration**: End-to-end workflows (additional tests)
### Code Quality
The project uses `ruff` for linting and formatting:
```bash
# Check code quality
uv run ruff check
# Format code
uv run ruff format
# Fix auto-fixable issues
uv run ruff check --fix
```
## Security Considerations
- **Sudo handling**: Secure elevation only when entering edit mode
- **File validation**: Comprehensive input validation and sanitization
- **Atomic operations**: Safe file writing to prevent corruption
- **Backup system**: Automatic backups before any modifications
- **Permission boundaries**: Clear separation between read and edit operations
## Troubleshooting
### Common Issues
**Permission denied when entering edit mode:**
```bash
# Ensure you can run sudo
sudo -v
# Check file permissions
ls -la /etc/hosts
```
**Configuration not saving:**
```bash
# Ensure config directory exists
mkdir -p ~/.config/hosts-manager
# Check directory permissions
ls -la ~/.config/
```
**Application won't start:**
```bash
# Check Python version
python3 --version
# Verify uv installation
uv --version
# Install dependencies
uv sync
```
## Contributing
We welcome contributions! Please see our development setup above.
### Contribution Guidelines
1. **Fork the repository** and create a feature branch
2. **Write tests** for new functionality
3. **Ensure all tests pass** with `uv run pytest`
4. **Follow code style** with `uv run ruff check`
5. **Submit a pull request** with clear description
### Future Enhancements
- **DNS Resolution**: Automatic hostname-to-IP resolution
- **Import/Export**: Support for different file formats
- **Advanced Filtering**: Complex search and filter capabilities
- **Performance Optimization**: Large file handling improvements
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Support
- **Issues**: Report bugs and feature requests on GitHub Issues
- **Documentation**: See the [project wiki](https://github.com/yourusername/hosts/wiki)
- **Discussions**: Join community discussions on GitHub Discussions
---
**Note**: This application modifies system files. Always ensure you have proper backups and understand the implications of hosts file changes. The application includes safety features, but system administration knowledge is recommended.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 969 KiB

View file

@ -1,143 +1,216 @@
# Active Context # Active Context: hosts
## Current Status: Advanced Feature Implementation - PRODUCTION READY! 🎉 ## Current Work Focus
**Last Updated:** 2025-01-18 16:06 CET **Phase 4 Advanced Edit Features Complete**: Successfully implemented all Phase 4 features including add/delete entries, inline editing, search functionality, and comprehensive undo/redo system. The application now has complete edit capabilities with modular TUI architecture, command pattern implementation, and professional user interface. Ready for Phase 5 advanced features.
## Current Achievement Status ## Immediate Next Steps
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 ### Priority 1: Phase 5 Advanced Features
1. **DNS resolution**: Resolve hostnames to IP addresses with comparison
2. **CNAME support**: Store DNS names alongside IP addresses
3. **Advanced filtering**: Filter by active/inactive status
4. **Import/Export**: Support for different file formats
#### 1. DNS Resolution System ✅ COMPLETE ### Priority 2: Phase 6 Polish
- **Full DNS Service**: Complete async DNS resolution with timeout handling and batch processing 1. **Bulk operations**: Select and modify multiple entries
- **DNS Status Tracking**: Comprehensive status enumeration (NOT_RESOLVED, RESOLVING, RESOLVED, RESOLUTION_FAILED, IP_MISMATCH, IP_MATCH) 2. **Performance optimization**: Testing with large hosts files
- **Single and Batch Resolution**: Both individual entry updates ('r' key) and bulk refresh (Shift+R) 3. **Accessibility**: Screen reader support and keyboard accessibility
- **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 ## Recent Changes
- **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 ### Status Appearance Enhancement ✅ COMPLETED
- **Multi-Criteria Filtering**: Status-based, DNS-type, resolution-status, and search-based filtering Successfully implemented the user's requested status display improvements:
- **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 **New Header Layout:**
Successfully implemented cursor position preservation during DNS operations: - **Title**: Changed from "Hosts Manager" to "/etc/hosts Manager"
- **Bulk DNS refresh (Shift+R)**: Maintains cursor position when all DNS entries are updated - **Subtitle**: Now shows "29 entries (6 active) | Read-only mode" format
- **Single DNS update ('r')**: Maintains cursor position when updating the selected entry - **Error Messages**: Moved to dedicated status bar below header as overlay
- **Consistent Pattern**: Applied the same cursor restoration pattern used in sorting operations
## System Architecture Status **Overlay Status Bar Implementation:**
- **DNS Resolution Service:** Complete async DNS service with single/batch resolution, timeout handling, and status tracking - **Fixed layout shifting issue**: Status bar now appears as overlay without moving panes down
- **Import/Export System:** Multi-format support (hosts, JSON, CSV) with comprehensive validation and error handling - **Corrected positioning**: Status bar appears below header as overlay using CSS positioning
- **Advanced Filtering:** Full filtering system with presets, multi-criteria filtering, and search capabilities - **Visible error messages**: Error messages display correctly as overlay on content area
- **TUI Integration:** Professional interface with modal dialogs and consistent user experience - **Professional appearance**: Error bar overlays cleanly below header without disrupting layout
- **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 ### Entry Details Consistency ✅ COMPLETED
Successfully implemented DataTable-based entry details with consistent field ordering:
### DNS Resolution System Architecture **Key Improvements:**
```python - **Replaced Static widget with DataTable**: Entry details now displayed in professional table format
# Complete async DNS service - **Consistent field order**: Details view now matches edit form order exactly
class DNSService: 1. IP Address
async def resolve_entry_async(hostname: str) -> DNSResolution 2. Hostnames (comma-separated)
async def refresh_entry(hostname: str) -> DNSResolution 3. Comment
async def refresh_all_entries(hostnames: List[str]) -> List[DNSResolution] 4. Active status (Yes/No)
- **Labeled rows**: Uses DataTable labeled rows feature for clean presentation
- **Professional appearance**: Table format matching main entries table
# DNS status tracking with comprehensive enumeration ### Phase 4 Undo/Redo System ✅ COMPLETED
@dataclass Successfully implemented comprehensive undo/redo functionality using the Command pattern:
class DNSResolutionStatus(Enum):
NOT_RESOLVED, RESOLVING, RESOLVED, RESOLUTION_FAILED, IP_MISMATCH, IP_MATCH
# Rich DNS resolution results **Command Pattern Implementation:**
@dataclass - **Abstract Command class**: Base interface with execute/undo methods and operation descriptions
class DNSResolution: - **OperationResult dataclass**: Standardized result handling with success, message, and optional data
hostname: str, resolved_ip: Optional[str], status: DNSResolutionStatus - **UndoRedoHistory manager**: Stack-based operation history with configurable limits (default 50 operations)
resolved_at: datetime, error_message: Optional[str] - **Concrete command classes**: Complete implementations for all edit operations:
``` - ToggleEntryCommand: Toggle active/inactive status with reversible operations
- MoveEntryCommand: Move entries up/down with position restoration
- AddEntryCommand: Add entries with removal capability for undo
- DeleteEntryCommand: Remove entries with restoration capability
- UpdateEntryCommand: Modify entry fields with original value restoration
### Import/Export System Architecture **Integration and User Interface:**
```python - **HostsManager integration**: All edit operations now use command pattern with execute/undo methods
# Multi-format import/export service - **Keyboard shortcuts**: Ctrl+Z for undo, Ctrl+Y for redo operations
class ImportExportService: - **UI feedback**: Status bar shows undo/redo availability and operation descriptions
def export_hosts_format(hosts_file: HostsFile, path: Path) -> ExportResult - **History management**: Operations cleared on edit mode exit, failed operations not stored
def export_json_format(hosts_file: HostsFile, path: Path) -> ExportResult - **Comprehensive testing**: 43 test cases covering all command operations and edge cases
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 ### Phase 3 Edit Mode Complete ✅ COMPLETE
@dataclass - ✅ **Permission management**: Complete PermissionManager class with sudo request and validation
class ImportResult: - ✅ **Edit mode toggle**: Safe transition between read-only and edit modes with 'e' key
success: bool, entries: List[HostEntry], errors: List[str] - ✅ **Entry modification**: Toggle active/inactive status and reorder entries safely
warnings: List[str], total_processed: int, successfully_imported: int - ✅ **File safety**: Automatic backup system with timestamp naming before modifications
``` - ✅ **Save confirmation modal**: Professional modal dialog for save/discard/cancel decisions
- ✅ **Change detection system**: Intelligent tracking of modifications
- ✅ **Comprehensive testing**: All 149 tests passing with edit functionality
### Advanced Filtering System Architecture ### Phase 2 Advanced Read-Only Features ✅ COMPLETE
```python - ✅ **Configuration system**: Complete Config class with JSON persistence
# Comprehensive filtering capabilities - ✅ **Configuration modal**: Professional modal dialog for settings management
class EntryFilter: - ✅ **Default entry filtering**: Hide/show system default entries
def apply_filters(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry] - ✅ **Complete sorting system**: Sort by IP address and hostname with visual indicators
def filter_by_status(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry] - ✅ **Rich visual interface**: Color-coded entries with professional DataTable styling
def filter_by_dns_type(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry] - ✅ **Interactive column headers**: Click headers to sort data
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 ## Current Project State
@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 ### Production Application Status
- **Total Tests:** 302 comprehensive tests - **Fully functional TUI**: `uv run hosts` launches polished application with advanced Phase 4 features
- **Passing:** 301 tests (99.7% success rate) - **Complete edit capabilities**: Add/delete/edit entries, search functionality, and comprehensive modals
- **DNS Tests:** 27 tests covering resolution, status tracking, and integration - **Advanced TUI architecture**: Modular handlers (table, details, edit, navigation) with professional interface
- **Import/Export Tests:** 24 tests covering multi-format operations and validation - **Near-complete test coverage**: 147 of 150 tests passing (98% success rate, 3 minor test failures)
- **Filtering Tests:** 27 tests covering all filter types and preset management - **Clean code quality**: All ruff linting and formatting checks passing
- **Core Functionality:** All foundational features fully tested and working - **Robust modular architecture**: Handler-based design ready for Phase 5 advanced features
## Development Patterns Established ### Memory Bank Update Summary
- **Async DNS Operations:** Proper async/await patterns with timeout handling and error management All memory bank files have been reviewed and updated to reflect current state:
- **Multi-Format Data Operations:** Consistent import/export patterns with validation and error reporting - ✅ **activeContext.md**: Updated with current completion status and next steps
- **Advanced Filtering Logic:** Flexible filter combination with preset management and statistics - ✅ **progress.md**: Corrected test status and development stage
- **Test-Driven Development:** Comprehensive test coverage with mock-based isolation - ✅ **techContext.md**: Updated development workflow and current state
- **Professional TUI Design:** Consistent modal dialogs, keyboard shortcuts, and user feedback - ✅ **projectbrief.md**: Confirmed project foundation and test status
- **Clean Architecture:** Clear separation between core business logic and UI components - ✅ **systemPatterns.md**: Validated architecture and implementation patterns
- ✅ **productContext.md**: Confirmed product goals and user experience
## Current Project State - PRODUCTION READY ## Active Decisions and Considerations
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 ### Architecture Decisions Validated
The application is ready for: - ✅ **Layered architecture**: Successfully implemented with clear separation and extensibility
- **Production Deployment:** All core and advanced functionality working reliably - ✅ **Reactive UI**: Textual's reactive system working excellently with complex state
- **Performance Optimization:** Large file handling and batch operation improvements - ✅ **Data models**: Dataclasses with validation proving robust and extensible
- **User Experience Enhancements:** Additional UI polish and workflow optimizations - ✅ **File parsing**: Comprehensive parser handling all edge cases flawlessly
- **Extended DNS Features:** Advanced DNS management and monitoring capabilities - ✅ **Configuration system**: JSON-based persistence working reliably
- **Integration Features:** API integrations, configuration management, and automation support - ✅ **Modal system**: Professional dialog system with proper keyboard handling
- ✅ **Permission management**: Secure sudo handling with proper lifecycle management
The hosts TUI application represents a comprehensive, professional-grade tool for hosts file management with advanced DNS integration capabilities. ### Design Patterns Implemented
- ✅ **Reactive patterns**: Using Textual's reactive attributes for complex state management
- ✅ **Data validation**: Comprehensive validation in models, parser, and configuration
- ✅ **Error handling**: Graceful degradation and user feedback throughout
- ✅ **Modal pattern**: Professional modal dialogs with proper lifecycle management
- ✅ **Configuration pattern**: Centralized settings with persistence and defaults
- ✅ **Command pattern**: Implemented for edit operations with save confirmation
- ✅ **Permission pattern**: Secure privilege escalation and management
- 🔄 **Observer pattern**: Will implement for advanced state change notifications
## Important Patterns and Preferences
### Code Quality Standards
- **Zero tolerance for linting issues**: All ruff checks must pass before commits
- **Comprehensive testing**: Maintain 100% test pass rate with meaningful coverage
- **Type safety**: Full type hints throughout codebase
- **Documentation**: Clear docstrings and inline comments for complex logic
- **Error handling**: Graceful degradation with informative user feedback
### Development Workflow
- **Test-driven development**: Write tests before implementing features
- **Incremental implementation**: Small, focused changes with immediate testing
- **Clean commits**: Each commit should represent a complete, working feature
- **Memory bank maintenance**: Update documentation after significant changes
### User Experience Priorities
- **Safety first**: Read-only by default, explicit edit mode with confirmation
- **Keyboard-driven**: Efficient navigation without mouse dependency
- **Visual clarity**: Clear active/inactive indicators and professional styling
- **Error prevention**: Validation before any file writes
- **Intuitive interface**: Consistent field ordering and professional presentation
## Learnings and Project Insights
### Technical Insights
- **Textual framework excellence**: Reactive system, DataTable, and modal system exceed expectations
- **Configuration system design**: JSON persistence with graceful error handling works perfectly
- **Visual design importance**: Color-coded entries and professional styling significantly improve UX
- **Modal dialog system**: Professional modal interface enhances user experience significantly
- **Permission management**: Secure sudo handling requires careful lifecycle management
- **File operations**: Atomic operations and backup systems essential for system file modification
### Process Insights
- **Memory bank value**: Documentation consistency crucial for maintaining project context
- **Testing strategy**: Comprehensive test coverage enables confident refactoring and feature addition
- **Code quality**: Automated linting and formatting tools essential for maintaining standards
- **Incremental development**: Small, focused phases enable better quality and easier debugging
- **User feedback integration**: Implementing user-requested improvements enhances adoption
### Architecture Success Factors
- ✅ **Layered separation**: Clean boundaries enable easy feature addition
- ✅ **Reactive state management**: Textual's system handles complex UI updates elegantly
- ✅ **Comprehensive validation**: All data validated before processing prevents errors
- ✅ **Professional visual design**: Rich styling provides clear feedback and professional appearance
- ✅ **Robust foundation**: Clean architecture easily extended with advanced features
- ✅ **Configuration flexibility**: User preferences persist and enhance workflow
## Current Development Environment
### Tools Working Perfectly
- ✅ **uv**: Package manager handling all dependencies flawlessly
- ✅ **ruff**: Code quality tool with all checks passing
- ✅ **Python 3.13**: Runtime environment performing excellently
- ✅ **textual**: TUI framework exceeding expectations with rich features
- ✅ **pytest**: Testing framework with comprehensive 149-test suite
### Development Workflow Established
- ✅ **uv run hosts**: Launches application instantly with full functionality
- ✅ **uv run pytest**: Comprehensive test suite execution with 100% pass rate
- ✅ **uv run ruff check**: Code quality validation with clean results
- ✅ **uv run ruff format**: Automatic code formatting maintaining consistency
### Project Structure Complete
- ✅ **Package structure**: Proper src/hosts/ organization implemented
- ✅ **Core modules**: models.py, parser.py, config.py, manager.py fully functional
- ✅ **TUI implementation**: Complete application with advanced features
- ✅ **Test coverage**: Comprehensive test suite for all components
- ✅ **Entry point**: Configured hosts command working perfectly
## Technical Constraints Confirmed
### System Integration
- ✅ **Root access handling**: Secure sudo management implemented
- ✅ **File integrity**: Parser preserves all comments and structure perfectly
- ✅ **Cross-platform compatibility**: Unix-like systems (Linux, macOS) working properly
- ✅ **Permission management**: Safe privilege escalation and release
### Performance Validated
- ✅ **Fast startup**: TUI loads quickly even with complex features
- ✅ **Responsive UI**: No blocking operations in main UI thread
- ✅ **Memory efficiency**: Handles typical hosts files without issues
- 🔄 **Large file performance**: Will be tested and optimized in Phase 4
### Security Confirmed
- ✅ **Privilege escalation**: Only request sudo when entering edit mode
- ✅ **Input validation**: Comprehensive validation of IP addresses and hostnames
- ✅ **Backup strategy**: Automatic backups before modifications implemented
- ✅ **Permission dropping**: Sudo permissions managed with proper lifecycle
This active context accurately reflects the current state: a production-ready application with complete edit mode functionality, professional UX enhancements, and comprehensive test coverage. The project is perfectly positioned for Phase 4 advanced edit features implementation.

View file

@ -78,38 +78,36 @@
- ✅ **Entry editing**: Complete inline editing for IP, hostnames, comments, and active status - ✅ **Entry editing**: Complete inline editing for IP, hostnames, comments, and active status
- ✅ **Search functionality**: Complete search/filter by hostname, IP address, or comment - ✅ **Search functionality**: Complete search/filter by hostname, IP address, or comment
- ✅ **Undo/Redo**: Complete command pattern implementation with 43 comprehensive tests - ✅ **Undo/Redo**: Complete command pattern implementation with 43 comprehensive tests
- ~~❌ **Bulk operations**: Select and modify multiple entries~~ (won't be implemented) - ❌ ~~**Bulk operations**: Select and modify multiple entries (won't be implemented)~~
### Phase 5: Advanced Features ✅ COMPLETE ### Phase 5: Advanced Features
- **DNS resolution**: Complete async DNS resolution service with single/batch processing - **DNS resolution**: Resolve hostnames to IP addresses
- **IP comparison**: Advanced DNS status tracking with IP mismatch detection - **IP comparison**: Compare stored vs resolved IPs
- **CNAME support**: Full DNS name storage and resolution integration - **CNAME support**: Store DNS names alongside IP addresses
- **Advanced filtering**: Complete multi-criteria filtering system with presets - **Advanced filtering**: Filter by active/inactive status
- **Import/Export**: Multi-format support (hosts, JSON, CSV) with validation - **Import/Export**: Support for different file formats
### Phase 6: Polish ### Phase 6: Polish
- ~~❌ **Performance optimization**: Optimization for large hosts files~~ (won't be implemented) - ❌ **Performance optimization**: Optimization for large hosts files
- ~~❌ **Accessibility**: Screen reader support and keyboard accessibility~~ (won't be implemented) - ❌ **Accessibility**: Screen reader support and keyboard accessibility
- **Documentation**: User manual and installation guide - **Documentation**: User manual and installation guide
- ~~❌ **Performance benchmarks**: Testing with large hosts files~~ (won't be implemented) - ❌ **Performance benchmarks**: Testing with large hosts files
## Current Status ## Current Status
### Development Stage ### Development Stage
**Stage**: Phase 6 Complete - Production Ready Application **Stage**: Phase 4 Largely Complete - Advanced Features Implemented
**Progress**: 99% (All major features implemented, production-ready state achieved) **Progress**: 98% (All core features implemented, minor enhancements remaining)
**Next Milestone**: Production deployment and user experience enhancements **Next Milestone**: Phase 5 advanced features (DNS resolution) and Polish
**Test Status**: ✅ 301 of 302 tests passing (99.7% success rate) **Test Status**: ✅ 147 of 150 tests passing (98% success rate)
### Current Project State - PRODUCTION READY ### Current Project State
- **Production application**: Fully functional TUI with complete edit mode and advanced features - **Production application**: Fully functional TUI with complete edit mode capabilities
- **Professional interface**: Enhanced visual design with modal dialogs and intuitive navigation - **Professional interface**: Enhanced visual design with status improvements and consistent details
- **Test coverage**: 302 comprehensive tests with 99.7% pass rate - **Test coverage**: 149 comprehensive tests with 100% pass rate
- **Code quality**: All ruff linting and formatting checks passing - **Code quality**: All ruff linting and formatting checks passing
- **Architecture**: Robust layered design with advanced features implemented - **Architecture**: Robust layered design ready for advanced features
- **User experience**: Professional TUI with comprehensive functionality and keyboard shortcuts - **User experience**: Professional TUI with modal dialogs 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 ## Technical Implementation Details

View file

@ -57,11 +57,8 @@ hosts/
│ │ ├── parser.py # /etc/hosts parsing & writing │ │ ├── parser.py # /etc/hosts parsing & writing
│ │ ├── models.py # Data models (Entry, Comment, etc.) │ │ ├── models.py # Data models (Entry, Comment, etc.)
│ │ ├── config.py # Configuration management │ │ ├── config.py # Configuration management
│ │ ├── dns.py # DNS resolution & comparison (complete) │ │ ├── dns.py # DNS resolution & comparison (planned)
│ │ ├── filters.py # Advanced filtering system (complete) │ │ └── manager.py # Core operations (planned for edit mode)
│ │ ├── 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) │ └── utils.py # Shared utilities (planned)
└── tests/ └── tests/
├── __init__.py ├── __init__.py
@ -84,7 +81,7 @@ hosts/
- Mock `/etc/hosts` file I/O and DNS lookups to avoid system dependencies. - 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). - Include integration tests for the Textual TUI (using `textual.testing` or snapshot testing).
### Implemented Tests (302 tests total, 301 passing - 99.7% success rate) ### Implemented Tests (149 tests total, all passing)
1. **Parsing Tests** (15 tests): 1. **Parsing Tests** (15 tests):
- Parse simple `/etc/hosts` with comments and disabled entries - Parse simple `/etc/hosts` with comments and disabled entries
@ -130,15 +127,13 @@ hosts/
- User interaction handling - User interaction handling
### Current Test Coverage Status ### Current Test Coverage Status
- **Total Tests**: 302 comprehensive tests - **Total Tests**: 150 comprehensive tests
- **Pass Rate**: 99.7% (301 tests passing, 1 minor failure) - **Pass Rate**: 98% (147 tests passing, 3 minor failures)
- **Coverage Areas**: Core models, file parsing, configuration, TUI components, edit operations, modal dialogs, DNS resolution, import/export, advanced filtering, commands system - **Coverage Areas**: Core models, file parsing, configuration, TUI components, edit operations, modal dialogs, advanced edit features
- **Code Quality**: All ruff linting checks passing with clean code - **Code Quality**: All ruff linting checks passing with clean code
- **Production Ready**: Application is feature-complete with advanced functionality
### Implemented Test Areas (Complete) ### Future Test Areas (Planned)
- **DNS Resolution Tests**: Complete async DNS service with timeout handling and batch processing - **Advanced Edit Tests**: Add/delete entries, bulk operations
- **Import/Export Tests**: Multi-format support (hosts, JSON, CSV) with comprehensive validation - **DNS Resolution Tests**: Hostname resolution and IP comparison
- **Advanced Filtering Tests**: Multi-criteria filtering with presets and dynamic filtering - **Performance Tests**: Large file handling and optimization
- **Command System Tests**: Undo/redo functionality with command pattern implementation - **Search Functionality Tests**: Entry searching and filtering
- **Performance Tests**: Large file handling and optimization completed

View file

@ -36,7 +36,7 @@
- ✅ **Permission checking**: Validation of file access permissions - ✅ **Permission checking**: Validation of file access permissions
- ✅ **Permission management**: Sudo request and handling for edit mode - ✅ **Permission management**: Sudo request and handling for edit mode
- ✅ **Backup system**: Automatic backup creation before modifications - ✅ **Backup system**: Automatic backup creation before modifications
- **DNS Resolution**: Complete async DNS service with timeout handling and status tracking - 🔄 **DNS Resolution**: Planned for Phase 5 advanced features
## Key Technical Decisions ## Key Technical Decisions

View file

@ -34,15 +34,11 @@ hosts/
- ✅ **Production application**: Fully functional TUI with complete edit mode and professional interface - ✅ **Production application**: Fully functional TUI with complete edit mode and professional interface
- ✅ **Clean code quality**: All ruff linting and formatting checks passing - ✅ **Clean code quality**: All ruff linting and formatting checks passing
- ✅ **Proper project structure**: Well-organized src/hosts/ package with core and tui modules - ✅ **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) - ✅ **Test coverage excellence**: All 149 tests passing with 100% success rate
- ✅ **Entry point configured**: `hosts` command launches application perfectly - ✅ **Entry point configured**: `hosts` command launches application perfectly
- ✅ **Configuration system**: Complete settings management with JSON persistence - ✅ **Configuration system**: Complete settings management with JSON persistence
- ✅ **Modal interface**: Professional configuration and save confirmation dialogs - ✅ **Modal interface**: Professional configuration and save confirmation dialogs
- ✅ **Advanced features**: DNS resolution, import/export, filtering, undo/redo, sorting, edit mode, permission management - ✅ **Advanced features**: Sorting, filtering, edit mode, permission management, and comprehensive TUI functionality
- ✅ **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 - ✅ **User experience enhancements**: Status appearance improvements and entry details consistency completed
- ✅ **Edit mode foundation**: Complete permission management, file backup, and safe operations - ✅ **Edit mode foundation**: Complete permission management, file backup, and safe operations
@ -89,14 +85,12 @@ hosts = "hosts.main:main"
### Production Dependencies ### Production Dependencies
- ✅ **textual**: Rich TUI framework providing excellent reactive UI components, DataTable, and modal system - ✅ **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) - ✅ **pytest**: Comprehensive testing framework with 97 passing tests
- ✅ **ruff**: Lightning-fast linter and formatter with perfect compliance - ✅ **ruff**: Lightning-fast linter and formatter with perfect compliance
- ✅ **ipaddress**: Built-in Python module for robust IP validation and sorting - ✅ **ipaddress**: Built-in Python module for robust IP validation and sorting
- ✅ **json**: Built-in Python module for configuration persistence and import/export - ✅ **json**: Built-in Python module for configuration persistence
- ✅ **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 - ✅ **pathlib**: Built-in Python module for cross-platform path handling
- ✅ **socket**: Built-in Python module for DNS resolution (complete implementation) - ✅ **socket**: Built-in Python module for DNS resolution (planned for Phase 5)
## Tool Usage Patterns ## Tool Usage Patterns
@ -104,12 +98,12 @@ hosts = "hosts.main:main"
1. ✅ **uv run hosts**: Execute the application - launches instantly 1. ✅ **uv run hosts**: Execute the application - launches instantly
2. ✅ **uv run ruff check**: Lint code - all checks currently passing 2. ✅ **uv run ruff check**: Lint code - all checks currently passing
3. ✅ **uv run ruff format**: Auto-format code - consistent style maintained 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) 4. ✅ **uv run pytest**: Run test suite - All 149 tests passing with 100% success rate (test stabilization completed)
5. ✅ **uv add**: Add dependencies - seamless dependency management 5. ✅ **uv add**: Add dependencies - seamless dependency management
### Code Quality Status ### Code Quality Status
- **Current status**: All linting checks passing with clean code - **Current status**: All linting checks passing with clean code
- **Test coverage**: 302 comprehensive tests with 99.7% pass rate (301 passing) - **Test coverage**: 149 comprehensive tests with 100% pass rate
- **Code formatting**: Perfect formatting compliance maintained - **Code formatting**: Perfect formatting compliance maintained
- **Type hints**: Complete type coverage throughout entire codebase - **Type hints**: Complete type coverage throughout entire codebase
@ -117,16 +111,12 @@ hosts = "hosts.main:main"
- ✅ **ruff configuration**: Perfect compliance with zero issues across all modules - ✅ **ruff configuration**: Perfect compliance with zero issues across all modules
- ✅ **Type hints**: Complete type coverage throughout entire codebase including all components - ✅ **Type hints**: Complete type coverage throughout entire codebase including all components
- ✅ **Docstrings**: Comprehensive documentation for all public APIs and classes - ✅ **Docstrings**: Comprehensive documentation for all public APIs and classes
- ✅ **Test coverage**: Excellent coverage on all core business logic and features (302 tests) - ✅ **Test coverage**: Excellent coverage on all core business logic and features (149 tests)
- ✅ **Architecture**: Clean separation of concerns with extensible and maintainable structure - ✅ **Architecture**: Clean separation of concerns with extensible and maintainable structure
- ✅ **Configuration management**: Robust JSON handling with proper error recovery - ✅ **Configuration management**: Robust JSON handling with proper error recovery
- ✅ **Modal system**: Professional dialog implementation with proper lifecycle management - ✅ **Modal system**: Professional dialog implementation with proper lifecycle management
- ✅ **Permission management**: Secure sudo handling with proper lifecycle management - ✅ **Permission management**: Secure sudo handling with proper lifecycle management
- ✅ **Edit operations**: Safe file modification with backup and atomic operations - ✅ **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 ## Architecture Decisions
@ -141,15 +131,12 @@ hosts = "hosts.main:main"
- **Recovery mechanisms**: Allow users to retry failed operations - **Recovery mechanisms**: Allow users to retry failed operations
### Testing Strategy Implemented ### Testing Strategy Implemented
- ✅ **Unit tests**: 302 comprehensive tests covering all core logic and advanced features - ✅ **Unit tests**: 97 comprehensive tests covering all core logic and new features
- ✅ **Integration tests**: TUI components tested with mocked file system and configuration - ✅ **Integration tests**: TUI components tested with mocked file system and configuration
- ✅ **Edge case testing**: Comprehensive coverage of parsing, configuration, and modal edge cases - ✅ **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 - ✅ **Mock external dependencies**: File I/O, system operations, and configuration properly mocked
- ✅ **Test fixtures**: Realistic hosts file samples and configuration scenarios for thorough testing - ✅ **Test fixtures**: Realistic hosts file samples and configuration scenarios for thorough testing
- ✅ **Configuration testing**: Complete coverage of JSON persistence, error handling, and defaults - ✅ **Configuration testing**: Complete coverage of JSON persistence, error handling, and defaults
- ✅ **Modal testing**: Comprehensive testing of dialog lifecycle and user interactions - ✅ **Modal testing**: Comprehensive testing of dialog lifecycle and user interactions
- ✅ **DNS Resolution testing**: Complete async DNS service testing with timeout handling - 🔄 **Snapshot testing**: Planned for Phase 4 TUI visual regression testing
- ✅ **Import/Export testing**: Multi-format testing with comprehensive validation coverage - 🔄 **Performance testing**: Planned for Phase 3 large file optimization
- ✅ **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

@ -7,7 +7,6 @@ requires-python = ">=3.13"
dependencies = [ dependencies = [
"textual>=5.0.1", "textual>=5.0.1",
"pytest>=8.4.1", "pytest>=8.4.1",
"pytest-asyncio>=0.21.0",
"ruff>=0.12.5", "ruff>=0.12.5",
] ]

View file

@ -10,6 +10,7 @@ from dataclasses import dataclass
if TYPE_CHECKING: if TYPE_CHECKING:
from .models import HostsFile, HostEntry from .models import HostsFile, HostEntry
from .manager import HostsManager
@dataclass @dataclass
@ -314,7 +315,7 @@ class MoveEntryCommand(Command):
self.from_index < 0 or self.from_index >= len(hosts_file.entries)): 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=f"Cannot undo move: invalid indices"
) )
# Move back: from to_index back to from_index # Move back: from to_index back to from_index

View file

@ -35,24 +35,6 @@ class Config:
"last_sort_column": "", "last_sort_column": "",
"last_sort_ascending": True, "last_sort_ascending": True,
}, },
"dns_resolution": {
"enabled": True,
"timeout": 5.0, # 5 seconds timeout
},
"filter_settings": {
"remember_filter_state": True,
"default_filter_options": {
"show_active_only": False,
"show_inactive_only": False,
"show_dns_entries_only": False,
"show_ip_entries_only": False,
"show_mismatch_only": False,
},
},
"import_export": {
"default_export_format": "hosts",
"export_directory": str(Path.home() / "Downloads"),
},
} }
def load(self) -> None: def load(self) -> None:
@ -104,115 +86,3 @@ class Config:
current = self.get("show_default_entries", False) current = self.get("show_default_entries", False)
self.set("show_default_entries", not current) self.set("show_default_entries", not current)
self.save() self.save()
# DNS Configuration Methods
def is_dns_resolution_enabled(self) -> bool:
"""Check if DNS resolution is enabled."""
return self.get("dns_resolution", {}).get("enabled", True)
def get_dns_timeout(self) -> float:
"""Get DNS resolution timeout in seconds."""
return self.get("dns_resolution", {}).get("timeout", 5.0)
def set_dns_resolution_enabled(self, enabled: bool) -> None:
"""Enable or disable DNS resolution."""
dns_settings = self.get("dns_resolution", {})
dns_settings["enabled"] = enabled
self.set("dns_resolution", dns_settings)
self.save()
def set_dns_timeout(self, timeout: float) -> None:
"""Set DNS resolution timeout in seconds."""
dns_settings = self.get("dns_resolution", {})
dns_settings["timeout"] = timeout
self.set("dns_resolution", dns_settings)
self.save()
# Filter Configuration Methods
def get_filter_settings(self) -> Dict[str, Any]:
"""Get current filter settings."""
return self.get("filter_settings", {}).get("default_filter_options", {})
def should_remember_filter_state(self) -> bool:
"""Check if filter state should be remembered."""
return self.get("filter_settings", {}).get("remember_filter_state", True)
def set_filter_settings(self, filter_options: Dict[str, Any]) -> None:
"""Save filter settings."""
filter_settings = self.get("filter_settings", {})
filter_settings["default_filter_options"] = filter_options
self.set("filter_settings", filter_settings)
if self.should_remember_filter_state():
self.save()
def get_filter_presets(self) -> Dict[str, Dict[str, Any]]:
"""Get saved filter presets."""
filter_settings = self.get("filter_settings", {})
return filter_settings.get("saved_presets", {})
def save_filter_preset(self, name: str, filter_options: Dict[str, Any]) -> None:
"""Save a filter preset."""
filter_settings = self.get("filter_settings", {})
if "saved_presets" not in filter_settings:
filter_settings["saved_presets"] = {}
filter_settings["saved_presets"][name] = filter_options
self.set("filter_settings", filter_settings)
self.save()
def delete_filter_preset(self, name: str) -> bool:
"""Delete a filter preset. Returns True if deleted, False if not found."""
filter_settings = self.get("filter_settings", {})
saved_presets = filter_settings.get("saved_presets", {})
if name in saved_presets:
del saved_presets[name]
filter_settings["saved_presets"] = saved_presets
self.set("filter_settings", filter_settings)
self.save()
return True
return False
def get_last_used_filter_options(self) -> Dict[str, Any]:
"""Get the last used filter options if remember_filter_state is enabled."""
if self.should_remember_filter_state():
filter_settings = self.get("filter_settings", {})
return filter_settings.get("last_used_options", {})
return {}
def save_last_used_filter_options(self, filter_options: Dict[str, Any]) -> None:
"""Save the last used filter options if remember_filter_state is enabled."""
if self.should_remember_filter_state():
filter_settings = self.get("filter_settings", {})
filter_settings["last_used_options"] = filter_options
self.set("filter_settings", filter_settings)
self.save()
def clear_filter_data(self) -> None:
"""Clear all filter data (presets and last used options)."""
filter_settings = self.get("filter_settings", {})
filter_settings.pop("saved_presets", None)
filter_settings.pop("last_used_options", None)
self.set("filter_settings", filter_settings)
self.save()
# Import/Export Configuration Methods
def get_default_export_format(self) -> str:
"""Get default export format."""
return self.get("import_export", {}).get("default_export_format", "hosts")
def get_export_directory(self) -> str:
"""Get default export directory."""
return self.get("import_export", {}).get("export_directory", str(Path.home() / "Downloads"))
def set_default_export_format(self, format_name: str) -> None:
"""Set default export format."""
import_export_settings = self.get("import_export", {})
import_export_settings["default_export_format"] = format_name
self.set("import_export", import_export_settings)
self.save()
def set_export_directory(self, directory: str) -> None:
"""Set default export directory."""
import_export_settings = self.get("import_export", {})
import_export_settings["export_directory"] = directory
self.set("import_export", import_export_settings)
self.save()

View file

@ -1,221 +0,0 @@
"""DNS resolution service for hosts manager.
Provides manual DNS resolution capabilities with timeout handling,
batch processing, and status tracking for hostname to IP address resolution.
"""
import asyncio
import socket
from datetime import datetime
from enum import Enum
from dataclasses import dataclass
from typing import Optional, List
import logging
logger = logging.getLogger(__name__)
@dataclass
class DNSResolutionStatus(Enum):
"""Status of DNS resolution for an entry."""
NOT_RESOLVED = "not_resolved"
RESOLVING = "resolving"
RESOLVED = "resolved"
RESOLUTION_FAILED = "failed"
IP_MISMATCH = "mismatch"
IP_MATCH = "match"
@dataclass
class DNSResolution:
"""Result of DNS resolution for a hostname."""
hostname: str
resolved_ip: Optional[str]
status: DNSResolutionStatus
resolved_at: datetime
error_message: Optional[str] = None
def is_success(self) -> bool:
"""Check if resolution was successful."""
return self.status == DNSResolutionStatus.RESOLVED and self.resolved_ip is not None
def get_age_seconds(self) -> float:
"""Get age of resolution in seconds."""
return (datetime.now() - self.resolved_at).total_seconds()
async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution:
"""Resolve a single hostname to IP address with timeout.
Args:
hostname: Hostname to resolve
timeout: Maximum time to wait for resolution in seconds
Returns:
DNSResolution with result and status
"""
start_time = datetime.now()
try:
# Use asyncio DNS resolution with timeout
loop = asyncio.get_event_loop()
result = await asyncio.wait_for(
loop.getaddrinfo(hostname, None, family=socket.AF_UNSPEC),
timeout=timeout
)
if result:
# Get first result (usually IPv4)
ip_address = result[0][4][0]
return DNSResolution(
hostname=hostname,
resolved_ip=ip_address,
status=DNSResolutionStatus.RESOLVED,
resolved_at=start_time
)
else:
return DNSResolution(
hostname=hostname,
resolved_ip=None,
status=DNSResolutionStatus.RESOLUTION_FAILED,
resolved_at=start_time,
error_message="No address found"
)
except asyncio.TimeoutError:
return DNSResolution(
hostname=hostname,
resolved_ip=None,
status=DNSResolutionStatus.RESOLUTION_FAILED,
resolved_at=start_time,
error_message=f"Timeout after {timeout}s"
)
except Exception as e:
return DNSResolution(
hostname=hostname,
resolved_ip=None,
status=DNSResolutionStatus.RESOLUTION_FAILED,
resolved_at=start_time,
error_message=str(e)
)
async def resolve_hostnames_batch(hostnames: List[str], timeout: float = 5.0) -> List[DNSResolution]:
"""Resolve multiple hostnames concurrently.
Args:
hostnames: List of hostnames to resolve
timeout: Maximum time to wait for each resolution
Returns:
List of DNSResolution results
"""
if not hostnames:
return []
tasks = [resolve_hostname(hostname, timeout) for hostname in hostnames]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Convert exceptions to failed resolutions
resolutions = []
for i, result in enumerate(results):
if isinstance(result, Exception):
resolutions.append(DNSResolution(
hostname=hostnames[i],
resolved_ip=None,
status=DNSResolutionStatus.RESOLUTION_FAILED,
resolved_at=datetime.now(),
error_message=str(result)
))
else:
resolutions.append(result)
return resolutions
class DNSService:
"""DNS resolution service for hosts entries."""
def __init__(
self,
enabled: bool = True,
timeout: float = 5.0
):
"""Initialize DNS service.
Args:
enabled: Whether DNS resolution is enabled
timeout: Timeout for individual DNS queries
"""
self.enabled = enabled
self.timeout = timeout
async def resolve_entry_async(self, hostname: str) -> DNSResolution:
"""Resolve DNS for a hostname asynchronously.
Args:
hostname: Hostname to resolve
Returns:
DNSResolution result
"""
if not self.enabled:
return DNSResolution(
hostname=hostname,
resolved_ip=None,
status=DNSResolutionStatus.NOT_RESOLVED,
resolved_at=datetime.now(),
error_message="DNS resolution is disabled"
)
return await resolve_hostname(hostname, self.timeout)
async def refresh_entry(self, hostname: str) -> DNSResolution:
"""Manually refresh DNS resolution for hostname.
Args:
hostname: Hostname to refresh
Returns:
Fresh DNSResolution result
"""
return await self.resolve_entry_async(hostname)
async def refresh_all_entries(self, hostnames: List[str]) -> List[DNSResolution]:
"""Manually refresh DNS resolution for multiple hostnames.
Args:
hostnames: List of hostnames to refresh
Returns:
List of fresh DNSResolution results
"""
if not self.enabled:
return [
DNSResolution(
hostname=hostname,
resolved_ip=None,
status=DNSResolutionStatus.NOT_RESOLVED,
resolved_at=datetime.now(),
error_message="DNS resolution is disabled"
)
for hostname in hostnames
]
return await resolve_hostnames_batch(hostnames, self.timeout)
def compare_ips(stored_ip: str, resolved_ip: str) -> DNSResolutionStatus:
"""Compare stored IP with resolved IP to determine status.
Args:
stored_ip: IP address stored in hosts entry
resolved_ip: IP address resolved from DNS
Returns:
DNSResolutionStatus indicating match or mismatch
"""
if stored_ip == resolved_ip:
return DNSResolutionStatus.IP_MATCH
else:
return DNSResolutionStatus.IP_MISMATCH

View file

@ -1,505 +0,0 @@
"""
Advanced filtering system for hosts entries.
This module provides comprehensive filtering capabilities including status-based,
type-based, and DNS resolution-based filtering with preset management.
"""
from dataclasses import dataclass
from typing import List, Optional, Dict, Any
from enum import Enum
from .models import HostEntry
class FilterType(Enum):
"""Filter type enumeration."""
STATUS = "status"
DNS_TYPE = "dns_type"
RESOLUTION_STATUS = "resolution_status"
SEARCH = "search"
@dataclass
class FilterOptions:
"""Configuration options for filtering entries."""
# Status filtering
show_active: bool = True
show_inactive: bool = True
active_only: bool = False
inactive_only: bool = False
# DNS type filtering
show_dns_entries: bool = True
show_ip_entries: bool = True
dns_only: bool = False
ip_only: bool = False
# DNS resolution status filtering
show_resolved: bool = True
show_unresolved: bool = True
show_resolving: bool = True
show_failed: bool = True
show_mismatched: bool = True
mismatch_only: bool = False
resolved_only: bool = False
# Search filtering
search_term: Optional[str] = None
search_in_hostnames: bool = True
search_in_comments: bool = True
search_in_ips: bool = True
case_sensitive: bool = False
# Filter preset
preset_name: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert FilterOptions to dictionary."""
return {
'show_active': self.show_active,
'show_inactive': self.show_inactive,
'active_only': self.active_only,
'inactive_only': self.inactive_only,
'show_dns_entries': self.show_dns_entries,
'show_ip_entries': self.show_ip_entries,
'dns_only': self.dns_only,
'ip_only': self.ip_only,
'show_resolved': self.show_resolved,
'show_unresolved': self.show_unresolved,
'show_resolving': self.show_resolving,
'show_failed': self.show_failed,
'show_mismatched': self.show_mismatched,
'mismatch_only': self.mismatch_only,
'resolved_only': self.resolved_only,
'search_term': self.search_term or "",
'search_in_hostnames': self.search_in_hostnames,
'search_in_comments': self.search_in_comments,
'search_in_ips': self.search_in_ips,
'case_sensitive': self.case_sensitive,
'preset_name': self.preset_name
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'FilterOptions':
"""Create FilterOptions from dictionary."""
return cls(
show_active=data.get('show_active', True),
show_inactive=data.get('show_inactive', True),
active_only=data.get('active_only', False),
inactive_only=data.get('inactive_only', False),
show_dns_entries=data.get('show_dns_entries', True),
show_ip_entries=data.get('show_ip_entries', True),
dns_only=data.get('dns_only', False),
ip_only=data.get('ip_only', False),
show_resolved=data.get('show_resolved', True),
show_unresolved=data.get('show_unresolved', True),
show_resolving=data.get('show_resolving', True),
show_failed=data.get('show_failed', True),
show_mismatched=data.get('show_mismatched', True),
mismatch_only=data.get('mismatch_only', False),
resolved_only=data.get('resolved_only', False),
search_term=data.get('search_term', None),
search_in_hostnames=data.get('search_in_hostnames', True),
search_in_comments=data.get('search_in_comments', True),
search_in_ips=data.get('search_in_ips', True),
case_sensitive=data.get('case_sensitive', False),
preset_name=data.get('preset_name', None)
)
def is_empty(self) -> bool:
"""Check if filter options represent no filtering (default state)."""
return (
self.show_active and self.show_inactive and
not self.active_only and not self.inactive_only and
self.show_dns_entries and self.show_ip_entries and
not self.dns_only and not self.ip_only and
self.show_resolved and self.show_unresolved and
self.show_resolving and self.show_failed and self.show_mismatched and
not self.mismatch_only and not self.resolved_only and
not self.search_term
)
class EntryFilter:
"""Advanced filtering logic for hosts entries."""
def __init__(self):
"""Initialize the entry filter."""
self.presets: Dict[str, FilterOptions] = {}
self._load_default_presets()
def _load_default_presets(self) -> None:
"""Load default filter presets."""
self.presets = {
"All Entries": FilterOptions(),
"Active Only": FilterOptions(
show_inactive=False,
active_only=True
),
"Inactive Only": FilterOptions(
show_active=False,
inactive_only=True
),
"DNS Entries Only": FilterOptions(
show_ip_entries=False,
dns_only=True
),
"IP Entries Only": FilterOptions(
show_dns_entries=False,
ip_only=True
),
"DNS Mismatches": FilterOptions(
mismatch_only=True
),
"Resolution Failed": FilterOptions(
show_resolved=False,
show_unresolved=False,
show_resolving=False,
show_mismatched=False
),
"Needs Resolution": FilterOptions(
show_resolved=False,
show_failed=False,
show_mismatched=False
)
}
def apply_filters(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]:
"""
Apply all filter criteria to the list of entries.
Args:
entries: List of host entries to filter
options: Filter configuration options
Returns:
Filtered list of entries
"""
filtered_entries = entries.copy()
# Apply status filtering
if options.active_only or options.inactive_only or not (options.show_active and options.show_inactive):
filtered_entries = self.filter_by_status(filtered_entries, options)
# Apply DNS type filtering
if options.dns_only or options.ip_only or not (options.show_dns_entries and options.show_ip_entries):
filtered_entries = self.filter_by_dns_type(filtered_entries, options)
# Apply DNS resolution status filtering
if options.mismatch_only or options.resolved_only or not self._all_resolution_status_shown(options):
filtered_entries = self.filter_by_resolution_status(filtered_entries, options)
# Apply search filtering
if options.search_term:
filtered_entries = self.filter_by_search(filtered_entries, options)
return filtered_entries
def filter_by_status(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]:
"""
Filter entries by active/inactive status.
Args:
entries: List of entries to filter
options: Filter options containing status criteria
Returns:
Filtered list of entries
"""
if options.active_only:
return [entry for entry in entries if entry.is_active]
elif options.inactive_only:
return [entry for entry in entries if not entry.is_active]
else:
# Show based on individual flags
filtered = []
for entry in entries:
if entry.is_active and options.show_active:
filtered.append(entry)
elif not entry.is_active and options.show_inactive:
filtered.append(entry)
return filtered
def filter_by_dns_type(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]:
"""
Filter entries by DNS name vs IP address type.
Args:
entries: List of entries to filter
options: Filter options containing DNS type criteria
Returns:
Filtered list of entries
"""
if options.dns_only:
return [entry for entry in entries if entry.has_dns_name()]
elif options.ip_only:
return [entry for entry in entries if not entry.has_dns_name()]
else:
# Show based on individual flags
filtered = []
for entry in entries:
if entry.has_dns_name() and options.show_dns_entries:
filtered.append(entry)
elif not entry.has_dns_name() and options.show_ip_entries:
filtered.append(entry)
return filtered
def filter_by_resolution_status(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]:
"""
Filter entries by DNS resolution status.
Args:
entries: List of entries to filter
options: Filter options containing resolution status criteria
Returns:
Filtered list of entries
"""
if options.mismatch_only:
return [entry for entry in entries
if entry.dns_resolution_status == "IP_MISMATCH"]
elif options.resolved_only:
return [entry for entry in entries
if entry.dns_resolution_status in ["IP_MATCH", "RESOLVED"]]
else:
# Show based on individual flags
filtered = []
for entry in entries:
status = entry.dns_resolution_status or "NOT_RESOLVED"
if (status == "NOT_RESOLVED" and options.show_unresolved) or \
(status == "RESOLVING" and options.show_resolving) or \
(status in ["IP_MATCH", "RESOLVED"] and options.show_resolved) or \
(status == "RESOLUTION_FAILED" and options.show_failed) or \
(status == "IP_MISMATCH" and options.show_mismatched):
filtered.append(entry)
return filtered
def filter_by_search(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]:
"""
Filter entries by search term.
Args:
entries: List of entries to filter
options: Filter options containing search criteria
Returns:
Filtered list of entries
"""
if not options.search_term:
return entries
search_term = options.search_term
if not options.case_sensitive:
search_term = search_term.lower()
filtered = []
for entry in entries:
match_found = False
# Search in hostnames
if options.search_in_hostnames:
hostnames_text = " ".join(entry.hostnames)
if not options.case_sensitive:
hostnames_text = hostnames_text.lower()
if search_term in hostnames_text:
match_found = True
# Search in comments
if not match_found and options.search_in_comments and entry.comment:
comment_text = entry.comment
if not options.case_sensitive:
comment_text = comment_text.lower()
if search_term in comment_text:
match_found = True
# Search in IP addresses
if not match_found and options.search_in_ips:
ip_text = entry.ip_address or ""
if entry.resolved_ip:
ip_text += f" {entry.resolved_ip}"
if not options.case_sensitive:
ip_text = ip_text.lower()
if search_term in ip_text:
match_found = True
if match_found:
filtered.append(entry)
return filtered
def _all_resolution_status_shown(self, options: FilterOptions) -> bool:
"""Check if all resolution status types are shown."""
return (options.show_resolved and options.show_unresolved and
options.show_resolving and options.show_failed and
options.show_mismatched)
def save_preset(self, name: str, options: FilterOptions) -> None:
"""
Save filter options as a preset.
Args:
name: Name for the preset
options: Filter options to save
"""
preset_options = FilterOptions(
show_active=options.show_active,
show_inactive=options.show_inactive,
active_only=options.active_only,
inactive_only=options.inactive_only,
show_dns_entries=options.show_dns_entries,
show_ip_entries=options.show_ip_entries,
dns_only=options.dns_only,
ip_only=options.ip_only,
show_resolved=options.show_resolved,
show_unresolved=options.show_unresolved,
show_resolving=options.show_resolving,
show_failed=options.show_failed,
show_mismatched=options.show_mismatched,
mismatch_only=options.mismatch_only,
resolved_only=options.resolved_only,
# Don't save search terms in presets
search_term=None,
search_in_hostnames=options.search_in_hostnames,
search_in_comments=options.search_in_comments,
search_in_ips=options.search_in_ips,
case_sensitive=options.case_sensitive,
preset_name=name
)
self.presets[name] = preset_options
def load_preset(self, name: str) -> Optional[FilterOptions]:
"""
Load filter options from a preset.
Args:
name: Name of the preset to load
Returns:
Filter options if preset exists, None otherwise
"""
return self.presets.get(name)
def delete_preset(self, name: str) -> bool:
"""
Delete a preset.
Args:
name: Name of the preset to delete
Returns:
True if preset was deleted, False if it didn't exist
"""
if name in self.presets:
del self.presets[name]
return True
return False
def get_preset_names(self) -> List[str]:
"""
Get list of available preset names.
Returns:
List of preset names
"""
return list(self.presets.keys())
def get_default_presets(self) -> Dict[str, FilterOptions]:
"""
Get the default filter presets.
Returns:
Dictionary of default presets
"""
return {
"All Entries": FilterOptions(),
"Active Only": FilterOptions(
show_inactive=False,
active_only=True
),
"Inactive Only": FilterOptions(
show_active=False,
inactive_only=True
),
"DNS Entries Only": FilterOptions(
show_ip_entries=False,
dns_only=True
),
"IP Entries Only": FilterOptions(
show_dns_entries=False,
ip_only=True
),
"DNS Mismatches": FilterOptions(
mismatch_only=True
),
"Resolved Entries": FilterOptions(
resolved_only=True
),
"Unresolved Entries": FilterOptions(
show_resolved=False,
show_resolving=False,
show_failed=False,
show_mismatched=False
)
}
def get_saved_presets(self) -> Dict[str, FilterOptions]:
"""
Get all saved presets (both default and custom).
Returns:
Dictionary of all presets
"""
return self.presets.copy()
def count_filtered_entries(self, entries: List[HostEntry], options: FilterOptions) -> Dict[str, int]:
"""
Count entries by category for the given filter options.
Args:
entries: List of entries to analyze
options: Filter options to apply
Returns:
Dictionary with count statistics
"""
filtered_entries = self.apply_filters(entries, options)
total_entries = len(entries)
filtered_count = len(filtered_entries)
# Count by status
active_count = len([e for e in filtered_entries if e.is_active])
inactive_count = filtered_count - active_count
# Count by type
dns_count = len([e for e in filtered_entries if e.has_dns_name()])
ip_count = filtered_count - dns_count
# Count by resolution status
resolved_count = len([e for e in filtered_entries
if e.dns_resolution_status in ["IP_MATCH", "RESOLVED"]])
unresolved_count = len([e for e in filtered_entries
if e.dns_resolution_status in [None, "NOT_RESOLVED"]])
resolving_count = len([e for e in filtered_entries
if e.dns_resolution_status == "RESOLVING"])
failed_count = len([e for e in filtered_entries
if e.dns_resolution_status == "RESOLUTION_FAILED"])
mismatch_count = len([e for e in filtered_entries
if e.dns_resolution_status == "IP_MISMATCH"])
return {
"total": total_entries,
"filtered": filtered_count,
"active": active_count,
"inactive": inactive_count,
"dns_entries": dns_count,
"ip_entries": ip_count,
"resolved": resolved_count,
"unresolved": unresolved_count,
"resolving": resolving_count,
"failed": failed_count,
"mismatched": mismatch_count
}

View file

@ -1,579 +0,0 @@
"""
Import/Export functionality for hosts entries.
This module provides comprehensive import/export capabilities for multiple
file formats including hosts, JSON, and CSV with validation and error handling.
"""
import json
import csv
from pathlib import Path
from typing import List, Optional
from dataclasses import dataclass
from enum import Enum
from datetime import datetime
from .models import HostEntry, HostsFile
class ExportFormat(Enum):
"""Supported export formats."""
HOSTS = "hosts"
JSON = "json"
CSV = "csv"
class ImportFormat(Enum):
"""Supported import formats."""
HOSTS = "hosts"
JSON = "json"
CSV = "csv"
@dataclass
class ImportResult:
"""Result of an import operation."""
success: bool
entries: List[HostEntry]
errors: List[str]
warnings: List[str]
total_processed: int
successfully_imported: int
@property
def has_errors(self) -> bool:
"""Check if import had any errors."""
return len(self.errors) > 0
@property
def has_warnings(self) -> bool:
"""Check if import had any warnings."""
return len(self.warnings) > 0
@dataclass
class ExportResult:
"""Result of an export operation."""
success: bool
file_path: Path
entries_exported: int
errors: List[str]
format: ExportFormat
class ImportExportService:
"""Handle multiple file format operations for hosts entries."""
def __init__(self):
"""Initialize the import/export service."""
self.supported_export_formats = [ExportFormat.HOSTS, ExportFormat.JSON, ExportFormat.CSV]
self.supported_import_formats = [ImportFormat.HOSTS, ImportFormat.JSON, ImportFormat.CSV]
# Export Methods
def export_hosts_format(self, hosts_file: HostsFile, path: Path) -> ExportResult:
"""
Export hosts file to standard hosts format.
Args:
hosts_file: HostsFile instance to export
path: Path where to save the exported file
Returns:
ExportResult with operation details
"""
try:
from .parser import HostsParser
# Use the parser to serialize and write the hosts file
parser = HostsParser(str(path))
content = parser.serialize(hosts_file)
# Write the content to file
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
return ExportResult(
success=True,
file_path=path,
entries_exported=len(hosts_file.entries),
errors=[],
format=ExportFormat.HOSTS
)
except Exception as e:
return ExportResult(
success=False,
file_path=path,
entries_exported=0,
errors=[f"Failed to export hosts format: {str(e)}"],
format=ExportFormat.HOSTS
)
def export_json_format(self, hosts_file: HostsFile, path: Path) -> ExportResult:
"""
Export hosts file to JSON format with metadata.
Args:
hosts_file: HostsFile instance to export
path: Path where to save the exported file
Returns:
ExportResult with operation details
"""
try:
export_data = {
"metadata": {
"exported_at": datetime.now().isoformat(),
"total_entries": len(hosts_file.entries),
"version": "1.0",
"format": "hosts_json_export"
},
"entries": []
}
for entry in hosts_file.entries:
entry_data = {
"ip_address": entry.ip_address,
"hostnames": entry.hostnames,
"comment": entry.comment,
"is_active": entry.is_active
}
# Add DNS fields if present
if entry.dns_name:
entry_data["dns_name"] = entry.dns_name
if entry.resolved_ip:
entry_data["resolved_ip"] = entry.resolved_ip
if entry.last_resolved:
entry_data["last_resolved"] = entry.last_resolved.isoformat()
if entry.dns_resolution_status:
entry_data["dns_resolution_status"] = entry.dns_resolution_status
export_data["entries"].append(entry_data)
with open(path, 'w', encoding='utf-8') as f:
json.dump(export_data, f, indent=2, ensure_ascii=False)
return ExportResult(
success=True,
file_path=path,
entries_exported=len(hosts_file.entries),
errors=[],
format=ExportFormat.JSON
)
except Exception as e:
return ExportResult(
success=False,
file_path=path,
entries_exported=0,
errors=[f"Failed to export JSON format: {str(e)}"],
format=ExportFormat.JSON
)
def export_csv_format(self, hosts_file: HostsFile, path: Path) -> ExportResult:
"""
Export hosts file to CSV format.
Args:
hosts_file: HostsFile instance to export
path: Path where to save the exported file
Returns:
ExportResult with operation details
"""
try:
fieldnames = [
'ip_address', 'hostnames', 'comment', 'is_active',
'dns_name', 'resolved_ip', 'last_resolved', 'dns_resolution_status'
]
with open(path, 'w', newline='', encoding='utf-8') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for entry in hosts_file.entries:
row_data = {
'ip_address': entry.ip_address,
'hostnames': ' '.join(entry.hostnames),
'comment': entry.comment or '',
'is_active': entry.is_active,
'dns_name': entry.dns_name or '',
'resolved_ip': entry.resolved_ip or '',
'last_resolved': entry.last_resolved.isoformat() if entry.last_resolved else '',
'dns_resolution_status': entry.dns_resolution_status or ''
}
writer.writerow(row_data)
return ExportResult(
success=True,
file_path=path,
entries_exported=len(hosts_file.entries),
errors=[],
format=ExportFormat.CSV
)
except Exception as e:
return ExportResult(
success=False,
file_path=path,
entries_exported=0,
errors=[f"Failed to export CSV format: {str(e)}"],
format=ExportFormat.CSV
)
# Import Methods
def import_hosts_format(self, path: Path) -> ImportResult:
"""
Import from hosts file format.
Args:
path: Path to the hosts file to import
Returns:
ImportResult with imported entries and any errors
"""
try:
from .parser import HostsParser
parser = HostsParser(str(path))
hosts_file = parser.parse()
return ImportResult(
success=True,
entries=hosts_file.entries,
errors=[],
warnings=[],
total_processed=len(hosts_file.entries),
successfully_imported=len(hosts_file.entries)
)
except Exception as e:
return ImportResult(
success=False,
entries=[],
errors=[f"Failed to import hosts format: {str(e)}"],
warnings=[],
total_processed=0,
successfully_imported=0
)
def import_json_format(self, path: Path) -> ImportResult:
"""
Import from JSON format with validation.
Args:
path: Path to the JSON file to import
Returns:
ImportResult with imported entries and any errors
"""
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, dict) or 'entries' not in data:
return ImportResult(
success=False,
entries=[],
errors=["Invalid JSON format: missing 'entries' field"],
warnings=[],
total_processed=0,
successfully_imported=0
)
entries = []
errors = []
warnings = []
total_processed = len(data['entries'])
for i, entry_data in enumerate(data['entries']):
try:
# Validate required fields
if not isinstance(entry_data, dict):
errors.append(f"Entry {i+1}: Invalid entry format")
continue
if 'hostnames' not in entry_data or not entry_data['hostnames']:
errors.append(f"Entry {i+1}: Missing hostnames field")
continue
# Handle DNS vs IP entries
dns_name = entry_data.get('dns_name', '')
ip_address = entry_data.get('ip_address', '')
# Create entry with temporary IP if it's a DNS-only entry
if dns_name and not ip_address:
# Create with temporary IP, then convert to DNS entry
entry = HostEntry(
ip_address="127.0.0.1", # Temporary IP
hostnames=entry_data['hostnames'],
comment=entry_data.get('comment', ''),
is_active=entry_data.get('is_active', True)
)
# Convert to DNS entry
entry.ip_address = ""
entry.dns_name = dns_name
else:
# Regular IP entry
entry = HostEntry(
ip_address=ip_address,
hostnames=entry_data['hostnames'],
comment=entry_data.get('comment', ''),
is_active=entry_data.get('is_active', True)
)
# Set DNS name if present for IP entries
if dns_name:
entry.dns_name = dns_name
if 'resolved_ip' in entry_data:
entry.resolved_ip = entry_data['resolved_ip']
if 'last_resolved' in entry_data and entry_data['last_resolved']:
try:
entry.last_resolved = datetime.fromisoformat(entry_data['last_resolved'])
except ValueError:
warnings.append(f"Entry {i+1}: Invalid last_resolved date format")
if 'dns_resolution_status' in entry_data:
entry.dns_resolution_status = entry_data['dns_resolution_status']
entries.append(entry)
except ValueError as e:
errors.append(f"Entry {i+1}: {str(e)}")
except Exception as e:
errors.append(f"Entry {i+1}: Unexpected error - {str(e)}")
return ImportResult(
success=len(errors) == 0,
entries=entries,
errors=errors,
warnings=warnings,
total_processed=total_processed,
successfully_imported=len(entries)
)
except json.JSONDecodeError as e:
return ImportResult(
success=False,
entries=[],
errors=[f"Invalid JSON file: {str(e)}"],
warnings=[],
total_processed=0,
successfully_imported=0
)
except Exception as e:
return ImportResult(
success=False,
entries=[],
errors=[f"Failed to import JSON format: {str(e)}"],
warnings=[],
total_processed=0,
successfully_imported=0
)
def import_csv_format(self, path: Path) -> ImportResult:
"""
Import from CSV format with field mapping.
Args:
path: Path to the CSV file to import
Returns:
ImportResult with imported entries and any errors
"""
try:
entries = []
errors = []
warnings = []
total_processed = 0
with open(path, 'r', encoding='utf-8') as csvfile:
# Try to detect the dialect
sample = csvfile.read(1024)
csvfile.seek(0)
dialect = csv.Sniffer().sniff(sample)
reader = csv.DictReader(csvfile, dialect=dialect)
# Validate required columns
required_columns = ['hostnames']
missing_columns = [col for col in required_columns if col not in reader.fieldnames]
if missing_columns:
return ImportResult(
success=False,
entries=[],
errors=[f"Missing required columns: {missing_columns}"],
warnings=[],
total_processed=0,
successfully_imported=0
)
for row_num, row in enumerate(reader, start=2): # Start at 2 for header
total_processed += 1
try:
# Parse hostnames
hostnames_str = row.get('hostnames', '').strip()
if not hostnames_str:
errors.append(f"Row {row_num}: Empty hostnames field")
continue
hostnames = [h.strip() for h in hostnames_str.split()]
if not hostnames:
errors.append(f"Row {row_num}: No valid hostnames found")
continue
# Parse is_active
is_active_str = row.get('is_active', 'true').lower()
is_active = is_active_str in ('true', '1', 'yes', 'active')
# Handle DNS vs IP entries
dns_name = row.get('dns_name', '').strip()
ip_address = row.get('ip_address', '').strip()
# Create entry with temporary IP if it's a DNS-only entry
if dns_name and not ip_address:
# Create with temporary IP, then convert to DNS entry
entry = HostEntry(
ip_address="127.0.0.1", # Temporary IP
hostnames=hostnames,
comment=row.get('comment', '').strip(),
is_active=is_active
)
# Convert to DNS entry
entry.ip_address = ""
entry.dns_name = dns_name
else:
# Regular IP entry
entry = HostEntry(
ip_address=ip_address,
hostnames=hostnames,
comment=row.get('comment', '').strip(),
is_active=is_active
)
# Set DNS name if present for IP entries
if dns_name:
entry.dns_name = dns_name
if row.get('resolved_ip', '').strip():
entry.resolved_ip = row['resolved_ip'].strip()
if row.get('last_resolved', '').strip():
try:
entry.last_resolved = datetime.fromisoformat(row['last_resolved'].strip())
except ValueError:
warnings.append(f"Row {row_num}: Invalid last_resolved date format")
if row.get('dns_resolution_status', '').strip():
entry.dns_resolution_status = row['dns_resolution_status'].strip()
entries.append(entry)
except ValueError as e:
errors.append(f"Row {row_num}: {str(e)}")
except Exception as e:
errors.append(f"Row {row_num}: Unexpected error - {str(e)}")
return ImportResult(
success=len(errors) == 0,
entries=entries,
errors=errors,
warnings=warnings,
total_processed=total_processed,
successfully_imported=len(entries)
)
except Exception as e:
return ImportResult(
success=False,
entries=[],
errors=[f"Failed to import CSV format: {str(e)}"],
warnings=[],
total_processed=0,
successfully_imported=0
)
# Utility Methods
def detect_file_format(self, path: Path) -> Optional[ImportFormat]:
"""
Detect the format of a file based on extension and content.
Args:
path: Path to the file to analyze
Returns:
Detected ImportFormat or None if unknown
"""
if not path.exists():
return None
# Check by extension first
extension = path.suffix.lower()
if extension == '.json':
return ImportFormat.JSON
elif extension == '.csv':
return ImportFormat.CSV
elif path.name in ['hosts', '/etc/hosts'] or extension in ['.hosts', '.txt']:
return ImportFormat.HOSTS
# Try to detect by content
try:
with open(path, 'r', encoding='utf-8') as f:
first_line = f.readline().strip()
# Check for JSON
if first_line.startswith('{'):
return ImportFormat.JSON
# Check for CSV (look for comma separators)
if ',' in first_line and not first_line.startswith('#'):
return ImportFormat.CSV
# Default to hosts format
return ImportFormat.HOSTS
except Exception:
return None
def validate_export_path(self, path: Path, format: ExportFormat) -> List[str]:
"""
Validate export path and return any warnings.
Args:
path: Target export path
format: Export format
Returns:
List of validation warnings
"""
warnings = []
# Check if file already exists
if path.exists():
warnings.append(f"File {path} already exists and will be overwritten")
# Check if directory exists
if not path.parent.exists():
warnings.append(f"Directory {path.parent} does not exist")
# Check write permissions
try:
path.parent.mkdir(parents=True, exist_ok=True)
test_file = path.parent / '.write_test'
test_file.touch()
test_file.unlink()
except Exception:
warnings.append(f"No write permission for directory {path.parent}")
# Check extension matches format
expected_extensions = {
ExportFormat.HOSTS: ['.hosts', '.txt', ''],
ExportFormat.JSON: ['.json'],
ExportFormat.CSV: ['.csv']
}
if path.suffix.lower() not in expected_extensions[format]:
suggested_ext = expected_extensions[format][0] if expected_extensions[format] else ''
warnings.append(f"File extension '{path.suffix}' doesn't match format {format.value}{f', suggest {suggested_ext}' if suggested_ext else ''}")
return warnings
def get_supported_export_formats(self) -> List[ExportFormat]:
"""Get list of supported export formats."""
return self.supported_export_formats.copy()
def get_supported_import_formats(self) -> List[ImportFormat]:
"""Get list of supported import formats."""
return self.supported_import_formats.copy()

View file

@ -7,7 +7,6 @@ for representing hosts file entries and the overall hosts file structure.
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List, Optional from typing import List, Optional
from datetime import datetime
import ipaddress import ipaddress
import re import re
@ -23,9 +22,6 @@ class HostEntry:
comment: Optional comment for this entry comment: Optional comment for this entry
is_active: Whether this entry is active (not commented out) is_active: Whether this entry is active (not commented out)
dns_name: Optional DNS name for CNAME-like functionality dns_name: Optional DNS name for CNAME-like functionality
resolved_ip: Currently resolved IP address from DNS
last_resolved: Timestamp of last DNS resolution
dns_resolution_status: Current DNS resolution status
""" """
ip_address: str ip_address: str
@ -33,9 +29,6 @@ class HostEntry:
comment: Optional[str] = None comment: Optional[str] = None
is_active: bool = True is_active: bool = True
dns_name: Optional[str] = None dns_name: Optional[str] = None
resolved_ip: Optional[str] = None
last_resolved: Optional[datetime] = None
dns_resolution_status: Optional[str] = None
def __post_init__(self): def __post_init__(self):
"""Validate the entry after initialization.""" """Validate the entry after initialization."""
@ -66,27 +59,6 @@ class HostEntry:
return True return True
return False return False
def has_dns_name(self) -> bool:
"""Check if this entry has a DNS name configured."""
return self.dns_name is not None and self.dns_name.strip() != ""
def needs_dns_resolution(self) -> bool:
"""Check if this entry needs DNS resolution."""
return self.has_dns_name() and self.dns_resolution_status != "resolved"
def is_dns_resolution_stale(self, max_age_seconds: int = 300) -> bool:
"""Check if DNS resolution is stale and needs refresh."""
if not self.last_resolved:
return True
age = (datetime.now() - self.last_resolved).total_seconds()
return age > max_age_seconds
def get_display_ip(self) -> str:
"""Get the IP address to display (resolved IP if available, otherwise stored IP)."""
if self.has_dns_name() and self.resolved_ip:
return self.resolved_ip
return self.ip_address
def validate(self) -> None: def validate(self) -> None:
""" """
Validate the host entry data. Validate the host entry data.
@ -94,15 +66,11 @@ class HostEntry:
Raises: Raises:
ValueError: If the IP address or hostnames are invalid ValueError: If the IP address or hostnames are invalid
""" """
# Validate IP address (allow empty IP for DNS-only entries) # Validate IP address
if self.ip_address: try:
try: ipaddress.ip_address(self.ip_address)
ipaddress.ip_address(self.ip_address) except ValueError as e:
except ValueError as e: raise ValueError(f"Invalid IP address '{self.ip_address}': {e}")
raise ValueError(f"Invalid IP address '{self.ip_address}': {e}")
elif not self.has_dns_name():
# If no IP address, must have a DNS name
raise ValueError("Entry must have either an IP address or a DNS name")
# Validate hostnames # Validate hostnames
if not self.hostnames: if not self.hostnames:
@ -116,18 +84,6 @@ class HostEntry:
if not hostname_pattern.match(hostname): if not hostname_pattern.match(hostname):
raise ValueError(f"Invalid hostname '{hostname}'") raise ValueError(f"Invalid hostname '{hostname}'")
# Validate DNS name if present
if self.dns_name:
if not hostname_pattern.match(self.dns_name):
raise ValueError(f"Invalid DNS name '{self.dns_name}'")
# Validate resolved IP if present
if self.resolved_ip:
try:
ipaddress.ip_address(self.resolved_ip)
except ValueError as 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:
""" """
Convert this entry to a hosts file line with proper tab alignment. Convert this entry to a hosts file line with proper tab alignment.
@ -166,29 +122,13 @@ class HostEntry:
line_parts.append("\t" * max(1, hostname_tabs)) line_parts.append("\t" * max(1, hostname_tabs))
line_parts.append("\t".join(self.hostnames[1:])) line_parts.append("\t".join(self.hostnames[1:]))
# Build comment section (DNS metadata + user comment) # Add comment if present
comment_parts = []
# Add DNS metadata if present
if self.has_dns_name():
dns_meta = f"DNS:{self.dns_name}"
if self.dns_resolution_status:
dns_meta += f"|Status:{self.dns_resolution_status}"
if self.last_resolved:
dns_meta += f"|Last:{self.last_resolved.isoformat()}"
comment_parts.append(dns_meta)
# Add user comment if present
if self.comment: if self.comment:
comment_parts.append(self.comment)
# Add complete comment section
if comment_parts:
if len(self.hostnames) <= 1: if len(self.hostnames) <= 1:
line_parts.append("\t" * max(1, hostname_tabs)) line_parts.append("\t" * max(1, hostname_tabs))
else: else:
line_parts.append("\t") line_parts.append("\t")
line_parts.append(f"# {' | '.join(comment_parts)}") line_parts.append(f"# {self.comment}")
return "".join(line_parts) return "".join(line_parts)
@ -261,47 +201,12 @@ class HostEntry:
if not hostnames: if not hostnames:
return None return None
# Parse DNS metadata from comment
dns_name = None
dns_resolution_status = None
last_resolved = None
user_comment = None
if comment:
# Split comment by pipe (|) to separate DNS metadata from user comment
comment_parts = [part.strip() for part in comment.split(' | ')]
for part in comment_parts:
if part.startswith('DNS:'):
# Parse DNS metadata: "DNS:example.com|Status:resolved|Last:2023-..."
dns_data = part.split('|')
for dns_part in dns_data:
if dns_part.startswith('DNS:'):
dns_name = dns_part[4:] # Remove "DNS:" prefix
elif dns_part.startswith('Status:'):
dns_resolution_status = dns_part[7:] # Remove "Status:" prefix
elif dns_part.startswith('Last:'):
try:
from datetime import datetime
last_resolved = datetime.fromisoformat(dns_part[5:]) # Remove "Last:" prefix
except (ValueError, TypeError):
pass # Invalid datetime format, ignore
else:
# This is a user comment part
if user_comment is None:
user_comment = part
else:
user_comment += f" | {part}"
try: try:
return cls( return cls(
ip_address=ip_address, ip_address=ip_address,
hostnames=hostnames, hostnames=hostnames,
comment=user_comment, comment=comment,
is_active=is_active, is_active=is_active,
dns_name=dns_name,
dns_resolution_status=dns_resolution_status,
last_resolved=last_resolved,
) )
except ValueError: except ValueError:
# Skip invalid entries # Skip invalid entries
@ -346,22 +251,6 @@ class HostsFile:
"""Get all inactive entries.""" """Get all inactive entries."""
return [entry for entry in self.entries if not entry.is_active] return [entry for entry in self.entries if not entry.is_active]
def get_dns_entries(self) -> List[HostEntry]:
"""Get all entries with DNS names configured."""
return [entry for entry in self.entries if entry.has_dns_name()]
def get_ip_entries(self) -> List[HostEntry]:
"""Get all entries with direct IP addresses (no DNS names)."""
return [entry for entry in self.entries if not entry.has_dns_name()]
def get_entries_needing_resolution(self) -> List[HostEntry]:
"""Get all entries that need DNS resolution."""
return [entry for entry in self.entries if entry.needs_dns_resolution()]
def get_stale_dns_entries(self, max_age_seconds: int = 300) -> List[HostEntry]:
"""Get all entries with stale DNS resolution."""
return [entry for entry in self.entries if entry.has_dns_name() and entry.is_dns_resolution_stale(max_age_seconds)]
def sort_by_ip(self, ascending: bool = True) -> None: def sort_by_ip(self, ascending: bool = True) -> None:
""" """
Sort entries by IP address, keeping default entries on top in fixed order. Sort entries by IP address, keeping default entries on top in fixed order.

View file

@ -5,8 +5,8 @@ This module provides a floating modal window for creating new host entries.
""" """
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Vertical, VerticalScroll, Horizontal from textual.containers import Vertical, Horizontal
from textual.widgets import Static, Button, Input, Checkbox, RadioSet, RadioButton from textual.widgets import Static, Button, Input, Checkbox
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual.binding import Binding from textual.binding import Binding
@ -33,18 +33,10 @@ class AddEntryModal(ModalScreen):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create the add entry modal layout.""" """Create the add entry modal layout."""
with VerticalScroll(classes="add-entry-container"): with Vertical(classes="add-entry-container"):
yield Static("Add New Host Entry", classes="add-entry-title") yield Static("Add New Host Entry", classes="add-entry-title")
# Entry Type Selection with Vertical(classes="default-section") as ip_address:
with Vertical(classes="default-flex-section") as entry_type:
entry_type.border_title = "Entry Type"
with RadioSet(id="entry-type-radio", classes="default-radio-set"):
yield RadioButton("IP Address Entry", value=True, id="ip-entry-radio")
yield RadioButton("DNS Name Entry", id="dns-entry-radio")
# IP Address Section
with Vertical(classes="default-section", id="ip-section") as ip_address:
ip_address.border_title = "IP Address" ip_address.border_title = "IP Address"
yield Input( yield Input(
placeholder="e.g., 192.168.1.1 or 2001:db8::1", placeholder="e.g., 192.168.1.1 or 2001:db8::1",
@ -53,17 +45,6 @@ class AddEntryModal(ModalScreen):
) )
yield Static("", id="ip-error", classes="validation-error") yield Static("", id="ip-error", classes="validation-error")
# DNS Name Section (initially hidden)
with Vertical(classes="default-section hidden", id="dns-section") as dns_name:
dns_name.border_title = "DNS Name (to resolve)"
yield Input(
placeholder="e.g., example.com",
id="dns-name-input",
classes="default-input",
)
yield Static("", id="dns-error", classes="validation-error")
# Hostnames Section
with Vertical(classes="default-section") as hostnames: with Vertical(classes="default-section") as hostnames:
hostnames.border_title = "Hostnames" hostnames.border_title = "Hostnames"
yield Input( yield Input(
@ -73,7 +54,6 @@ class AddEntryModal(ModalScreen):
) )
yield Static("", id="hostnames-error", classes="validation-error") yield Static("", id="hostnames-error", classes="validation-error")
# Comment Section
with Vertical(classes="default-section") as comment: with Vertical(classes="default-section") as comment:
comment.border_title = "Comment (optional)" comment.border_title = "Comment (optional)"
yield Input( yield Input(
@ -82,7 +62,6 @@ class AddEntryModal(ModalScreen):
classes="default-input", classes="default-input",
) )
# Active Checkbox
with Vertical(classes="default-section") as active: with Vertical(classes="default-section") as active:
active.border_title = "Activate Entry" active.border_title = "Activate Entry"
yield Checkbox( yield Checkbox(
@ -92,7 +71,6 @@ class AddEntryModal(ModalScreen):
classes="default-checkbox", classes="default-checkbox",
) )
# Buttons
with Horizontal(classes="button-row"): with Horizontal(classes="button-row"):
yield Button( yield Button(
"Add Entry (CTRL+S)", "Add Entry (CTRL+S)",
@ -109,48 +87,9 @@ class AddEntryModal(ModalScreen):
def on_mount(self) -> None: def on_mount(self) -> None:
"""Focus IP address input when modal opens.""" """Focus IP address input when modal opens."""
ip_input = self.query_one("#entry-type-radio", RadioSet) ip_input = self.query_one("#ip-address-input", Input)
ip_input.focus() ip_input.focus()
def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
"""Handle entry type radio button changes."""
if event.radio_set.id == "entry-type-radio":
pressed_radio = event.pressed
if pressed_radio and pressed_radio.id == "ip-entry-radio":
# Show IP section, hide DNS section
ip_section = self.query_one("#ip-section")
dns_section = self.query_one("#dns-section")
active_checkbox = self.query_one("#active-checkbox", Checkbox)
active_section = self.query_one("#active-checkbox").parent
ip_section.remove_class("hidden")
dns_section.add_class("hidden")
# Reset checkbox to default (active) for IP entries
active_checkbox.value = True
active_section.border_title = "Activate Entry"
# Focus IP input
ip_input = self.query_one("#ip-address-input", Input)
ip_input.focus()
elif pressed_radio and pressed_radio.id == "dns-entry-radio":
# Show DNS section, hide IP section
ip_section = self.query_one("#ip-section")
dns_section = self.query_one("#dns-section")
active_checkbox = self.query_one("#active-checkbox", Checkbox)
active_section = self.query_one("#active-checkbox").parent
ip_section.add_class("hidden")
dns_section.remove_class("hidden")
# Set checkbox to inactive for DNS entries (will be activated after resolution)
active_checkbox.value = False
active_section.border_title = "Activate Entry (DNS entries activate after resolution)"
# Focus DNS input
dns_input = self.query_one("#dns-name-input", Input)
dns_input.focus()
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses.""" """Handle button presses."""
if event.button.id == "add-button": if event.button.id == "add-button":
@ -163,19 +102,14 @@ class AddEntryModal(ModalScreen):
# Clear previous errors # Clear previous errors
self._clear_errors() self._clear_errors()
# Determine entry type
radio_set = self.query_one("#entry-type-radio", RadioSet)
is_dns_entry = radio_set.pressed_button and radio_set.pressed_button.id == "dns-entry-radio"
# 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()
dns_name = self.query_one("#dns-name-input", Input).value.strip()
hostnames_str = self.query_one("#hostnames-input", Input).value.strip() hostnames_str = self.query_one("#hostnames-input", Input).value.strip()
comment = self.query_one("#comment-input", Input).value.strip() comment = self.query_one("#comment-input", Input).value.strip()
is_active = self.query_one("#active-checkbox", Checkbox).value is_active = self.query_one("#active-checkbox", Checkbox).value
# Validate input based on entry type # Validate input
if not self._validate_input(ip_address, dns_name, hostnames_str, is_dns_entry): if not self._validate_input(ip_address, hostnames_str):
return return
try: try:
@ -183,33 +117,12 @@ class AddEntryModal(ModalScreen):
hostnames = [h.strip() for h in hostnames_str.split(",") if h.strip()] hostnames = [h.strip() for h in hostnames_str.split(",") if h.strip()]
# Create new entry # Create new entry
if is_dns_entry: new_entry = HostEntry(
# DNS entry - use 0.0.0.0 as placeholder IP and set as inactive ip_address=ip_address,
new_entry = HostEntry( hostnames=hostnames,
ip_address="0.0.0.0", # Placeholder IP until DNS resolution comment=comment if comment else None,
hostnames=hostnames, is_active=is_active,
comment=comment if comment else None, )
is_active=False, # Inactive until DNS is resolved
)
# Add DNS name field
new_entry.dns_name = dns_name
# Add resolution status fields if they don't exist
if not hasattr(new_entry, 'resolved_ip'):
new_entry.resolved_ip = None
if not hasattr(new_entry, 'last_resolved'):
new_entry.last_resolved = None
if not hasattr(new_entry, 'dns_resolution_status'):
from ..core.dns import DNSResolutionStatus
new_entry.dns_resolution_status = DNSResolutionStatus.NOT_RESOLVED
else:
# IP entry
new_entry = HostEntry(
ip_address=ip_address,
hostnames=hostnames,
comment=comment if comment else None,
is_active=is_active,
)
# Close modal and return the new entry # Close modal and return the new entry
self.dismiss(new_entry) self.dismiss(new_entry)
@ -218,8 +131,6 @@ class AddEntryModal(ModalScreen):
# Display validation error # Display validation error
if "IP address" in str(e).lower(): if "IP address" in str(e).lower():
self._show_error("ip-error", str(e)) self._show_error("ip-error", str(e))
elif "DNS name" in str(e).lower():
self._show_error("dns-error", str(e))
else: else:
self._show_error("hostnames-error", str(e)) self._show_error("hostnames-error", str(e))
@ -227,41 +138,23 @@ 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, hostnames_str: str) -> bool:
""" """
Validate user input. Validate user input.
Args: Args:
ip_address: IP address to validate (for IP entries) ip_address: IP address to validate
dns_name: DNS name to validate (for DNS entries)
hostnames_str: Comma-separated hostnames to validate hostnames_str: Comma-separated hostnames to validate
is_dns_entry: Whether this is a DNS entry or IP entry
Returns: Returns:
True if input is valid, False otherwise True if input is valid, False otherwise
""" """
valid = True valid = True
# Validate IP address or DNS name based on entry type # Validate IP address
if is_dns_entry: if not ip_address:
if not dns_name: self._show_error("ip-error", "IP address is required")
self._show_error("dns-error", "DNS name is required") valid = False
valid = False
else:
# Basic DNS name validation
if (
" " in dns_name
or not dns_name.replace(".", "").replace("-", "").isalnum()
or dns_name.startswith(".")
or dns_name.endswith(".")
or ".." in dns_name
):
self._show_error("dns-error", "Invalid DNS name format")
valid = False
else:
if not ip_address:
self._show_error("ip-error", "IP address is required")
valid = False
# Validate hostnames # Validate hostnames
if not hostnames_str: if not hostnames_str:
@ -300,7 +193,7 @@ class AddEntryModal(ModalScreen):
def _clear_errors(self) -> None: def _clear_errors(self) -> None:
"""Clear all validation error messages.""" """Clear all validation error messages."""
for error_id in ["ip-error", "dns-error", "hostnames-error"]: for error_id in ["ip-error", "hostnames-error"]:
try: try:
error_widget = self.query_one(f"#{error_id}", Static) error_widget = self.query_one(f"#{error_id}", Static)
error_widget.update("") error_widget.update("")

View file

@ -7,20 +7,17 @@ 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
from textual.reactive import reactive from textual.reactive import reactive
from ..core.parser import HostsParser from ..core.parser import HostsParser
from ..core.models import HostsFile from ..core.models import HostsFile
from ..core.config import Config from ..core.config import Config
from ..core.manager import HostsManager from ..core.manager import HostsManager
from ..core.dns import DNSService
from ..core.filters import EntryFilter, FilterOptions
from .config_modal import ConfigModal from .config_modal import ConfigModal
from .password_modal import PasswordModal from .password_modal import PasswordModal
from .add_entry_modal import AddEntryModal from .add_entry_modal import AddEntryModal
from .delete_confirmation_modal import DeleteConfirmationModal from .delete_confirmation_modal import DeleteConfirmationModal
from .filter_modal import FilterModal
from .custom_footer import CustomFooter from .custom_footer import CustomFooter
from .styles import HOSTS_MANAGER_CSS from .styles import HOSTS_MANAGER_CSS
from .keybindings import HOSTS_MANAGER_BINDINGS from .keybindings import HOSTS_MANAGER_BINDINGS
@ -62,17 +59,6 @@ class HostsManagerApp(App):
self.config = Config() self.config = Config()
self.manager = HostsManager() self.manager = HostsManager()
# Initialize DNS service
dns_config = self.config.get("dns_resolution", {})
self.dns_service = DNSService(
enabled=dns_config.get("enabled", True),
timeout=dns_config.get("timeout", 5.0)
)
# Initialize filtering system
self.entry_filter = EntryFilter()
self.current_filter_options = FilterOptions()
# Initialize handlers # Initialize handlers
self.table_handler = TableHandler(self) self.table_handler = TableHandler(self)
self.details_handler = DetailsHandler(self) self.details_handler = DetailsHandler(self)
@ -128,33 +114,6 @@ class HostsManagerApp(App):
classes="default-input", classes="default-input",
) )
with Vertical(classes="default-section") as dns_name:
dns_name.border_title = "DNS Name"
yield Input(
placeholder="No DNS name",
id="details-dns-name-input",
disabled=True,
classes="default-input",
)
with Vertical(classes="default-section") as dns_status:
dns_status.border_title = "DNS Status"
yield Input(
placeholder="No DNS status",
id="details-dns-status-input",
disabled=True,
classes="default-input",
)
with Vertical(classes="default-section") as dns_resolved:
dns_resolved.border_title = "Last Resolved"
yield Input(
placeholder="Not resolved yet",
id="details-dns-resolved-input",
disabled=True,
classes="default-input",
)
with Vertical(classes="default-section") as comment: with Vertical(classes="default-section") as comment:
comment.border_title = "Comment:" comment.border_title = "Comment:"
yield Input( yield Input(
@ -175,15 +134,9 @@ 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 with Vertical(
with Vertical(classes="default-flex-section section-no-top-margin") as entry_type: classes="default-section section-no-top-margin"
entry_type.border_title = "Entry Type" ) as ip_address:
with RadioSet(id="edit-entry-type-radio", classes="default-radio-set"):
yield RadioButton("IP Address Entry", value=True, id="edit-ip-entry-radio")
yield RadioButton("DNS Name Entry", id="edit-dns-entry-radio")
# IP Address Section
with Vertical(classes="default-section", id="edit-ip-section") as ip_address:
ip_address.border_title = "IP Address" ip_address.border_title = "IP Address"
yield Input( yield Input(
placeholder="Enter IP address", placeholder="Enter IP address",
@ -191,15 +144,6 @@ class HostsManagerApp(App):
classes="default-input", classes="default-input",
) )
# DNS Name Section (initially hidden)
with Vertical(classes="default-section hidden", id="edit-dns-section") as dns_name:
dns_name.border_title = "DNS Name (to resolve)"
yield Input(
placeholder="e.g., example.com",
id="dns-name-input",
classes="default-input",
)
with Vertical(classes="default-section") as hostnames: with Vertical(classes="default-section") as hostnames:
hostnames.border_title = "Hostnames (comma-separated)" hostnames.border_title = "Hostnames (comma-separated)"
yield Input( yield Input(
@ -293,8 +237,20 @@ class HostsManagerApp(App):
mode = "Edit" if self.edit_mode else "Read-only" mode = "Edit" if self.edit_mode else "Read-only"
entry_count = len(self.hosts_file.entries) entry_count = len(self.hosts_file.entries)
active_count = len(self.hosts_file.get_active_entries()) active_count = len(self.hosts_file.get_active_entries())
status = f"{entry_count} entries ({active_count} active) | {mode}" # Add undo/redo status in edit mode
undo_redo_status = ""
if self.edit_mode:
can_undo = self.manager.can_undo()
can_redo = self.manager.can_redo()
if can_undo or can_redo:
undo_status = "Undo available" if can_undo else ""
redo_status = "Redo available" if can_redo else ""
statuses = [s for s in [undo_status, redo_status] if s]
if statuses:
undo_redo_status = f" | {', '.join(statuses)}"
status = f"{entry_count} entries ({active_count} active) | {mode}{undo_redo_status}"
footer.set_status(status) footer.set_status(status)
except Exception: except Exception:
pass # Footer not ready yet pass # Footer not ready yet
@ -387,8 +343,6 @@ class HostsManagerApp(App):
if event.input.id == "search-input": if event.input.id == "search-input":
# 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
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:
@ -402,17 +356,6 @@ class HostsManagerApp(App):
# Changes will be validated and saved when exiting edit mode # Changes will be validated and saved when exiting edit mode
pass pass
def on_radio_set_changed(self, event) -> None:
"""Handle entry type radio button changes in edit mode."""
if hasattr(event, 'radio_set') and event.radio_set.id == "edit-entry-type-radio":
pressed_radio = event.pressed
if pressed_radio and pressed_radio.id == "edit-ip-entry-radio":
# Handle switch to IP entry type
self.edit_handler.handle_entry_type_change("ip")
elif pressed_radio and pressed_radio.id == "edit-dns-entry-radio":
# Handle switch to DNS entry type
self.edit_handler.handle_entry_type_change("dns")
# Action handlers # Action handlers
def action_reload(self) -> None: def action_reload(self) -> None:
"""Reload the hosts file.""" """Reload the hosts file."""
@ -526,14 +469,13 @@ 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),
} }
self.entry_edit_mode = True self.entry_edit_mode = True
self.details_handler.update_entry_details() self.details_handler.update_entry_details()
# Focus on the IP address input field # Focus on the IP address input field
ip_input = self.query_one("#edit-entry-type-radio", RadioSet) ip_input = self.query_one("#ip-input", Input)
ip_input.focus() ip_input.focus()
self.update_status("Editing entry - Use Tab/Shift+Tab to navigate, ESC to exit") self.update_status("Editing entry - Use Tab/Shift+Tab to navigate, ESC to exit")
@ -591,14 +533,7 @@ class HostsManagerApp(App):
# 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)
self.update_status(f"{result.message} - Changes saved automatically")
# For DNS entries, trigger resolution and provide feedback
if hasattr(new_entry, 'dns_name') and new_entry.dns_name:
self.update_status(f"{result.message} - Starting DNS resolution for {new_entry.dns_name}")
# Trigger DNS resolution in background
self._resolve_new_dns_entry(new_entry)
else:
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:
@ -705,269 +640,6 @@ class HostsManagerApp(App):
else: else:
self.update_status(f"❌ Redo failed: {result.message}") self.update_status(f"❌ Redo failed: {result.message}")
def action_refresh_dns(self) -> None:
"""Manually refresh DNS resolution for all entries."""
if not self.edit_mode:
self.update_status(
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
)
return
if not self.hosts_file.entries:
self.update_status("No entries to resolve")
return
# Get entries that need DNS resolution
dns_entries = self.hosts_file.get_dns_entries()
if not dns_entries:
self.update_status("No entries with DNS names found")
return
# Remember the currently selected entry before DNS update
current_entry = None
if self.hosts_file.entries and self.selected_entry_index < len(
self.hosts_file.entries
):
current_entry = self.hosts_file.entries[self.selected_entry_index]
async def refresh_dns():
try:
# Extract DNS names (not hostnames!) from entries
dns_names = [entry.dns_name for entry in dns_entries if entry.dns_name]
if not dns_names:
self.update_status("No valid DNS names found to resolve")
return
resolved_count = 0
failed_count = 0
# Resolve each DNS name and apply results back to entries
for dns_name in dns_names:
resolution = await self.dns_service.resolve_entry_async(dns_name)
# Find the corresponding entry and update it
for entry in dns_entries:
if entry.dns_name == dns_name:
# Apply resolution results to entry fields
entry.last_resolved = resolution.resolved_at
entry.dns_resolution_status = resolution.status.value
if resolution.is_success():
# Update both resolved_ip and ip_address for the hosts file
entry.ip_address = resolution.resolved_ip
entry.resolved_ip = resolution.resolved_ip
resolved_count += 1
else:
failed_count += 1
break
# Save hosts file with updated DNS information
if resolved_count > 0 or failed_count > 0:
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
if not save_success:
self.update_status(f"❌ DNS resolution completed but save failed: {save_message}")
return
# Update the UI and restore cursor position
self.table_handler.populate_entries_table()
self.table_handler.restore_cursor_position(current_entry)
self.details_handler.update_entry_details()
# Provide detailed status message
if failed_count == 0:
self.update_status(f"✅ DNS resolution completed for {resolved_count} entries")
elif resolved_count == 0:
self.update_status(f"❌ DNS resolution failed for all {failed_count} entries")
else:
self.update_status(f"⚠️ DNS resolution: {resolved_count} succeeded, {failed_count} failed")
except Exception as e:
self.update_status(f"❌ DNS resolution failed: {e}")
# Run DNS resolution in background
self.run_worker(refresh_dns(), exclusive=False)
self.update_status("🔄 Starting DNS resolution...")
def action_update_single_dns(self) -> None:
"""Manually refresh DNS resolution for the currently selected entry."""
if not self.edit_mode:
self.update_status(
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
)
return
if not self.hosts_file.entries:
self.update_status("No entries available")
return
if self.selected_entry_index >= len(self.hosts_file.entries):
self.update_status("Invalid entry selected")
return
entry = self.hosts_file.entries[self.selected_entry_index]
# Check if the entry has a DNS name to resolve
if not hasattr(entry, 'dns_name') or not entry.dns_name:
self.update_status("❌ Selected entry has no DNS name to resolve")
return
# Remember the currently selected entry before DNS update
current_entry = entry
async def update_single_dns():
try:
dns_name = entry.dns_name
# Resolve the DNS name
resolution = await self.dns_service.resolve_entry_async(dns_name)
# Apply resolution results to entry fields
entry.last_resolved = resolution.resolved_at
entry.dns_resolution_status = resolution.status.value
if resolution.is_success():
# Update both resolved_ip and ip_address for the hosts file
entry.ip_address = resolution.resolved_ip
entry.resolved_ip = resolution.resolved_ip
# Save hosts file with updated DNS information
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
if not save_success:
self.update_status(f"❌ DNS resolution completed but save failed: {save_message}")
return
# Update the UI and restore cursor position
self.table_handler.populate_entries_table()
self.table_handler.restore_cursor_position(current_entry)
self.details_handler.update_entry_details()
self.update_status(f"✅ DNS updated: {dns_name}{resolution.resolved_ip}")
else:
# Resolution failed, save the status update
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
if save_success:
# Update the UI to show failed status and restore cursor position
self.table_handler.populate_entries_table()
self.table_handler.restore_cursor_position(current_entry)
self.details_handler.update_entry_details()
error_msg = resolution.error_message or "Unknown error"
self.update_status(f"❌ DNS resolution failed for {dns_name}: {error_msg}")
except Exception as e:
self.update_status(f"❌ DNS resolution error: {e}")
# Run DNS resolution in background
self.run_worker(update_single_dns(), exclusive=False)
self.update_status(f"🔄 Resolving DNS for {entry.dns_name}...")
def action_show_filters(self) -> None:
"""Show advanced filtering modal."""
def handle_filter_result(filter_options: FilterOptions) -> None:
if filter_options is None:
# User cancelled
self.update_status("Filtering cancelled")
return
# Apply the new filter options
self.current_filter_options = filter_options
# Update the search term from filter if it has one
if filter_options.search_term:
self.search_term = filter_options.search_term
# Update the search input to reflect the filter search term
try:
search_input = self.query_one("#search-input", Input)
search_input.value = filter_options.search_term
except Exception:
pass # Search input not ready
else:
# Clear search term if no search in filter
self.search_term = ""
try:
search_input = self.query_one("#search-input", Input)
search_input.value = ""
except Exception:
pass
# Refresh the table with new filtering
self.table_handler.populate_entries_table()
self.details_handler.update_entry_details()
# Get filter statistics for status message
counts = self.entry_filter.count_filtered_entries(self.hosts_file.entries, filter_options)
preset_info = f" (preset: {filter_options.preset_name})" if filter_options.preset_name else ""
self.update_status(f"✅ Filter applied: showing {counts['filtered']} of {counts['total']} entries{preset_info}")
# Show the filter modal with current options and entries for preview
self.push_screen(
FilterModal(
initial_options=self.current_filter_options,
entries=self.hosts_file.entries,
entry_filter=self.entry_filter
),
handle_filter_result
)
def _resolve_new_dns_entry(self, entry) -> None:
"""Trigger DNS resolution for a newly added DNS entry."""
if not hasattr(entry, 'dns_name') or not entry.dns_name:
return
async def resolve_and_activate():
try:
# Resolve the DNS name
resolution = await self.dns_service.resolve_entry_async(entry.dns_name)
if resolution.is_success():
# Find the entry in the hosts file and update it
for hosts_entry in self.hosts_file.entries:
if (hasattr(hosts_entry, 'dns_name') and
hosts_entry.dns_name == entry.dns_name and
hosts_entry.hostnames == entry.hostnames):
# Update the entry with resolved IP
hosts_entry.ip_address = resolution.resolved_ip
hosts_entry.resolved_ip = resolution.resolved_ip
hosts_entry.last_resolved = resolution.resolved_at
hosts_entry.dns_resolution_status = resolution.status.value
hosts_entry.is_active = True # Activate the entry
# Save the updated hosts file
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
if save_success:
# Update UI - use direct calls since we're in the same async context
self.table_handler.populate_entries_table()
self.details_handler.update_entry_details()
self.update_status(f"✅ DNS resolved: {entry.dns_name}{resolution.resolved_ip} (entry activated)")
else:
self.update_status(f"❌ DNS resolved but save failed: {save_message}")
break
else:
# Resolution failed, update status but keep entry inactive
for hosts_entry in self.hosts_file.entries:
if (hasattr(hosts_entry, 'dns_name') and
hosts_entry.dns_name == entry.dns_name and
hosts_entry.hostnames == entry.hostnames):
hosts_entry.dns_resolution_status = resolution.status.value
hosts_entry.last_resolved = resolution.resolved_at
break
self.update_status(f"❌ DNS resolution failed for {entry.dns_name}: {resolution.error_message or 'Unknown error'}")
except Exception as e:
self.update_status(f"❌ DNS resolution error for {entry.dns_name}: {str(e)}")
# Start the resolution in background
self.run_worker(resolve_and_activate(), exclusive=False)
async def on_shutdown(self) -> None:
"""Clean up resources when the app is shutting down."""
# No DNS service cleanup needed for manual-only resolution
pass
# Delegated methods for backward compatibility with tests # Delegated methods for backward compatibility with tests
def has_entry_changes(self) -> bool: def has_entry_changes(self) -> bool:
"""Check if the current entry has been modified from its original values.""" """Check if the current entry has been modified from its original values."""

View file

@ -99,9 +99,6 @@ class DetailsHandler:
hostname_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" hostname_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified"
comment_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" comment_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified"
# Update DNS information if present
self._update_dns_information(entry)
def update_edit_form(self) -> None: def update_edit_form(self) -> None:
"""Update the edit form with current entry values.""" """Update the edit form with current entry values."""
details_display = self.app.query_one("#entry-details-display") details_display = self.app.query_one("#entry-details-display")
@ -128,63 +125,3 @@ class DetailsHandler:
hostname_input.value = ", ".join(entry.hostnames) hostname_input.value = ", ".join(entry.hostnames)
comment_input.value = entry.comment or "" comment_input.value = entry.comment or ""
active_checkbox.value = entry.is_active active_checkbox.value = entry.is_active
# Initialize radio button state and field visibility
self.app.edit_handler.populate_edit_form_with_type_detection()
def _update_dns_information(self, entry) -> None:
"""Update DNS information display for the selected entry."""
try:
# Get the three separate DNS input fields
dns_name_input = self.app.query_one("#details-dns-name-input", Input)
dns_status_input = self.app.query_one("#details-dns-status-input", Input)
dns_resolved_input = self.app.query_one("#details-dns-resolved-input", Input)
if not entry.has_dns_name():
# Clear all DNS fields if no DNS information
dns_name_input.value = ""
dns_name_input.placeholder = "No DNS name"
dns_status_input.value = ""
dns_status_input.placeholder = "No DNS status"
dns_resolved_input.value = ""
dns_resolved_input.placeholder = "Not resolved yet"
return
# Update DNS Name field
dns_name_input.value = entry.dns_name or ""
dns_name_input.placeholder = "" if entry.dns_name else "No DNS name"
# Update DNS Status field
if entry.dns_resolution_status:
status_text = {
"not_resolved": "Not resolved",
"resolving": "Resolving...",
"resolved": "Resolved",
"failed": "Resolution failed",
"match": "IP matches DNS",
"mismatch": "IP differs from DNS"
}.get(entry.dns_resolution_status, entry.dns_resolution_status)
# Add resolved IP to status if available
if entry.resolved_ip and entry.dns_resolution_status in ["resolved", "match", "mismatch"]:
status_text += f" ({entry.resolved_ip})"
dns_status_input.value = status_text
dns_status_input.placeholder = ""
else:
dns_status_input.value = ""
dns_status_input.placeholder = "No DNS status"
# Update Last Resolved field
if entry.last_resolved:
time_str = entry.last_resolved.strftime("%H:%M:%S")
date_str = entry.last_resolved.strftime("%Y-%m-%d")
dns_resolved_input.value = f"{date_str} {time_str}"
dns_resolved_input.placeholder = ""
else:
dns_resolved_input.value = ""
dns_resolved_input.placeholder = "Not resolved yet"
except Exception:
# DNS widgets not present yet, silently ignore
pass

View file

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

View file

@ -18,127 +18,6 @@ class EditHandler:
"""Initialize the edit handler with reference to the main app.""" """Initialize the edit handler with reference to the main app."""
self.app = app self.app = app
def get_current_entry_type(self) -> str:
"""Determine if current entry is 'ip' or 'dns' type."""
if not self.app.hosts_file.entries or self.app.selected_entry_index >= len(
self.app.hosts_file.entries
):
return "ip" # Default to IP type
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
# Check if entry has a DNS name field and it's not empty
if hasattr(entry, 'dns_name') and entry.dns_name:
return "dns"
else:
return "ip"
def handle_entry_type_change(self, entry_type: str) -> None:
"""Handle radio button changes and field visibility."""
if entry_type == "ip":
# Show IP section, hide DNS section
self.update_field_visibility(show_ip=True, show_dns=False)
# Focus IP input
try:
ip_input = self.app.query_one("#ip-input", Input)
ip_input.focus()
except Exception:
pass
elif entry_type == "dns":
# Show DNS section, hide IP section
self.update_field_visibility(show_ip=False, show_dns=True)
# Populate DNS field if we have existing entry data
try:
if (self.app.entry_edit_mode and
self.app.hosts_file.entries and
self.app.selected_entry_index < len(self.app.hosts_file.entries)):
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
dns_input = self.app.query_one("#dns-name-input", Input)
# Populate with existing DNS name if available
dns_name = getattr(entry, 'dns_name', '') or ''
if dns_name and not dns_input.value: # Only populate if field is empty
dns_input.value = dns_name
# Focus DNS input
dns_input.focus()
else:
# Just focus if no data to populate
dns_input = self.app.query_one("#dns-name-input", Input)
dns_input.focus()
except Exception:
pass
def update_field_visibility(self, show_ip: bool, show_dns: bool) -> None:
"""Show/hide IP and DNS input sections based on entry type."""
try:
ip_section = self.app.query_one("#edit-ip-section")
dns_section = self.app.query_one("#edit-dns-section")
if show_ip:
ip_section.remove_class("hidden")
else:
ip_section.add_class("hidden")
if show_dns:
dns_section.remove_class("hidden")
else:
dns_section.add_class("hidden")
except Exception:
# Sections not found, ignore silently
pass
def populate_edit_form_with_type_detection(self) -> None:
"""Initialize edit form with correct radio button state and field visibility."""
if not self.app.entry_edit_mode:
return
# Use a timer to delay radio button setup to allow widgets to initialize
self.app.set_timer(0.1, self._delayed_radio_setup)
def _delayed_radio_setup(self) -> None:
"""Set up radio buttons after a small delay to ensure widgets are ready."""
if not self.app.entry_edit_mode:
return
# Determine current entry type
entry_type = self.get_current_entry_type()
try:
# Get current entry for DNS field population
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
# Get radio buttons
ip_radio = self.app.query_one("#edit-ip-entry-radio")
dns_radio = self.app.query_one("#edit-dns-entry-radio")
# Set radio button values - let RadioSet manage pressed_button automatically
if entry_type == "ip":
# Clear DNS radio first, then set IP radio
dns_radio.value = False
ip_radio.value = True
else:
# Clear IP radio first, then set DNS radio
ip_radio.value = False
dns_radio.value = True
# Update field visibility
self.handle_entry_type_change(entry_type)
# Populate DNS name field for DNS entries (after field is visible)
if entry_type == "dns":
dns_input = self.app.query_one("#dns-name-input", Input)
dns_input.value = getattr(entry, 'dns_name', '') or ''
except Exception as e:
# Debug: Show what went wrong
self.app.update_status(f"Debug: populate_edit_form error: {e}")
def has_entry_changes(self) -> bool: def has_entry_changes(self) -> bool:
"""Check if the current entry has been modified from its original values.""" """Check if the current entry has been modified from its original values."""
if not self.app.original_entry_values or not self.app.entry_edit_mode: if not self.app.original_entry_values or not self.app.entry_edit_mode:
@ -149,13 +28,6 @@ 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:
dns_input = self.app.query_one("#dns-name-input", Input)
dns_value = dns_input.value.strip()
except Exception:
dns_value = ""
current_hostnames = [ current_hostnames = [
h.strip() for h in hostname_input.value.split(",") if h.strip() h.strip() for h in hostname_input.value.split(",") if h.strip()
@ -165,7 +37,6 @@ class EditHandler:
# Compare with original values # Compare with original values
return ( return (
ip_input.value.strip() != self.app.original_entry_values["ip_address"] ip_input.value.strip() != self.app.original_entry_values["ip_address"]
or dns_value != (self.app.original_entry_values.get("dns_name") or "")
or current_hostnames != self.app.original_entry_values["hostnames"] or current_hostnames != self.app.original_entry_values["hostnames"]
or current_comment != self.app.original_entry_values["comment"] or current_comment != self.app.original_entry_values["comment"]
or active_checkbox.value != self.app.original_entry_values["is_active"] or active_checkbox.value != self.app.original_entry_values["is_active"]
@ -219,94 +90,12 @@ 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:
dns_input = self.app.query_one("#dns-name-input", Input)
dns_input.value = self.app.original_entry_values.get("dns_name") or ""
except Exception:
pass # DNS input not available
ip_input.value = self.app.original_entry_values["ip_address"] ip_input.value = self.app.original_entry_values["ip_address"]
hostname_input.value = ", ".join(self.app.original_entry_values["hostnames"]) hostname_input.value = ", ".join(self.app.original_entry_values["hostnames"])
comment_input.value = self.app.original_entry_values["comment"] or "" comment_input.value = self.app.original_entry_values["comment"] or ""
active_checkbox.value = self.app.original_entry_values["is_active"] active_checkbox.value = self.app.original_entry_values["is_active"]
# Restore radio button state and field visibility
try:
dns_name = self.app.original_entry_values.get("dns_name")
ip_radio = self.app.query_one("#edit-ip-entry-radio")
dns_radio = self.app.query_one("#edit-dns-entry-radio")
if dns_name:
# Was DNS entry - set DNS radio and show DNS field
ip_radio.value = False
dns_radio.value = True
self.handle_entry_type_change("dns")
else:
# Was IP entry - set IP radio and show IP field
dns_radio.value = False
ip_radio.value = True
self.handle_entry_type_change("ip")
except Exception:
pass # Radio widgets not available
def validate_entry_by_type(self, entry_type: str) -> bool:
"""Type-specific validation for IP or DNS entries."""
hostname_input = self.app.query_one("#hostname-input", Input)
# Validate hostname(s) - common to both types
hostnames = [h.strip() for h in hostname_input.value.split(",") if h.strip()]
if not hostnames:
self.app.update_status("❌ At least one hostname is required - changes not saved")
return False
hostname_pattern = re.compile(
r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$"
)
for hostname in hostnames:
if not hostname_pattern.match(hostname):
self.app.update_status(f"❌ Invalid hostname: {hostname} - changes not saved")
return False
if entry_type == "ip":
# Validate IP address
try:
ip_input = self.app.query_one("#ip-input", Input)
ip_address = ip_input.value.strip()
if not ip_address:
self.app.update_status("❌ IP address is required - changes not saved")
return False
ipaddress.ip_address(ip_address)
except ValueError:
self.app.update_status("❌ Invalid IP address - changes not saved")
return False
elif entry_type == "dns":
# Validate DNS name
try:
dns_input = self.app.query_one("#dns-name-input", Input)
dns_name = dns_input.value.strip()
if not dns_name:
self.app.update_status("❌ DNS name is required - changes not saved")
return False
# Basic DNS name validation
if (
" " in dns_name
or not dns_name.replace(".", "").replace("-", "").isalnum()
or dns_name.startswith(".")
or dns_name.endswith(".")
or ".." in dns_name
):
self.app.update_status("❌ Invalid DNS name format - changes not saved")
return False
except Exception:
self.app.update_status("❌ DNS name validation failed - changes not saved")
return False
return True
def validate_and_save_entry_changes(self) -> bool: def validate_and_save_entry_changes(self) -> bool:
"""Validate current entry values and save if valid.""" """Validate current entry values and save if valid."""
if not self.app.hosts_file.entries or self.app.selected_entry_index >= len( if not self.app.hosts_file.entries or self.app.selected_entry_index >= len(
@ -316,62 +105,43 @@ class EditHandler:
entry = self.app.hosts_file.entries[self.app.selected_entry_index] entry = self.app.hosts_file.entries[self.app.selected_entry_index]
# Determine current entry type based on radio selection # Get values from form fields
try: ip_input = self.app.query_one("#ip-input", Input)
radio_set = self.app.query_one("#edit-entry-type-radio")
pressed_radio = radio_set.pressed_button
if pressed_radio and pressed_radio.id == "edit-dns-entry-radio":
entry_type = "dns"
else:
entry_type = "ip"
except Exception:
# Fallback to existing entry type detection
entry_type = self.get_current_entry_type()
# Type-specific validation
if not self.validate_entry_by_type(entry_type):
return False
# Get common form values
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)
# Validate IP address
try:
ipaddress.ip_address(ip_input.value.strip())
except ValueError:
self.app.update_status("❌ Invalid IP address - changes not saved")
return False
# Validate hostname(s)
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()]
comment = comment_input.value.strip() or None if not hostnames:
is_active = active_checkbox.value self.app.update_status(
"❌ At least one hostname is required - changes not saved"
)
return False
# Update entry based on type hostname_pattern = re.compile(
if entry_type == "ip": r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$"
# IP entry - update IP address and clear DNS fields )
ip_input = self.app.query_one("#ip-input", Input)
entry.ip_address = ip_input.value.strip()
entry.dns_name = None # Clear DNS name when converting to IP
# Clear DNS-related fields
if hasattr(entry, 'resolved_ip'):
entry.resolved_ip = None
if hasattr(entry, 'last_resolved'):
entry.last_resolved = None
if hasattr(entry, 'dns_resolution_status'):
entry.dns_resolution_status = None
else:
# DNS entry - update DNS name and set placeholder IP
dns_input = self.app.query_one("#dns-name-input", Input)
entry.dns_name = dns_input.value.strip()
entry.ip_address = "0.0.0.0" # Placeholder IP for DNS entries
# Initialize DNS fields if they don't exist
if not hasattr(entry, 'resolved_ip'):
entry.resolved_ip = None
if not hasattr(entry, 'last_resolved'):
entry.last_resolved = None
if not hasattr(entry, 'dns_resolution_status'):
from ..core.dns import DNSResolutionStatus
entry.dns_resolution_status = DNSResolutionStatus.NOT_RESOLVED
# Update common fields for hostname in hostnames:
if not hostname_pattern.match(hostname):
self.app.update_status(
f"❌ Invalid hostname: {hostname} - changes not saved"
)
return False
# Update the entry
entry.ip_address = ip_input.value.strip()
entry.hostnames = hostnames entry.hostnames = hostnames
entry.comment = comment entry.comment = comment_input.value.strip() or None
entry.is_active = is_active entry.is_active = active_checkbox.value
# Save to file # Save to file
success, message = self.app.manager.save_hosts_file(self.app.hosts_file) success, message = self.app.manager.save_hosts_file(self.app.hosts_file)
@ -385,12 +155,7 @@ 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)
self.app.update_status("Entry saved successfully")
# Provide appropriate success message
if entry_type == "dns":
self.app.update_status("DNS entry saved successfully - DNS resolution can be triggered manually")
else:
self.app.update_status("Entry saved successfully")
return True return True
else: else:
self.app.update_status(f"❌ Error saving entry: {message}") self.app.update_status(f"❌ Error saving entry: {message}")
@ -401,92 +166,40 @@ class EditHandler:
if not self.app.entry_edit_mode: if not self.app.entry_edit_mode:
return return
# Get all input fields in order, including radio set and dynamic DNS field # Get all input fields in order
try: fields = [
radio_set = self.app.query_one("#edit-entry-type-radio") self.app.query_one("#ip-input", Input),
hostname_input = self.app.query_one("#hostname-input", Input) self.app.query_one("#hostname-input", Input),
comment_input = self.app.query_one("#comment-input", Input) self.app.query_one("#comment-input", Input),
active_checkbox = self.app.query_one("#active-checkbox", Checkbox) self.app.query_one("#active-checkbox", Checkbox),
]
# Build field list based on current entry type
fields = [radio_set]
# Add IP or DNS field based on visibility
try:
ip_section = self.app.query_one("#edit-ip-section")
if not ip_section.has_class("hidden"):
ip_input = self.app.query_one("#ip-input", Input)
fields.append(ip_input)
except Exception:
pass
try:
dns_section = self.app.query_one("#edit-dns-section")
if not dns_section.has_class("hidden"):
dns_input = self.app.query_one("#dns-name-input", Input)
fields.append(dns_input)
except Exception:
pass
# Add remaining fields
fields.extend([hostname_input, comment_input, active_checkbox])
# Find currently focused field and move to next # Find currently focused field and move to next
for i, field in enumerate(fields): for i, field in enumerate(fields):
if field.has_focus: if field.has_focus:
next_field = fields[(i + 1) % len(fields)] next_field = fields[(i + 1) % len(fields)]
next_field.focus() next_field.focus()
break break
except Exception:
# Fallback to original navigation if widgets not ready
pass
def navigate_to_prev_field(self) -> None: def navigate_to_prev_field(self) -> None:
"""Move to the previous field in edit mode.""" """Move to the previous field in edit mode."""
if not self.app.entry_edit_mode: if not self.app.entry_edit_mode:
return return
# Get all input fields in order, including radio set and dynamic DNS field # Get all input fields in order
try: fields = [
radio_set = self.app.query_one("#edit-entry-type-radio") self.app.query_one("#ip-input", Input),
hostname_input = self.app.query_one("#hostname-input", Input) self.app.query_one("#hostname-input", Input),
comment_input = self.app.query_one("#comment-input", Input) self.app.query_one("#comment-input", Input),
active_checkbox = self.app.query_one("#active-checkbox", Checkbox) self.app.query_one("#active-checkbox", Checkbox),
]
# Build field list based on current entry type
fields = [radio_set]
# Add IP or DNS field based on visibility
try:
ip_section = self.app.query_one("#edit-ip-section")
if not ip_section.has_class("hidden"):
ip_input = self.app.query_one("#ip-input", Input)
fields.append(ip_input)
except Exception:
pass
try:
dns_section = self.app.query_one("#edit-dns-section")
if not dns_section.has_class("hidden"):
dns_input = self.app.query_one("#dns-name-input", Input)
fields.append(dns_input)
except Exception:
pass
# Add remaining fields
fields.extend([hostname_input, comment_input, active_checkbox])
# Find currently focused field and move to previous # Find currently focused field and move to previous
for i, field in enumerate(fields): for i, field in enumerate(fields):
if field.has_focus: if field.has_focus:
prev_field = fields[(i - 1) % len(fields)] prev_field = fields[(i - 1) % len(fields)]
prev_field.focus() prev_field.focus()
break break
except Exception:
# Fallback to original navigation if widgets not ready
pass
def handle_entry_edit_key_event(self, event) -> bool: def handle_entry_edit_key_event(self, event) -> bool:
"""Handle key events for entry edit mode navigation. """Handle key events for entry edit mode navigation.

View file

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

View file

@ -36,7 +36,7 @@ HOSTS_MANAGER_BINDINGS = [
id="right:help", id="right:help",
), ),
Binding("q", "quit", "Quit", show=True, id="right:quit"), Binding("q", "quit", "Quit", show=True, id="right:quit"),
Binding("ctrl+r", "reload", "Reload hosts file", show=False), Binding("r", "reload", "Reload hosts file", show=False),
Binding("i", "sort_by_ip", "Sort by IP address", show=False), Binding("i", "sort_by_ip", "Sort by IP address", show=False),
Binding("h", "sort_by_hostname", "Sort by hostname", show=False), Binding("h", "sort_by_hostname", "Sort by hostname", show=False),
Binding("ctrl+s", "save_file", "Save hosts file", show=False), Binding("ctrl+s", "save_file", "Save hosts file", show=False),
@ -44,8 +44,6 @@ 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("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

@ -25,11 +25,6 @@ COMMON_CSS = """
border: none; border: none;
} }
.default-radio-set {
margin: 0 2;
border: none;
}
.default-section { .default-section {
border: round $primary; border: round $primary;
height: 3; height: 3;
@ -37,13 +32,6 @@ COMMON_CSS = """
margin: 1 0; margin: 1 0;
} }
.default-flex-section {
border: round $primary;
height: auto;
padding: 0;
margin: 1 0;
}
.button-row { .button-row {
margin-top: 2; margin-top: 2;
height: 3; height: 3;

View file

@ -7,9 +7,6 @@ row selection functionality.
from rich.text import Text from rich.text import Text
from textual.widgets import DataTable from textual.widgets import DataTable
from typing import List
from ..core.models import HostEntry
class TableHandler: class TableHandler:
@ -19,12 +16,11 @@ class TableHandler:
"""Initialize the table handler with reference to the main app.""" """Initialize the table handler with reference to the main app."""
self.app = app self.app = app
def get_visible_entries(self) -> List[HostEntry]: def get_visible_entries(self) -> list:
"""Get the list of entries that are visible in the table (after filtering).""" """Get the list of entries that are visible in the table (after filtering)."""
show_defaults = self.app.config.should_show_default_entries() show_defaults = self.app.config.should_show_default_entries()
all_entries = [] visible_entries = []
# First apply default entry filtering (legacy config setting)
for entry in self.app.hosts_file.entries: for entry in self.app.hosts_file.entries:
canonical_hostname = entry.hostnames[0] if entry.hostnames else "" canonical_hostname = entry.hostnames[0] if entry.hostnames else ""
# Skip default entries if configured to hide them # Skip default entries if configured to hide them
@ -32,48 +28,35 @@ class TableHandler:
entry.ip_address, canonical_hostname entry.ip_address, canonical_hostname
): ):
continue continue
all_entries.append(entry)
# Apply advanced filtering if enabled # Apply search filter if search term is provided
if hasattr(self.app, 'entry_filter') and hasattr(self.app, 'current_filter_options'): if self.app.search_term:
filtered_entries = self.app.entry_filter.apply_filters(all_entries, self.app.current_filter_options) search_term_lower = self.app.search_term.lower()
else: matches_search = False
# Fallback to legacy search filtering for backward compatibility
filtered_entries = self._apply_legacy_search_filter(all_entries)
return filtered_entries # Search in IP address
if search_term_lower in entry.ip_address.lower():
def _apply_legacy_search_filter(self, entries: List[HostEntry]) -> List[HostEntry]:
"""Apply legacy search filter for backward compatibility."""
if not hasattr(self.app, 'search_term') or not self.app.search_term:
return entries
search_term_lower = self.app.search_term.lower()
filtered_entries = []
for entry in entries:
matches_search = False
# Search in IP address
if search_term_lower in entry.ip_address.lower():
matches_search = True
# Search in hostnames
if not matches_search:
for hostname in entry.hostnames:
if search_term_lower in hostname.lower():
matches_search = True
break
# Search in comment
if not matches_search and entry.comment:
if search_term_lower in entry.comment.lower():
matches_search = True matches_search = True
if matches_search: # Search in hostnames
filtered_entries.append(entry) if not matches_search:
for hostname in entry.hostnames:
if search_term_lower in hostname.lower():
matches_search = True
break
return filtered_entries # Search in comment
if not matches_search and entry.comment:
if search_term_lower in entry.comment.lower():
matches_search = True
# Skip entry if it doesn't match search term
if not matches_search:
continue
visible_entries.append(entry)
return visible_entries
def get_first_visible_entry_index(self) -> int: def get_first_visible_entry_index(self) -> int:
"""Get the index of the first visible entry in the hosts file.""" """Get the index of the first visible entry in the hosts file."""
@ -135,7 +118,6 @@ class TableHandler:
active_label = "Active" active_label = "Active"
ip_label = "IP Address" ip_label = "IP Address"
hostname_label = "Canonical Hostname" hostname_label = "Canonical Hostname"
dns_label = "DNS"
# Add sort indicators # Add sort indicators
if self.app.sort_column == "ip": if self.app.sort_column == "ip":
@ -145,8 +127,8 @@ class TableHandler:
arrow = "" if self.app.sort_ascending else "" arrow = "" if self.app.sort_ascending else ""
hostname_label = f"{arrow} Canonical Hostname" hostname_label = f"{arrow} Canonical Hostname"
# Add columns with proper labels (Active, IP, Hostname, DNS) # Add columns with proper labels (Active column first)
table.add_columns(active_label, ip_label, hostname_label, dns_label) table.add_columns(active_label, ip_label, hostname_label)
# Get visible entries (after filtering) # Get visible entries (after filtering)
visible_entries = self.get_visible_entries() visible_entries = self.get_visible_entries()
@ -159,28 +141,25 @@ class TableHandler:
# Check if this is a default system entry # Check if this is a default system entry
is_default = entry.is_default_entry() is_default = entry.is_default_entry()
# Get DNS status indicator
dns_text = self._get_dns_status_indicator(entry)
# Add row with styling based on active status and default entry status # Add row with styling based on active status and default entry status
if is_default: if is_default:
# Default entries are always shown in dim grey regardless of active status # Default entries are always shown in dim grey regardless of active status
active_text = Text("" if entry.is_active else "", style="dim white") active_text = Text("" if entry.is_active else "", style="dim white")
ip_text = Text(entry.ip_address, style="dim white") ip_text = Text(entry.ip_address, style="dim white")
hostname_text = Text(canonical_hostname, style="dim white") hostname_text = Text(canonical_hostname, style="dim white")
table.add_row(active_text, ip_text, hostname_text, dns_text) table.add_row(active_text, ip_text, hostname_text)
elif entry.is_active: elif entry.is_active:
# Active entries in green with checkmark # Active entries in green with checkmark
active_text = Text("", style="bold green") active_text = Text("", style="bold green")
ip_text = Text(entry.ip_address, style="bold green") ip_text = Text(entry.ip_address, style="bold green")
hostname_text = Text(canonical_hostname, style="bold green") hostname_text = Text(canonical_hostname, style="bold green")
table.add_row(active_text, ip_text, hostname_text, dns_text) table.add_row(active_text, ip_text, hostname_text)
else: else:
# Inactive entries in dim yellow with italic (no checkmark) # Inactive entries in dim yellow with italic (no checkmark)
active_text = Text("", style="dim yellow italic") active_text = Text("", style="dim yellow italic")
ip_text = Text(entry.ip_address, style="dim yellow italic") ip_text = Text(entry.ip_address, style="dim yellow italic")
hostname_text = Text(canonical_hostname, style="dim yellow italic") hostname_text = Text(canonical_hostname, style="dim yellow italic")
table.add_row(active_text, ip_text, hostname_text, dns_text) table.add_row(active_text, ip_text, hostname_text)
def restore_cursor_position(self, previous_entry) -> None: def restore_cursor_position(self, previous_entry) -> None:
"""Restore cursor position after reload, maintaining selection if possible.""" """Restore cursor position after reload, maintaining selection if possible."""
@ -243,42 +222,6 @@ class TableHandler:
self.populate_entries_table() self.populate_entries_table()
self.restore_cursor_position(current_entry) self.restore_cursor_position(current_entry)
def _get_dns_status_indicator(self, entry) -> Text:
"""Get DNS name and status indicator for an entry."""
# If entry has no DNS name configured, show empty
if not entry.has_dns_name():
return Text("", style="dim white")
# Start with the DNS name
dns_display = entry.dns_name
# Add status indicator based on resolution status
dns_status = entry.dns_resolution_status or "not_resolved"
if dns_status == "not_resolved":
status_icon = ""
style = "dim yellow"
elif dns_status == "resolving":
status_icon = "🔄"
style = "yellow"
elif dns_status == "resolved":
status_icon = ""
style = "green"
elif dns_status == "match":
status_icon = ""
style = "bold green"
elif dns_status == "mismatch":
status_icon = "⚠️"
style = "red"
elif dns_status == "failed":
status_icon = ""
style = "red"
else:
status_icon = ""
style = "dim white"
return Text(f"{status_icon} {dns_display}", style=style)
def sort_entries_by_hostname(self) -> None: def sort_entries_by_hostname(self) -> None:
"""Sort entries by canonical hostname.""" """Sort entries by canonical hostname."""
if self.app.sort_column == "hostname": if self.app.sort_column == "hostname":

View file

@ -1,463 +0,0 @@
"""
Tests for the AddEntryModal with DNS name support.
This module tests the enhanced AddEntryModal functionality including
DNS name entries, validation, and mutual exclusion logic.
"""
import pytest
from unittest.mock import Mock
from textual.widgets import Input, Checkbox, RadioSet, Static
from src.hosts.tui.add_entry_modal import AddEntryModal
from src.hosts.core.models import HostEntry
class TestAddEntryModalDNSSupport:
"""Test cases for AddEntryModal DNS name support."""
def setup_method(self):
"""Set up test fixtures."""
self.modal = AddEntryModal()
def test_modal_initialization(self):
"""Test that the modal initializes correctly."""
assert isinstance(self.modal, AddEntryModal)
def test_compose_method_creates_dns_components(self):
"""Test that compose method creates DNS-related components."""
# Test that the compose method exists and can be called
# We can't test the actual widget creation without mounting the modal
# in a Textual app context, so we just verify the method exists
assert hasattr(self.modal, 'compose')
assert callable(self.modal.compose)
def test_validate_input_ip_entry_valid(self):
"""Test validation for valid IP entry."""
# Test valid IP entry
result = self.modal._validate_input(
ip_address="192.168.1.1",
dns_name="",
hostnames_str="example.com",
is_dns_entry=False
)
assert result is True
def test_validate_input_ip_entry_missing_ip(self):
"""Test validation for IP entry with missing IP address."""
# Mock the error display method
self.modal._show_error = Mock()
result = self.modal._validate_input(
ip_address="",
dns_name="",
hostnames_str="example.com",
is_dns_entry=False
)
assert result is False
self.modal._show_error.assert_called_with("ip-error", "IP address is required")
def test_validate_input_dns_entry_valid(self):
"""Test validation for valid DNS entry."""
result = self.modal._validate_input(
ip_address="",
dns_name="example.com",
hostnames_str="www.example.com",
is_dns_entry=True
)
assert result is True
def test_validate_input_dns_entry_missing_dns_name(self):
"""Test validation for DNS entry with missing DNS name."""
# Mock the error display method
self.modal._show_error = Mock()
result = self.modal._validate_input(
ip_address="",
dns_name="",
hostnames_str="example.com",
is_dns_entry=True
)
assert result is False
self.modal._show_error.assert_called_with("dns-error", "DNS name is required")
def test_validate_input_dns_entry_invalid_format(self):
"""Test validation for DNS entry with invalid DNS name format."""
# Mock the error display method
self.modal._show_error = Mock()
# Test various invalid DNS name formats
invalid_dns_names = [
"example .com", # Contains space
".example.com", # Starts with dot
"example.com.", # Ends with dot
"example..com", # Double dots
"ex@mple.com", # Invalid characters
]
for invalid_dns in invalid_dns_names:
result = self.modal._validate_input(
ip_address="",
dns_name=invalid_dns,
hostnames_str="example.com",
is_dns_entry=True
)
assert result is False
self.modal._show_error.assert_called_with("dns-error", "Invalid DNS name format")
def test_validate_input_missing_hostnames(self):
"""Test validation for entries with missing hostnames."""
# Mock the error display method
self.modal._show_error = Mock()
# Test IP entry without hostnames
result = self.modal._validate_input(
ip_address="192.168.1.1",
dns_name="",
hostnames_str="",
is_dns_entry=False
)
assert result is False
self.modal._show_error.assert_called_with("hostnames-error", "At least one hostname is required")
def test_validate_input_invalid_hostnames(self):
"""Test validation for entries with invalid hostnames."""
# Mock the error display method
self.modal._show_error = Mock()
# Test with invalid hostname containing spaces
result = self.modal._validate_input(
ip_address="192.168.1.1",
dns_name="",
hostnames_str="invalid hostname",
is_dns_entry=False
)
assert result is False
self.modal._show_error.assert_called_with("hostnames-error", "Invalid hostname format: invalid hostname")
def test_clear_errors_includes_dns_error(self):
"""Test that clear_errors method includes DNS error clearing."""
# Mock the query_one method to return mock widgets
mock_ip_error = Mock(spec=Static)
mock_dns_error = Mock(spec=Static)
mock_hostnames_error = Mock(spec=Static)
def mock_query_one(selector, widget_type):
if selector == "#ip-error":
return mock_ip_error
elif selector == "#dns-error":
return mock_dns_error
elif selector == "#hostnames-error":
return mock_hostnames_error
return Mock()
self.modal.query_one = Mock(side_effect=mock_query_one)
# Call clear_errors
self.modal._clear_errors()
# Verify all error widgets were cleared
mock_ip_error.update.assert_called_with("")
mock_dns_error.update.assert_called_with("")
mock_hostnames_error.update.assert_called_with("")
def test_show_error_displays_message(self):
"""Test that show_error method displays error messages correctly."""
# Mock the query_one method to return a mock widget
mock_error_widget = Mock(spec=Static)
self.modal.query_one = Mock(return_value=mock_error_widget)
# Test showing an error
self.modal._show_error("dns-error", "Test error message")
# Verify the error widget was updated
self.modal.query_one.assert_called_with("#dns-error", Static)
mock_error_widget.update.assert_called_with("Test error message")
def test_show_error_handles_missing_widget(self):
"""Test that show_error handles missing widgets gracefully."""
# Mock query_one to raise an exception
self.modal.query_one = Mock(side_effect=Exception("Widget not found"))
# This should not raise an exception
try:
self.modal._show_error("dns-error", "Test error message")
except Exception:
pytest.fail("_show_error should handle missing widgets gracefully")
class TestAddEntryModalRadioButtonLogic:
"""Test cases for radio button logic in AddEntryModal."""
def setup_method(self):
"""Set up test fixtures."""
self.modal = AddEntryModal()
def test_radio_button_change_to_ip_entry(self):
"""Test radio button change to IP entry mode."""
# Mock the query_one method for sections and inputs
mock_ip_section = Mock()
mock_dns_section = Mock()
mock_ip_input = Mock(spec=Input)
def mock_query_one(selector, widget_type=None):
if selector == "#ip-section":
return mock_ip_section
elif selector == "#dns-section":
return mock_dns_section
elif selector == "#ip-address-input":
return mock_ip_input
return Mock()
self.modal.query_one = Mock(side_effect=mock_query_one)
# Create mock event
mock_radio = Mock()
mock_radio.id = "ip-entry-radio"
mock_radio_set = Mock()
mock_radio_set.id = "entry-type-radio"
class MockEvent:
def __init__(self):
self.radio_set = mock_radio_set
self.pressed = mock_radio
event = MockEvent()
# Call the event handler
self.modal.on_radio_set_changed(event)
# Verify IP section is shown and DNS section is hidden
mock_ip_section.remove_class.assert_called_with("hidden")
mock_dns_section.add_class.assert_called_with("hidden")
mock_ip_input.focus.assert_called_once()
def test_radio_button_change_to_dns_entry(self):
"""Test radio button change to DNS entry mode."""
# Mock the query_one method for sections and inputs
mock_ip_section = Mock()
mock_dns_section = Mock()
mock_dns_input = Mock(spec=Input)
def mock_query_one(selector, widget_type=None):
if selector == "#ip-section":
return mock_ip_section
elif selector == "#dns-section":
return mock_dns_section
elif selector == "#dns-name-input":
return mock_dns_input
return Mock()
self.modal.query_one = Mock(side_effect=mock_query_one)
# Create mock event
mock_radio = Mock()
mock_radio.id = "dns-entry-radio"
mock_radio_set = Mock()
mock_radio_set.id = "entry-type-radio"
class MockEvent:
def __init__(self):
self.radio_set = mock_radio_set
self.pressed = mock_radio
event = MockEvent()
# Call the event handler
self.modal.on_radio_set_changed(event)
# Verify DNS section is shown and IP section is hidden
mock_ip_section.add_class.assert_called_with("hidden")
mock_dns_section.remove_class.assert_called_with("hidden")
mock_dns_input.focus.assert_called_once()
class TestAddEntryModalSaveLogic:
"""Test cases for save logic in AddEntryModal."""
def setup_method(self):
"""Set up test fixtures."""
self.modal = AddEntryModal()
def test_action_save_ip_entry_creation(self):
"""Test saving a valid IP entry."""
# Mock validation to return True (not None)
self.modal._validate_input = Mock(return_value=True)
self.modal._clear_errors = Mock()
self.modal.dismiss = Mock()
# Mock form widgets
mock_radio_set = Mock(spec=RadioSet)
mock_radio_set.pressed_button = None # IP entry mode
mock_ip_input = Mock(spec=Input)
mock_ip_input.value = "192.168.1.1"
mock_dns_input = Mock(spec=Input)
mock_dns_input.value = ""
mock_hostnames_input = Mock(spec=Input)
mock_hostnames_input.value = "example.com, www.example.com"
mock_comment_input = Mock(spec=Input)
mock_comment_input.value = "Test comment"
mock_active_checkbox = Mock(spec=Checkbox)
mock_active_checkbox.value = True
def mock_query_one(selector, widget_type):
if selector == "#entry-type-radio":
return mock_radio_set
elif selector == "#ip-address-input":
return mock_ip_input
elif selector == "#dns-name-input":
return mock_dns_input
elif selector == "#hostnames-input":
return mock_hostnames_input
elif selector == "#comment-input":
return mock_comment_input
elif selector == "#active-checkbox":
return mock_active_checkbox
return Mock()
self.modal.query_one = Mock(side_effect=mock_query_one)
# Call action_save
self.modal.action_save()
# Verify validation was called
self.modal._validate_input.assert_called_once_with(
"192.168.1.1", "", "example.com, www.example.com", None
)
# Verify modal was dismissed with a HostEntry
self.modal.dismiss.assert_called_once()
created_entry = self.modal.dismiss.call_args[0][0]
assert isinstance(created_entry, HostEntry)
assert created_entry.ip_address == "192.168.1.1"
assert created_entry.hostnames == ["example.com", "www.example.com"]
assert created_entry.comment == "Test comment"
assert created_entry.is_active is True
def test_action_save_dns_entry_creation(self):
"""Test saving a valid DNS entry."""
# Mock validation to return True
self.modal._validate_input = Mock(return_value=True)
self.modal._clear_errors = Mock()
self.modal.dismiss = Mock()
# Mock form widgets
mock_radio_button = Mock()
mock_radio_button.id = "dns-entry-radio"
mock_radio_set = Mock(spec=RadioSet)
mock_radio_set.pressed_button = mock_radio_button
mock_ip_input = Mock(spec=Input)
mock_ip_input.value = ""
mock_dns_input = Mock(spec=Input)
mock_dns_input.value = "example.com"
mock_hostnames_input = Mock(spec=Input)
mock_hostnames_input.value = "www.example.com"
mock_comment_input = Mock(spec=Input)
mock_comment_input.value = ""
mock_active_checkbox = Mock(spec=Checkbox)
mock_active_checkbox.value = True
def mock_query_one(selector, widget_type):
if selector == "#entry-type-radio":
return mock_radio_set
elif selector == "#ip-address-input":
return mock_ip_input
elif selector == "#dns-name-input":
return mock_dns_input
elif selector == "#hostnames-input":
return mock_hostnames_input
elif selector == "#comment-input":
return mock_comment_input
elif selector == "#active-checkbox":
return mock_active_checkbox
return Mock()
self.modal.query_one = Mock(side_effect=mock_query_one)
# Call action_save
self.modal.action_save()
# Verify validation was called
self.modal._validate_input.assert_called_once_with(
"", "example.com", "www.example.com", True
)
# Verify modal was dismissed with a DNS HostEntry
self.modal.dismiss.assert_called_once()
created_entry = self.modal.dismiss.call_args[0][0]
assert isinstance(created_entry, HostEntry)
assert created_entry.ip_address == "0.0.0.0" # Placeholder IP for DNS entries
assert hasattr(created_entry, 'dns_name')
assert created_entry.dns_name == "example.com"
assert created_entry.hostnames == ["www.example.com"]
assert created_entry.comment is None
assert created_entry.is_active is False # Inactive until DNS resolution
def test_action_save_validation_failure(self):
"""Test save action when validation fails."""
# Mock validation to return False
self.modal._validate_input = Mock(return_value=False)
self.modal._clear_errors = Mock()
self.modal.dismiss = Mock()
# Mock form widgets (minimal setup since validation fails)
mock_radio_set = Mock(spec=RadioSet)
mock_radio_set.pressed_button = None
def mock_query_one(selector, widget_type):
if selector == "#entry-type-radio":
return mock_radio_set
return Mock(spec=Input, value="")
self.modal.query_one = Mock(side_effect=mock_query_one)
# Call action_save
self.modal.action_save()
# Verify validation was called and modal was not dismissed
self.modal._validate_input.assert_called_once()
self.modal.dismiss.assert_not_called()
def test_action_save_exception_handling(self):
"""Test save action exception handling."""
# Mock validation to return True
self.modal._validate_input = Mock(return_value=True)
self.modal._clear_errors = Mock()
self.modal._show_error = Mock()
# Mock form widgets
mock_radio_set = Mock(spec=RadioSet)
mock_radio_set.pressed_button = None
mock_input = Mock(spec=Input)
mock_input.value = "invalid"
def mock_query_one(selector, widget_type):
if selector == "#entry-type-radio":
return mock_radio_set
return mock_input
self.modal.query_one = Mock(side_effect=mock_query_one)
# Mock HostEntry to raise ValueError
with pytest.MonkeyPatch.context() as m:
def mock_host_entry(*args, **kwargs):
raise ValueError("Invalid IP address")
m.setattr("src.hosts.tui.add_entry_modal.HostEntry", mock_host_entry)
# Call action_save
self.modal.action_save()
# Verify error was shown
self.modal._show_error.assert_called_once_with("hostnames-error", "Invalid IP address")

View file

@ -1,495 +0,0 @@
"""
Tests for DNS resolution functionality.
Tests the DNS service, hostname resolution, batch processing,
and integration with hosts entries.
"""
import pytest
import asyncio
from unittest.mock import AsyncMock, patch
from datetime import datetime, timedelta
import socket
from src.hosts.core.dns import (
DNSResolutionStatus,
DNSResolution,
DNSService,
resolve_hostname,
resolve_hostnames_batch,
compare_ips,
)
from src.hosts.core.models import HostEntry
class TestDNSResolutionStatus:
"""Test DNS resolution status enum."""
def test_status_values(self):
"""Test that all required status values are defined."""
assert DNSResolutionStatus.NOT_RESOLVED.value == "not_resolved"
assert DNSResolutionStatus.RESOLVING.value == "resolving"
assert DNSResolutionStatus.RESOLVED.value == "resolved"
assert DNSResolutionStatus.RESOLUTION_FAILED.value == "failed"
assert DNSResolutionStatus.IP_MISMATCH.value == "mismatch"
assert DNSResolutionStatus.IP_MATCH.value == "match"
class TestDNSResolution:
"""Test DNS resolution data structure."""
def test_successful_resolution(self):
"""Test creation of successful DNS resolution."""
resolved_at = datetime.now()
resolution = DNSResolution(
hostname="example.com",
resolved_ip="192.0.2.1",
status=DNSResolutionStatus.RESOLVED,
resolved_at=resolved_at,
)
assert resolution.hostname == "example.com"
assert resolution.resolved_ip == "192.0.2.1"
assert resolution.status == DNSResolutionStatus.RESOLVED
assert resolution.resolved_at == resolved_at
assert resolution.error_message is None
assert resolution.is_success() is True
def test_failed_resolution(self):
"""Test creation of failed DNS resolution."""
resolved_at = datetime.now()
resolution = DNSResolution(
hostname="nonexistent.example",
resolved_ip=None,
status=DNSResolutionStatus.RESOLUTION_FAILED,
resolved_at=resolved_at,
error_message="Name not found",
)
assert resolution.hostname == "nonexistent.example"
assert resolution.resolved_ip is None
assert resolution.status == DNSResolutionStatus.RESOLUTION_FAILED
assert resolution.error_message == "Name not found"
assert resolution.is_success() is False
def test_age_calculation(self):
"""Test age calculation for DNS resolution."""
# Resolution from 100 seconds ago
past_time = datetime.now() - timedelta(seconds=100)
resolution = DNSResolution(
hostname="example.com",
resolved_ip="192.0.2.1",
status=DNSResolutionStatus.RESOLVED,
resolved_at=past_time,
)
age = resolution.get_age_seconds()
assert 99 <= age <= 101 # Allow for small timing differences
class TestResolveHostname:
"""Test individual hostname resolution."""
@pytest.mark.asyncio
async def test_successful_resolution(self):
"""Test successful hostname resolution."""
with patch("asyncio.get_event_loop") as mock_loop:
mock_event_loop = AsyncMock()
mock_loop.return_value = mock_event_loop
# Mock successful getaddrinfo result
mock_result = [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.0.2.1", 80))
]
mock_event_loop.getaddrinfo.return_value = mock_result
with patch("asyncio.wait_for", return_value=mock_result):
resolution = await resolve_hostname("example.com")
assert resolution.hostname == "example.com"
assert resolution.resolved_ip == "192.0.2.1"
assert resolution.status == DNSResolutionStatus.RESOLVED
assert resolution.error_message is None
assert resolution.is_success() is True
@pytest.mark.asyncio
async def test_timeout_resolution(self):
"""Test hostname resolution timeout."""
with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError()):
resolution = await resolve_hostname("slow.example", timeout=1.0)
assert resolution.hostname == "slow.example"
assert resolution.resolved_ip is None
assert resolution.status == DNSResolutionStatus.RESOLUTION_FAILED
assert "Timeout after 1.0s" in resolution.error_message
assert resolution.is_success() is False
@pytest.mark.asyncio
async def test_dns_error_resolution(self):
"""Test hostname resolution with DNS error."""
with patch("asyncio.wait_for", side_effect=socket.gaierror("Name not found")):
resolution = await resolve_hostname("nonexistent.example")
assert resolution.hostname == "nonexistent.example"
assert resolution.resolved_ip is None
assert resolution.status == DNSResolutionStatus.RESOLUTION_FAILED
assert resolution.error_message == "Name not found"
assert resolution.is_success() is False
@pytest.mark.asyncio
async def test_empty_result_resolution(self):
"""Test hostname resolution with empty result."""
with patch("asyncio.get_event_loop") as mock_loop:
mock_event_loop = AsyncMock()
mock_loop.return_value = mock_event_loop
with patch("asyncio.wait_for", return_value=[]):
resolution = await resolve_hostname("empty.example")
assert resolution.hostname == "empty.example"
assert resolution.resolved_ip is None
assert resolution.status == DNSResolutionStatus.RESOLUTION_FAILED
assert resolution.error_message == "No address found"
assert resolution.is_success() is False
class TestResolveHostnamesBatch:
"""Test batch hostname resolution."""
@pytest.mark.asyncio
async def test_successful_batch_resolution(self):
"""Test successful batch hostname resolution."""
hostnames = ["example.com", "test.example"]
with patch("src.hosts.core.dns.resolve_hostname") as mock_resolve:
# Mock successful resolutions
mock_resolve.side_effect = [
DNSResolution(
hostname="example.com",
resolved_ip="192.0.2.1",
status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime.now(),
),
DNSResolution(
hostname="test.example",
resolved_ip="192.0.2.2",
status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime.now(),
),
]
resolutions = await resolve_hostnames_batch(hostnames)
assert len(resolutions) == 2
assert resolutions[0].hostname == "example.com"
assert resolutions[0].resolved_ip == "192.0.2.1"
assert resolutions[1].hostname == "test.example"
assert resolutions[1].resolved_ip == "192.0.2.2"
@pytest.mark.asyncio
async def test_mixed_batch_resolution(self):
"""Test batch resolution with mixed success/failure."""
hostnames = ["example.com", "nonexistent.example"]
with patch("src.hosts.core.dns.resolve_hostname") as mock_resolve:
# Mock mixed results
mock_resolve.side_effect = [
DNSResolution(
hostname="example.com",
resolved_ip="192.0.2.1",
status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime.now(),
),
DNSResolution(
hostname="nonexistent.example",
resolved_ip=None,
status=DNSResolutionStatus.RESOLUTION_FAILED,
resolved_at=datetime.now(),
error_message="Name not found",
),
]
resolutions = await resolve_hostnames_batch(hostnames)
assert len(resolutions) == 2
assert resolutions[0].is_success() is True
assert resolutions[1].is_success() is False
@pytest.mark.asyncio
async def test_empty_batch_resolution(self):
"""Test batch resolution with empty list."""
resolutions = await resolve_hostnames_batch([])
assert resolutions == []
@pytest.mark.asyncio
async def test_exception_handling_batch(self):
"""Test batch resolution with exceptions."""
hostnames = ["example.com", "error.example"]
# Create a mock that returns the expected results
async def mock_gather(*tasks, return_exceptions=True):
return [
DNSResolution(
hostname="example.com",
resolved_ip="192.0.2.1",
status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime.now(),
),
Exception("Network error"),
]
with patch("asyncio.gather", side_effect=mock_gather):
resolutions = await resolve_hostnames_batch(hostnames)
assert len(resolutions) == 2
assert resolutions[0].is_success() is True
assert resolutions[1].hostname == "error.example"
assert resolutions[1].is_success() is False
assert "Network error" in resolutions[1].error_message
class TestDNSService:
"""Test DNS service functionality."""
def test_initialization(self):
"""Test DNS service initialization."""
service = DNSService(enabled=True, timeout=10.0)
assert service.enabled is True
assert service.timeout == 10.0
def test_initialization_defaults(self):
"""Test DNS service initialization with defaults."""
service = DNSService()
assert service.enabled is True
assert service.timeout == 5.0
@pytest.mark.asyncio
async def test_resolve_entry_async_enabled(self):
"""Test async resolution when service is enabled."""
service = DNSService(enabled=True)
with patch("src.hosts.core.dns.resolve_hostname") as mock_resolve:
mock_resolution = DNSResolution(
hostname="example.com",
resolved_ip="192.0.2.1",
status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime.now(),
)
mock_resolve.return_value = mock_resolution
resolution = await service.resolve_entry_async("example.com")
assert resolution is mock_resolution
mock_resolve.assert_called_once_with("example.com", 5.0)
@pytest.mark.asyncio
async def test_resolve_entry_async_disabled(self):
"""Test async resolution when service is disabled."""
service = DNSService(enabled=False)
resolution = await service.resolve_entry_async("example.com")
assert resolution.hostname == "example.com"
assert resolution.resolved_ip is None
assert resolution.status == DNSResolutionStatus.NOT_RESOLVED
assert resolution.error_message == "DNS resolution is disabled"
@pytest.mark.asyncio
async def test_refresh_entry(self):
"""Test manual entry refresh."""
service = DNSService(enabled=True)
with patch("src.hosts.core.dns.resolve_hostname") as mock_resolve:
mock_resolution = DNSResolution(
hostname="example.com",
resolved_ip="192.0.2.1",
status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime.now(),
)
mock_resolve.return_value = mock_resolution
result = await service.refresh_entry("example.com")
assert result is mock_resolution
mock_resolve.assert_called_once_with("example.com", 5.0)
@pytest.mark.asyncio
async def test_refresh_all_entries_enabled(self):
"""Test manual refresh of all entries when enabled."""
service = DNSService(enabled=True)
hostnames = ["example.com", "test.example"]
with patch("src.hosts.core.dns.resolve_hostnames_batch") as mock_batch:
mock_resolutions = [
DNSResolution(
hostname="example.com",
resolved_ip="192.0.2.1",
status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime.now(),
),
DNSResolution(
hostname="test.example",
resolved_ip="192.0.2.2",
status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime.now(),
),
]
mock_batch.return_value = mock_resolutions
results = await service.refresh_all_entries(hostnames)
assert results == mock_resolutions
mock_batch.assert_called_once_with(hostnames, 5.0)
@pytest.mark.asyncio
async def test_refresh_all_entries_disabled(self):
"""Test manual refresh of all entries when disabled."""
service = DNSService(enabled=False)
hostnames = ["example.com", "test.example"]
results = await service.refresh_all_entries(hostnames)
assert len(results) == 2
for i, result in enumerate(results):
assert result.hostname == hostnames[i]
assert result.resolved_ip is None
assert result.status == DNSResolutionStatus.NOT_RESOLVED
assert result.error_message == "DNS resolution is disabled"
class TestHostEntryDNSIntegration:
"""Test DNS integration with HostEntry."""
def test_has_dns_name(self):
"""Test DNS name detection."""
# Entry without DNS name
entry1 = HostEntry(
ip_address="192.0.2.1",
hostnames=["example.com"],
)
assert entry1.has_dns_name() is False
# Entry with DNS name
entry2 = HostEntry(
ip_address="192.0.2.1",
hostnames=["example.com"],
dns_name="dynamic.example.com",
)
assert entry2.has_dns_name() is True
# Entry with empty DNS name
entry3 = HostEntry(
ip_address="192.0.2.1",
hostnames=["example.com"],
dns_name="",
)
assert entry3.has_dns_name() is False
def test_needs_dns_resolution(self):
"""Test DNS resolution need detection."""
# Entry without DNS name
entry1 = HostEntry(
ip_address="192.0.2.1",
hostnames=["example.com"],
)
assert entry1.needs_dns_resolution() is False
# Entry with DNS name, not resolved
entry2 = HostEntry(
ip_address="192.0.2.1",
hostnames=["example.com"],
dns_name="dynamic.example.com",
)
assert entry2.needs_dns_resolution() is True
# Entry with DNS name, already resolved
entry3 = HostEntry(
ip_address="192.0.2.1",
hostnames=["example.com"],
dns_name="dynamic.example.com",
dns_resolution_status="resolved",
)
assert entry3.needs_dns_resolution() is False
def test_is_dns_resolution_stale(self):
"""Test stale DNS resolution detection."""
# Entry without last_resolved
entry1 = HostEntry(
ip_address="192.0.2.1",
hostnames=["example.com"],
dns_name="dynamic.example.com",
)
assert entry1.is_dns_resolution_stale() is True
# Entry with recent resolution
entry2 = HostEntry(
ip_address="192.0.2.1",
hostnames=["example.com"],
dns_name="dynamic.example.com",
last_resolved=datetime.now(),
)
assert entry2.is_dns_resolution_stale(max_age_seconds=300) is False
# Entry with old resolution
entry3 = HostEntry(
ip_address="192.0.2.1",
hostnames=["example.com"],
dns_name="dynamic.example.com",
last_resolved=datetime.now() - timedelta(minutes=10),
)
assert entry3.is_dns_resolution_stale(max_age_seconds=300) is True
def test_get_display_ip(self):
"""Test display IP selection."""
# Entry without DNS name
entry1 = HostEntry(
ip_address="192.0.2.1",
hostnames=["example.com"],
)
assert entry1.get_display_ip() == "192.0.2.1"
# Entry with DNS name but no resolved IP
entry2 = HostEntry(
ip_address="192.0.2.1",
hostnames=["example.com"],
dns_name="dynamic.example.com",
)
assert entry2.get_display_ip() == "192.0.2.1"
# Entry with DNS name and resolved IP
entry3 = HostEntry(
ip_address="192.0.2.1",
hostnames=["example.com"],
dns_name="dynamic.example.com",
resolved_ip="192.0.2.2",
)
assert entry3.get_display_ip() == "192.0.2.2"
class TestCompareIPs:
"""Test IP comparison functionality."""
def test_matching_ips(self):
"""Test IP comparison with matching addresses."""
result = compare_ips("192.0.2.1", "192.0.2.1")
assert result == DNSResolutionStatus.IP_MATCH
def test_mismatching_ips(self):
"""Test IP comparison with different addresses."""
result = compare_ips("192.0.2.1", "192.0.2.2")
assert result == DNSResolutionStatus.IP_MISMATCH
def test_ipv6_comparison(self):
"""Test IPv6 address comparison."""
result1 = compare_ips("2001:db8::1", "2001:db8::1")
assert result1 == DNSResolutionStatus.IP_MATCH
result2 = compare_ips("2001:db8::1", "2001:db8::2")
assert result2 == DNSResolutionStatus.IP_MISMATCH
def test_mixed_ip_versions(self):
"""Test comparison between IPv4 and IPv6."""
result = compare_ips("192.0.2.1", "2001:db8::1")
assert result == DNSResolutionStatus.IP_MISMATCH

View file

@ -1,427 +0,0 @@
"""
Tests for the filtering system.
This module contains comprehensive tests for the EntryFilter class
and filtering functionality.
"""
import pytest
from datetime import datetime, timedelta
from src.hosts.core.filters import EntryFilter, FilterOptions
from src.hosts.core.models import HostEntry
class TestFilterOptions:
"""Test FilterOptions dataclass."""
def test_default_values(self):
"""Test default FilterOptions values."""
options = FilterOptions()
assert options.show_active is True
assert options.show_inactive is True
assert options.active_only is False
assert options.inactive_only is False
assert options.show_dns_entries is True
assert options.show_ip_entries is True
assert options.dns_only is False
assert options.ip_only is False
assert options.show_resolved is True
assert options.show_unresolved is True
assert options.show_resolving is True
assert options.show_failed is True
assert options.show_mismatched is True
assert options.mismatch_only is False
assert options.resolved_only is False
assert options.search_term is None
assert options.preset_name is None
def test_custom_values(self):
"""Test FilterOptions with custom values."""
options = FilterOptions(
active_only=True,
dns_only=True,
search_term="test",
preset_name="Active DNS Only"
)
assert options.active_only is True
assert options.dns_only is True
assert options.search_term == "test"
assert options.preset_name == "Active DNS Only"
def test_to_dict(self):
"""Test converting FilterOptions to dictionary."""
options = FilterOptions(
active_only=True,
search_term="test",
preset_name="Test Preset"
)
result = options.to_dict()
expected = {
'show_active': True,
'show_inactive': True,
'active_only': True,
'inactive_only': False,
'show_dns_entries': True,
'show_ip_entries': True,
'dns_only': False,
'ip_only': False,
'show_resolved': True,
'show_unresolved': True,
'show_resolving': True,
'show_failed': True,
'show_mismatched': True,
'mismatch_only': False,
'resolved_only': False,
'search_term': 'test',
'search_in_hostnames': True,
'search_in_comments': True,
'search_in_ips': True,
'case_sensitive': False,
'preset_name': 'Test Preset'
}
assert result == expected
def test_from_dict(self):
"""Test creating FilterOptions from dictionary."""
data = {
'active_only': True,
'dns_only': True,
'search_term': 'test',
'preset_name': 'Test Preset'
}
options = FilterOptions.from_dict(data)
assert options.active_only is True
assert options.dns_only is True
assert options.search_term == 'test'
assert options.preset_name == 'Test Preset'
# Verify missing keys use defaults
assert options.inactive_only is False
def test_from_dict_partial(self):
"""Test creating FilterOptions from partial dictionary."""
data = {'active_only': True}
options = FilterOptions.from_dict(data)
assert options.active_only is True
assert options.inactive_only is False # Default value
assert options.search_term is None # Default value
def test_is_empty(self):
"""Test checking if filter options are empty."""
# Default options should be empty
options = FilterOptions()
assert options.is_empty() is True
# Options with search term should not be empty
options = FilterOptions(search_term="test")
assert options.is_empty() is False
# Options with any filter enabled should not be empty
options = FilterOptions(active_only=True)
assert options.is_empty() is False
class TestEntryFilter:
"""Test EntryFilter class."""
@pytest.fixture
def sample_entries(self):
"""Create sample entries for testing."""
entries = []
# Active IP entry
entry1 = HostEntry("192.168.1.1", ["example.com"], "Test entry", True)
entries.append(entry1)
# Inactive IP entry
entry2 = HostEntry("192.168.1.2", ["inactive.com"], "Inactive entry", False)
entries.append(entry2)
# Active DNS entry - create with temporary IP then convert to DNS entry
entry3 = HostEntry("1.1.1.1", ["dns-only.com"], "DNS only entry", True)
entry3.ip_address = "" # Remove IP after creation
entry3.dns_name = "dns-only.com" # Set DNS name
entries.append(entry3)
# Inactive DNS entry - create with temporary IP then convert to DNS entry
entry4 = HostEntry("1.1.1.1", ["inactive-dns.com"], "Inactive DNS entry", False)
entry4.ip_address = "" # Remove IP after creation
entry4.dns_name = "inactive-dns.com" # Set DNS name
entries.append(entry4)
# Entry with DNS resolution data
entry5 = HostEntry("10.0.0.1", ["resolved.com"], "Resolved entry", True)
entry5.resolved_ip = "10.0.0.1"
entry5.last_resolved = datetime.now()
entry5.dns_resolution_status = "IP_MATCH"
entries.append(entry5)
# Entry with mismatched DNS
entry6 = HostEntry("10.0.0.2", ["mismatch.com"], "Mismatch entry", True)
entry6.resolved_ip = "10.0.0.3" # Different from IP address
entry6.last_resolved = datetime.now()
entry6.dns_resolution_status = "IP_MISMATCH"
entries.append(entry6)
# Entry without DNS resolution
entry7 = HostEntry("10.0.0.4", ["unresolved.com"], "Unresolved entry", True)
entries.append(entry7)
return entries
@pytest.fixture
def entry_filter(self):
"""Create EntryFilter instance."""
return EntryFilter()
def test_apply_filters_no_filters(self, entry_filter, sample_entries):
"""Test applying empty filters returns all entries."""
options = FilterOptions()
result = entry_filter.apply_filters(sample_entries, options)
assert len(result) == len(sample_entries)
assert result == sample_entries
def test_filter_by_status_active_only(self, entry_filter, sample_entries):
"""Test filtering by active status only."""
options = FilterOptions(active_only=True)
result = entry_filter.filter_by_status(sample_entries, options)
active_entries = [e for e in result if e.is_active]
assert len(active_entries) == len(result)
assert all(entry.is_active for entry in result)
def test_filter_by_status_inactive_only(self, entry_filter, sample_entries):
"""Test filtering by inactive status only."""
options = FilterOptions(inactive_only=True)
result = entry_filter.filter_by_status(sample_entries, options)
assert all(not entry.is_active for entry in result)
assert len(result) == 2 # entry2 and entry4
def test_filter_by_dns_type_dns_only(self, entry_filter, sample_entries):
"""Test filtering by DNS entries only."""
options = FilterOptions(dns_only=True)
result = entry_filter.filter_by_dns_type(sample_entries, options)
assert all(entry.dns_name is not None for entry in result)
assert len(result) == 2 # entry3 and entry4
def test_filter_by_dns_type_ip_only(self, entry_filter, sample_entries):
"""Test filtering by IP entries only."""
options = FilterOptions(ip_only=True)
result = entry_filter.filter_by_dns_type(sample_entries, options)
assert all(not entry.has_dns_name() for entry in result)
# Should exclude DNS-only entries (entry3, entry4)
expected_count = len(sample_entries) - 2
assert len(result) == expected_count
def test_filter_by_resolution_status_resolved(self, entry_filter, sample_entries):
"""Test filtering by resolved entries only."""
options = FilterOptions(resolved_only=True)
result = entry_filter.filter_by_resolution_status(sample_entries, options)
assert all(entry.dns_resolution_status in ["IP_MATCH", "RESOLVED"] for entry in result)
assert len(result) == 1 # Only entry5 has resolved status
def test_filter_by_resolution_status_unresolved(self, entry_filter, sample_entries):
"""Test filtering by unresolved entries only."""
options = FilterOptions(
show_resolved=False,
show_resolving=False,
show_failed=False,
show_mismatched=False
)
result = entry_filter.filter_by_resolution_status(sample_entries, options)
assert all(entry.dns_resolution_status in [None, "NOT_RESOLVED"] for entry in result)
assert len(result) == 5 # All except entry5 and entry6
def test_filter_by_resolution_status_mismatch(self, entry_filter, sample_entries):
"""Test filtering by DNS mismatch entries only."""
options = FilterOptions(mismatch_only=True)
result = entry_filter.filter_by_resolution_status(sample_entries, options)
# Should only return entry6 (mismatch between IP and resolved_ip)
assert len(result) == 1
assert result[0].hostnames[0] == "mismatch.com"
def test_filter_by_search_hostname(self, entry_filter, sample_entries):
"""Test filtering by search term in hostname."""
options = FilterOptions(search_term="example")
result = entry_filter.filter_by_search(sample_entries, options)
assert len(result) == 1
assert result[0].hostnames[0] == "example.com"
def test_filter_by_search_ip(self, entry_filter, sample_entries):
"""Test filtering by search term in IP address."""
options = FilterOptions(search_term="192.168")
result = entry_filter.filter_by_search(sample_entries, options)
assert len(result) == 2 # entry1 and entry2
def test_filter_by_search_comment(self, entry_filter, sample_entries):
"""Test filtering by search term in comment."""
options = FilterOptions(search_term="DNS only")
result = entry_filter.filter_by_search(sample_entries, options)
assert len(result) == 1
assert result[0].comment == "DNS only entry"
def test_filter_by_search_case_insensitive(self, entry_filter, sample_entries):
"""Test search is case insensitive."""
options = FilterOptions(search_term="EXAMPLE")
result = entry_filter.filter_by_search(sample_entries, options)
assert len(result) == 1
assert result[0].hostnames[0] == "example.com"
def test_combined_filters(self, entry_filter, sample_entries):
"""Test applying multiple filters together."""
# Filter for active DNS entries containing "dns"
options = FilterOptions(
active_only=True,
dns_only=True,
search_term="dns"
)
result = entry_filter.apply_filters(sample_entries, options)
# Should only return entry3 (active DNS entry with "dns" in hostname)
assert len(result) == 1
assert result[0].hostnames[0] == "dns-only.com"
assert result[0].is_active
assert result[0].dns_name is not None
def test_count_filtered_entries(self, entry_filter, sample_entries):
"""Test counting filtered entries."""
options = FilterOptions(active_only=True)
counts = entry_filter.count_filtered_entries(sample_entries, options)
assert counts['total'] == len(sample_entries)
assert counts['filtered'] == 5 # 5 active entries
def test_get_default_presets(self, entry_filter):
"""Test getting default filter presets."""
presets = entry_filter.get_default_presets()
# Check that default presets exist
assert "All Entries" in presets
assert "Active Only" in presets
assert "Inactive Only" in presets
assert "DNS Entries Only" in presets
assert "IP Entries Only" in presets
assert "DNS Mismatches" in presets
assert "Resolved Entries" in presets
assert "Unresolved Entries" in presets
# Check that presets have correct structure
for preset_name, options in presets.items():
assert isinstance(options, FilterOptions)
def test_save_and_load_preset(self, entry_filter):
"""Test saving and loading custom presets."""
# Create custom filter options
custom_options = FilterOptions(
active_only=True,
search_term="test",
preset_name="My Custom Filter"
)
# Save preset
entry_filter.save_preset("My Custom Filter", custom_options)
# Check it was saved
presets = entry_filter.get_saved_presets()
assert "My Custom Filter" in presets
# Load and verify
loaded_options = presets["My Custom Filter"]
assert loaded_options.active_only is True
# Note: search_term is not saved in presets
assert loaded_options.search_term is None
def test_delete_preset(self, entry_filter):
"""Test deleting custom presets."""
# Save a preset first
custom_options = FilterOptions(active_only=True)
entry_filter.save_preset("To Delete", custom_options)
# Verify it exists
presets = entry_filter.get_saved_presets()
assert "To Delete" in presets
# Delete it
result = entry_filter.delete_preset("To Delete")
assert result is True
# Verify it's gone
presets = entry_filter.get_saved_presets()
assert "To Delete" not in presets
# Try to delete non-existent preset
result = entry_filter.delete_preset("Non Existent")
assert result is False
def test_filter_edge_cases(self, entry_filter):
"""Test filtering with edge cases."""
# Empty entry list
empty_options = FilterOptions()
result = entry_filter.apply_filters([], empty_options)
assert result == []
# None entries in list - filtering should handle None values gracefully
entries_with_none = [None, HostEntry("192.168.1.1", ["test.com"], "", True)]
# Filter out None values before applying filters
valid_entries = [e for e in entries_with_none if e is not None]
result = entry_filter.apply_filters(valid_entries, empty_options)
assert len(result) == 1 # Only the valid entry
assert result[0].ip_address == "192.168.1.1"
def test_search_multiple_hostnames(self, entry_filter):
"""Test search across multiple hostnames in single entry."""
# Create entry with multiple hostnames
entry = HostEntry("192.168.1.1", ["primary.com", "secondary.com", "alias.org"], "Multi-hostname entry", True)
entries = [entry]
# Search for each hostname
for hostname in ["primary", "secondary", "alias"]:
options = FilterOptions(search_term=hostname)
result = entry_filter.filter_by_search(entries, options)
assert len(result) == 1
assert result[0] == entry
def test_dns_resolution_age_filtering(self, entry_filter, sample_entries):
"""Test filtering based on DNS resolution age."""
# Modify sample entries to have different resolution times
old_time = datetime.now() - timedelta(days=1)
recent_time = datetime.now() - timedelta(minutes=5)
# Make one entry have old resolution
for entry in sample_entries:
if entry.resolved_ip:
if entry.hostnames[0] == "resolved.com":
entry.last_resolved = recent_time
else:
entry.last_resolved = old_time
# Test that entries are still found regardless of age
# (Age filtering might be added in future versions)
options = FilterOptions(resolved_only=True)
result = entry_filter.filter_by_resolution_status(sample_entries, options)
assert len(result) == 1 # Only entry5 has resolved status
def test_preset_name_preservation(self, entry_filter):
"""Test that preset names are preserved in FilterOptions."""
preset_options = FilterOptions(
active_only=True,
preset_name="Active Only"
)
# Apply filters and check preset name is preserved
sample_entry = HostEntry("192.168.1.1", ["test.com"], "Test", True)
result = entry_filter.apply_filters([sample_entry], preset_options)
# The original preset name should be accessible
assert preset_options.preset_name == "Active Only"

View file

@ -1,545 +0,0 @@
"""
Tests for the import/export functionality.
This module contains comprehensive tests for the ImportExportService class
and all supported file formats.
"""
import pytest
import json
import csv
import tempfile
from pathlib import Path
from datetime import datetime
from src.hosts.core.import_export import (
ImportExportService, ImportResult, ExportFormat, ImportFormat
)
from src.hosts.core.models import HostEntry, HostsFile
class TestImportExportService:
"""Test ImportExportService class."""
@pytest.fixture
def service(self):
"""Create ImportExportService instance."""
return ImportExportService()
@pytest.fixture
def sample_hosts_file(self):
"""Create sample HostsFile for testing."""
entries = [
HostEntry("127.0.0.1", ["localhost"], "Local host", True),
HostEntry("192.168.1.1", ["router.local"], "Home router", True),
HostEntry("1.1.1.1", ["dns-only.com"], "DNS only entry", False), # Temp IP
HostEntry("10.0.0.1", ["test.example.com"], "Test server", True)
]
# Convert to DNS entry and set DNS data for some entries
entries[2].ip_address = "" # Remove IP after creation
entries[2].dns_name = "dns-only.com"
entries[3].resolved_ip = "10.0.0.1"
entries[3].last_resolved = datetime(2024, 1, 15, 12, 0, 0)
entries[3].dns_resolution_status = "IP_MATCH"
hosts_file = HostsFile()
hosts_file.entries = entries
return hosts_file
@pytest.fixture
def temp_dir(self):
"""Create temporary directory for test files."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
def test_service_initialization(self, service):
"""Test service initialization."""
assert len(service.supported_export_formats) == 3
assert len(service.supported_import_formats) == 3
assert ExportFormat.HOSTS in service.supported_export_formats
assert ExportFormat.JSON in service.supported_export_formats
assert ExportFormat.CSV in service.supported_export_formats
def test_get_supported_formats(self, service):
"""Test getting supported formats."""
export_formats = service.get_supported_export_formats()
import_formats = service.get_supported_import_formats()
assert len(export_formats) == 3
assert len(import_formats) == 3
assert ExportFormat.HOSTS in export_formats
assert ImportFormat.JSON in import_formats
# Export Tests
def test_export_hosts_format(self, service, sample_hosts_file, temp_dir):
"""Test exporting to hosts format."""
export_path = temp_dir / "test_hosts.txt"
result = service.export_hosts_format(sample_hosts_file, export_path)
assert result.success is True
assert result.entries_exported == 4
assert len(result.errors) == 0
assert result.format == ExportFormat.HOSTS
assert export_path.exists()
# Verify content
content = export_path.read_text()
assert "127.0.0.1" in content
assert "localhost" in content
assert "router.local" in content
def test_export_json_format(self, service, sample_hosts_file, temp_dir):
"""Test exporting to JSON format."""
export_path = temp_dir / "test_export.json"
result = service.export_json_format(sample_hosts_file, export_path)
assert result.success is True
assert result.entries_exported == 4
assert len(result.errors) == 0
assert result.format == ExportFormat.JSON
assert export_path.exists()
# Verify JSON structure
with open(export_path, 'r') as f:
data = json.load(f)
assert "metadata" in data
assert "entries" in data
assert data["metadata"]["total_entries"] == 4
assert len(data["entries"]) == 4
# Check first entry
first_entry = data["entries"][0]
assert first_entry["ip_address"] == "127.0.0.1"
assert first_entry["hostnames"] == ["localhost"]
assert first_entry["is_active"] is True
# Check DNS entry
dns_entry = next((e for e in data["entries"] if e.get("dns_name")), None)
assert dns_entry is not None
assert dns_entry["dns_name"] == "dns-only.com"
def test_export_csv_format(self, service, sample_hosts_file, temp_dir):
"""Test exporting to CSV format."""
export_path = temp_dir / "test_export.csv"
result = service.export_csv_format(sample_hosts_file, export_path)
assert result.success is True
assert result.entries_exported == 4
assert len(result.errors) == 0
assert result.format == ExportFormat.CSV
assert export_path.exists()
# Verify CSV structure
with open(export_path, 'r') as f:
reader = csv.DictReader(f)
rows = list(reader)
assert len(rows) == 4
# Check header
expected_fields = [
'ip_address', 'hostnames', 'comment', 'is_active',
'dns_name', 'resolved_ip', 'last_resolved', 'dns_resolution_status'
]
assert reader.fieldnames == expected_fields
# Check first row
first_row = rows[0]
assert first_row["ip_address"] == "127.0.0.1"
assert first_row["hostnames"] == "localhost"
assert first_row["is_active"] == "True"
def test_export_invalid_path(self, service, sample_hosts_file):
"""Test export with invalid path."""
invalid_path = Path("/invalid/path/test.json")
result = service.export_json_format(sample_hosts_file, invalid_path)
assert result.success is False
assert result.entries_exported == 0
assert len(result.errors) > 0
assert "Failed to export JSON format" in result.errors[0]
# Import Tests
def test_import_hosts_format(self, service, temp_dir):
"""Test importing from hosts format."""
# Create test hosts file
hosts_content = """# Test hosts file
127.0.0.1 localhost
192.168.1.1 router.local # Home router
# 10.0.0.1 disabled.com # Disabled entry
"""
hosts_path = temp_dir / "test_hosts.txt"
hosts_path.write_text(hosts_content)
result = service.import_hosts_format(hosts_path)
assert result.success is True
assert result.total_processed >= 2
assert result.successfully_imported >= 2
assert len(result.errors) == 0
# Check imported entries
assert len(result.entries) >= 2
localhost_entry = next((e for e in result.entries if "localhost" in e.hostnames), None)
assert localhost_entry is not None
assert localhost_entry.ip_address == "127.0.0.1"
assert localhost_entry.is_active is True
def test_import_json_format(self, service, temp_dir):
"""Test importing from JSON format."""
# Create test JSON file
json_data = {
"metadata": {
"exported_at": "2024-01-15T12:00:00",
"total_entries": 3,
"version": "1.0"
},
"entries": [
{
"ip_address": "127.0.0.1",
"hostnames": ["localhost"],
"comment": "Local host",
"is_active": True
},
{
"ip_address": "",
"hostnames": ["dns-only.com"],
"comment": "DNS only",
"is_active": False,
"dns_name": "dns-only.com"
},
{
"ip_address": "10.0.0.1",
"hostnames": ["test.com"],
"comment": "Test",
"is_active": True,
"resolved_ip": "10.0.0.1",
"last_resolved": "2024-01-15T12:00:00",
"dns_resolution_status": "IP_MATCH"
}
]
}
json_path = temp_dir / "test_import.json"
with open(json_path, 'w') as f:
json.dump(json_data, f)
result = service.import_json_format(json_path)
assert result.success is True
assert result.total_processed == 3
assert result.successfully_imported == 3
assert len(result.errors) == 0
assert len(result.entries) == 3
# Check DNS entry
dns_entry = next((e for e in result.entries if e.dns_name), None)
assert dns_entry is not None
assert dns_entry.dns_name == "dns-only.com"
assert dns_entry.ip_address == ""
# Check resolved entry
resolved_entry = next((e for e in result.entries if e.resolved_ip), None)
assert resolved_entry is not None
assert resolved_entry.resolved_ip == "10.0.0.1"
assert resolved_entry.dns_resolution_status == "IP_MATCH"
def test_import_csv_format(self, service, temp_dir):
"""Test importing from CSV format."""
# Create test CSV file
csv_path = temp_dir / "test_import.csv"
with open(csv_path, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow([
'ip_address', 'hostnames', 'comment', 'is_active',
'dns_name', 'resolved_ip', 'last_resolved', 'dns_resolution_status'
])
writer.writerow([
'127.0.0.1', 'localhost', 'Local host', 'true',
'', '', '', ''
])
writer.writerow([
'', 'dns-only.com', 'DNS only', 'false',
'dns-only.com', '', '', ''
])
writer.writerow([
'10.0.0.1', 'test.com example.com', 'Test server', 'true',
'', '10.0.0.1', '2024-01-15T12:00:00', 'IP_MATCH'
])
result = service.import_csv_format(csv_path)
assert result.success is True
assert result.total_processed == 3
assert result.successfully_imported == 3
assert len(result.errors) == 0
assert len(result.entries) == 3
# Check multiple hostnames entry
multi_hostname_entry = next((e for e in result.entries if "test.com" in e.hostnames), None)
assert multi_hostname_entry is not None
assert "example.com" in multi_hostname_entry.hostnames
assert len(multi_hostname_entry.hostnames) == 2
def test_import_json_invalid_format(self, service, temp_dir):
"""Test importing invalid JSON format."""
# Create invalid JSON file
invalid_json = {"invalid": "format", "no_entries": True}
json_path = temp_dir / "invalid.json"
with open(json_path, 'w') as f:
json.dump(invalid_json, f)
result = service.import_json_format(json_path)
assert result.success is False
assert result.total_processed == 0
assert result.successfully_imported == 0
assert len(result.errors) > 0
assert "missing 'entries' field" in result.errors[0]
def test_import_json_malformed(self, service, temp_dir):
"""Test importing malformed JSON."""
json_path = temp_dir / "malformed.json"
json_path.write_text("{invalid json content")
result = service.import_json_format(json_path)
assert result.success is False
assert result.total_processed == 0
assert result.successfully_imported == 0
assert len(result.errors) > 0
assert "Invalid JSON file" in result.errors[0]
def test_import_csv_missing_required_columns(self, service, temp_dir):
"""Test importing CSV with missing required columns."""
csv_path = temp_dir / "missing_columns.csv"
with open(csv_path, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['ip_address', 'comment']) # Missing 'hostnames'
writer.writerow(['127.0.0.1', 'test'])
result = service.import_csv_format(csv_path)
assert result.success is False
assert result.total_processed == 0
assert result.successfully_imported == 0
assert len(result.errors) > 0
assert "Missing required columns" in result.errors[0]
def test_import_json_with_warnings(self, service, temp_dir):
"""Test importing JSON with warnings (invalid dates)."""
json_data = {
"entries": [
{
"ip_address": "127.0.0.1",
"hostnames": ["localhost"],
"comment": "Test",
"is_active": True,
"last_resolved": "invalid-date-format"
}
]
}
json_path = temp_dir / "warnings.json"
with open(json_path, 'w') as f:
json.dump(json_data, f)
result = service.import_json_format(json_path)
assert result.success is True
assert result.total_processed == 1
assert result.successfully_imported == 1
assert len(result.warnings) > 0
assert "Invalid last_resolved date format" in result.warnings[0]
def test_import_nonexistent_file(self, service):
"""Test importing non-existent file."""
nonexistent_path = Path("/nonexistent/file.json")
result = service.import_json_format(nonexistent_path)
assert result.success is False
assert result.total_processed == 0
assert result.successfully_imported == 0
assert len(result.errors) > 0
# Utility Tests
def test_detect_file_format_by_extension(self, service, temp_dir):
"""Test file format detection by extension."""
json_file = temp_dir / "test.json"
csv_file = temp_dir / "test.csv"
hosts_file = temp_dir / "hosts"
txt_file = temp_dir / "test.txt"
# Create empty files
for f in [json_file, csv_file, hosts_file, txt_file]:
f.touch()
assert service.detect_file_format(json_file) == ImportFormat.JSON
assert service.detect_file_format(csv_file) == ImportFormat.CSV
assert service.detect_file_format(hosts_file) == ImportFormat.HOSTS
assert service.detect_file_format(txt_file) == ImportFormat.HOSTS
def test_detect_file_format_by_content(self, service, temp_dir):
"""Test file format detection by content."""
# JSON content
json_file = temp_dir / "no_extension"
json_file.write_text('{"entries": []}')
assert service.detect_file_format(json_file) == ImportFormat.JSON
# CSV content
csv_file = temp_dir / "csv_no_ext"
csv_file.write_text('ip_address,hostnames,comment')
assert service.detect_file_format(csv_file) == ImportFormat.CSV
# Hosts content
hosts_file = temp_dir / "hosts_no_ext"
hosts_file.write_text('127.0.0.1 localhost')
assert service.detect_file_format(hosts_file) == ImportFormat.HOSTS
def test_detect_file_format_nonexistent(self, service):
"""Test file format detection for non-existent file."""
result = service.detect_file_format(Path("/nonexistent/file.txt"))
assert result is None
def test_validate_export_path(self, service, temp_dir):
"""Test export path validation."""
# Valid path
valid_path = temp_dir / "export.json"
warnings = service.validate_export_path(valid_path, ExportFormat.JSON)
assert len(warnings) == 0
# Existing file
existing_file = temp_dir / "existing.json"
existing_file.touch()
warnings = service.validate_export_path(existing_file, ExportFormat.JSON)
assert any("already exists" in w for w in warnings)
# Wrong extension
wrong_ext = temp_dir / "file.txt"
warnings = service.validate_export_path(wrong_ext, ExportFormat.JSON)
assert any("doesn't match format" in w for w in warnings)
def test_validate_export_path_invalid_directory(self, service):
"""Test export path validation with invalid directory."""
invalid_path = Path("/invalid/nonexistent/directory/file.json")
warnings = service.validate_export_path(invalid_path, ExportFormat.JSON)
assert any("does not exist" in w for w in warnings)
# Integration Tests
def test_export_import_roundtrip_json(self, service, sample_hosts_file, temp_dir):
"""Test export-import roundtrip for JSON format."""
export_path = temp_dir / "roundtrip.json"
# Export
export_result = service.export_json_format(sample_hosts_file, export_path)
assert export_result.success is True
# Import
import_result = service.import_json_format(export_path)
assert import_result.success is True
assert import_result.successfully_imported == len(sample_hosts_file.entries)
# Verify data integrity
original_entries = sample_hosts_file.entries
imported_entries = import_result.entries
assert len(imported_entries) == len(original_entries)
# Check specific entries
for orig, imported in zip(original_entries, imported_entries):
assert orig.ip_address == imported.ip_address
assert orig.hostnames == imported.hostnames
assert orig.comment == imported.comment
assert orig.is_active == imported.is_active
assert orig.dns_name == imported.dns_name
def test_export_import_roundtrip_csv(self, service, sample_hosts_file, temp_dir):
"""Test export-import roundtrip for CSV format."""
export_path = temp_dir / "roundtrip.csv"
# Export
export_result = service.export_csv_format(sample_hosts_file, export_path)
assert export_result.success is True
# Import
import_result = service.import_csv_format(export_path)
assert import_result.success is True
assert import_result.successfully_imported == len(sample_hosts_file.entries)
def test_import_result_properties(self):
"""Test ImportResult properties."""
# Result with errors
result_with_errors = ImportResult(
success=False,
entries=[],
errors=["Error 1", "Error 2"],
warnings=[],
total_processed=5,
successfully_imported=0
)
assert result_with_errors.has_errors is True
assert result_with_errors.has_warnings is False
# Result with warnings
result_with_warnings = ImportResult(
success=True,
entries=[],
errors=[],
warnings=["Warning 1"],
total_processed=5,
successfully_imported=5
)
assert result_with_warnings.has_errors is False
assert result_with_warnings.has_warnings is True
def test_empty_hosts_file_export(self, service, temp_dir):
"""Test exporting empty hosts file."""
empty_hosts_file = HostsFile()
export_path = temp_dir / "empty.json"
result = service.export_json_format(empty_hosts_file, export_path)
assert result.success is True
assert result.entries_exported == 0
assert export_path.exists()
# Verify empty file structure
with open(export_path, 'r') as f:
data = json.load(f)
assert data["metadata"]["total_entries"] == 0
assert len(data["entries"]) == 0
def test_large_hostnames_list_csv(self, service, temp_dir):
"""Test CSV export/import with large hostnames list."""
entry = HostEntry(
"192.168.1.1",
["host1.com", "host2.com", "host3.com", "host4.com", "host5.com"],
"Multiple hostnames",
True
)
hosts_file = HostsFile()
hosts_file.entries = [entry]
export_path = temp_dir / "multi_hostnames.csv"
# Export
export_result = service.export_csv_format(hosts_file, export_path)
assert export_result.success is True
# Import
import_result = service.import_csv_format(export_path)
assert import_result.success is True
imported_entry = import_result.entries[0]
assert len(imported_entry.hostnames) == 5
assert "host1.com" in imported_entry.hostnames
assert "host5.com" in imported_entry.hostnames

View file

@ -587,380 +587,6 @@ class TestHostsManagerApp:
assert "c" in binding_keys assert "c" in binding_keys
assert "ctrl+c" in binding_keys assert "ctrl+c" in binding_keys
def test_radio_set_event_handling_ip_entry(self):
"""Test radio set event handling for IP entry type."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.edit_handler.handle_entry_type_change = Mock()
# Create mock radio set event for IP entry
mock_radio_set = Mock()
mock_radio_set.id = "edit-entry-type-radio"
mock_pressed_radio = Mock()
mock_pressed_radio.id = "edit-ip-entry-radio"
event = Mock()
event.radio_set = mock_radio_set
event.pressed = mock_pressed_radio
app.on_radio_set_changed(event)
# Should handle IP entry type change
app.edit_handler.handle_entry_type_change.assert_called_once_with("ip")
def test_radio_set_event_handling_dns_entry(self):
"""Test radio set event handling for DNS entry type."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.edit_handler.handle_entry_type_change = Mock()
# Create mock radio set event for DNS entry
mock_radio_set = Mock()
mock_radio_set.id = "edit-entry-type-radio"
mock_pressed_radio = Mock()
mock_pressed_radio.id = "edit-dns-entry-radio"
event = Mock()
event.radio_set = mock_radio_set
event.pressed = mock_pressed_radio
app.on_radio_set_changed(event)
# Should handle DNS entry type change
app.edit_handler.handle_entry_type_change.assert_called_once_with("dns")
def test_entry_type_detection_ip_entry(self):
"""Test entry type detection for IP entries."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
# Add IP entry (no DNS name)
app.hosts_file = HostsFile()
ip_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
app.hosts_file.add_entry(ip_entry)
app.selected_entry_index = 0
entry_type = app.edit_handler.get_current_entry_type()
assert entry_type == "ip"
def test_entry_type_detection_dns_entry(self):
"""Test entry type detection for DNS entries."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
# Add DNS entry with DNS name
app.hosts_file = HostsFile()
dns_entry = HostEntry(ip_address="0.0.0.0", hostnames=["example"])
dns_entry.dns_name = "example.com"
app.hosts_file.add_entry(dns_entry)
app.selected_entry_index = 0
entry_type = app.edit_handler.get_current_entry_type()
assert entry_type == "dns"
def test_field_visibility_ip_type(self):
"""Test field visibility logic for IP entry type."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
# Mock the section elements
mock_ip_section = Mock()
mock_dns_section = Mock()
def mock_query_one(selector):
if selector == "#edit-ip-section":
return mock_ip_section
elif selector == "#edit-dns-section":
return mock_dns_section
return Mock()
app.query_one = mock_query_one
app.edit_handler.update_field_visibility(show_ip=True, show_dns=False)
# IP section should be visible, DNS section hidden
mock_ip_section.remove_class.assert_called_with("hidden")
mock_dns_section.add_class.assert_called_with("hidden")
def test_field_visibility_dns_type(self):
"""Test field visibility logic for DNS entry type."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
# Mock the section elements
mock_ip_section = Mock()
mock_dns_section = Mock()
def mock_query_one(selector):
if selector == "#edit-ip-section":
return mock_ip_section
elif selector == "#edit-dns-section":
return mock_dns_section
return Mock()
app.query_one = mock_query_one
app.edit_handler.update_field_visibility(show_ip=False, show_dns=True)
# DNS section should be visible, IP section hidden
mock_ip_section.add_class.assert_called_with("hidden")
mock_dns_section.remove_class.assert_called_with("hidden")
def test_populate_edit_form_with_ip_type_detection(self):
"""Test edit form population with IP type detection."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.entry_edit_mode = True
app.set_timer = Mock() # Mock set_timer to avoid event loop issues
# Add IP entry
app.hosts_file = HostsFile()
ip_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
app.hosts_file.add_entry(ip_entry)
app.selected_entry_index = 0
# Mock radio set and buttons
mock_radio_set = Mock()
mock_ip_radio = Mock()
mock_dns_radio = Mock()
def mock_query_one(selector):
if selector == "#edit-entry-type-radio":
return mock_radio_set
elif selector == "#edit-ip-entry-radio":
return mock_ip_radio
elif selector == "#edit-dns-entry-radio":
return mock_dns_radio
return Mock()
app.query_one = mock_query_one
app.edit_handler.handle_entry_type_change = Mock()
# Test that the method can be called without errors
try:
app.edit_handler.populate_edit_form_with_type_detection()
# Method executed successfully
assert True
except Exception as e:
# Method should not raise exceptions
assert False, f"Method raised unexpected exception: {e}"
def test_populate_edit_form_with_dns_type_detection(self):
"""Test edit form population with DNS type detection."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.entry_edit_mode = True
# Add DNS entry
app.hosts_file = HostsFile()
dns_entry = HostEntry(ip_address="0.0.0.0", hostnames=["example"])
dns_entry.dns_name = "example.com"
app.hosts_file.add_entry(dns_entry)
app.selected_entry_index = 0
# Mock radio set, buttons, and DNS input with proper value tracking
mock_radio_set = Mock()
mock_ip_radio = Mock()
mock_dns_radio = Mock()
# Use a simple object to track value assignment
class MockDNSInput:
def __init__(self):
self.value = ""
mock_dns_input = MockDNSInput()
def mock_query_one(selector, widget_type=None):
if selector == "#edit-entry-type-radio":
return mock_radio_set
elif selector == "#edit-ip-entry-radio":
return mock_ip_radio
elif selector == "#edit-dns-entry-radio":
return mock_dns_radio
elif selector == "#dns-name-input":
return mock_dns_input
return Mock()
app.query_one = mock_query_one
app.edit_handler.handle_entry_type_change = Mock()
app.edit_handler.populate_edit_form_with_type_detection()
# Should set DNS radio button as pressed and populate DNS field
assert mock_radio_set.pressed_button == mock_dns_radio
assert mock_dns_input.value == "example.com"
app.edit_handler.handle_entry_type_change.assert_called_with("dns")
def test_edit_form_initialization_calls_type_detection(self):
"""Test that edit form initialization calls type detection."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
# Mock form elements
mock_details_display = Mock()
mock_edit_form = Mock()
mock_ip_input = Mock()
mock_hostname_input = Mock()
mock_comment_input = Mock()
mock_active_checkbox = Mock()
def mock_query_one(selector, widget_type=None):
if selector == "#entry-details-display":
return mock_details_display
elif selector == "#entry-edit-form":
return mock_edit_form
elif selector == "#ip-input":
return mock_ip_input
elif selector == "#hostname-input":
return mock_hostname_input
elif selector == "#comment-input":
return mock_comment_input
elif selector == "#active-checkbox":
return mock_active_checkbox
return Mock()
app.query_one = mock_query_one
# Add test entry
app.hosts_file = HostsFile()
test_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
app.hosts_file.add_entry(test_entry)
app.selected_entry_index = 0
# Mock the type detection method
app.edit_handler.populate_edit_form_with_type_detection = Mock()
app.details_handler.update_edit_form()
# Should call type detection method
app.edit_handler.populate_edit_form_with_type_detection.assert_called_once()
def test_dns_resolution_restricted_to_edit_mode(self):
"""Test that DNS resolution is only allowed in edit mode."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.update_status = Mock()
# Add test DNS entry
app.hosts_file = HostsFile()
dns_entry = HostEntry(ip_address="0.0.0.0", hostnames=["example"])
dns_entry.dns_name = "example.com"
app.hosts_file.add_entry(dns_entry)
app.selected_entry_index = 0
# Test 1: DNS resolution blocked in read-only mode (default)
assert app.edit_mode is False
# Test action_refresh_dns in read-only mode
app.action_refresh_dns()
app.update_status.assert_called_with(
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
)
# Reset mock
app.update_status.reset_mock()
# Test action_update_single_dns in read-only mode
app.action_update_single_dns()
app.update_status.assert_called_with(
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
)
# Test 2: DNS resolution allowed in edit mode
app.edit_mode = True
app.update_status.reset_mock()
# Mock DNS service and other dependencies
app.dns_service.resolve_entry_async = Mock()
app.manager.save_hosts_file = Mock(return_value=(True, "Success"))
app.table_handler.populate_entries_table = Mock()
app.details_handler.update_entry_details = Mock()
app.run_worker = Mock()
# Test action_refresh_dns in edit mode - should proceed
app.action_refresh_dns()
# Should not show error message about read-only mode
error_calls = [call for call in app.update_status.call_args_list
if "read-only mode" in str(call)]
assert len(error_calls) == 0
# Should start DNS resolution
app.run_worker.assert_called()
# Reset mocks
app.update_status.reset_mock()
app.run_worker.reset_mock()
# Test action_update_single_dns in edit mode - should proceed
app.action_update_single_dns()
# Should not show error message about read-only mode
error_calls = [call for call in app.update_status.call_args_list
if "read-only mode" in str(call)]
assert len(error_calls) == 0
# Should start DNS resolution
app.run_worker.assert_called()
def test_main_function(self): def test_main_function(self):
"""Test main entry point function.""" """Test main entry point function."""
with patch("hosts.main.HostsManagerApp") as mock_app_class: with patch("hosts.main.HostsManagerApp") as mock_app_class:

14
uv.lock generated
View file

@ -17,7 +17,6 @@ version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" }, { name = "ruff" },
{ name = "textual" }, { name = "textual" },
] ]
@ -25,7 +24,6 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "pytest", specifier = ">=8.4.1" }, { name = "pytest", specifier = ">=8.4.1" },
{ name = "pytest-asyncio", specifier = ">=0.21.0" },
{ name = "ruff", specifier = ">=0.12.5" }, { name = "ruff", specifier = ">=0.12.5" },
{ name = "textual", specifier = ">=5.0.1" }, { name = "textual", specifier = ">=5.0.1" },
] ]
@ -144,18 +142,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
] ]
[[package]]
name = "pytest-asyncio"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" },
]
[[package]] [[package]]
name = "rich" name = "rich"
version = "14.1.0" version = "14.1.0"