Compare commits
No commits in common. "d7ca9cc87f9d40c4d3f6f7909983f2d2baf8b345" and "e6f3e9f3d4db4754d1870118237aff68fde176ba" have entirely different histories.
d7ca9cc87f
...
e6f3e9f3d4
29 changed files with 377 additions and 5975 deletions
277
README.md
277
README.md
|
@ -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
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 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 |
|
@ -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
|
### Phase 3 Edit Mode Complete ✅ COMPLETE
|
||||||
def import_json_format(path: Path) -> ImportResult
|
- ✅ **Permission management**: Complete PermissionManager class with sudo request and validation
|
||||||
def import_csv_format(path: Path) -> ImportResult
|
- ✅ **Edit mode toggle**: Safe transition between read-only and edit modes with 'e' key
|
||||||
|
- ✅ **Entry modification**: Toggle active/inactive status and reorder entries safely
|
||||||
|
- ✅ **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
|
||||||
|
|
||||||
def detect_file_format(path: Path) -> Optional[ImportFormat]
|
### Phase 2 Advanced Read-Only Features ✅ COMPLETE
|
||||||
def validate_export_path(path: Path, format: ExportFormat) -> List[str]
|
- ✅ **Configuration system**: Complete Config class with JSON persistence
|
||||||
|
- ✅ **Configuration modal**: Professional modal dialog for settings management
|
||||||
|
- ✅ **Default entry filtering**: Hide/show system default entries
|
||||||
|
- ✅ **Complete sorting system**: Sort by IP address and hostname with visual indicators
|
||||||
|
- ✅ **Rich visual interface**: Color-coded entries with professional DataTable styling
|
||||||
|
- ✅ **Interactive column headers**: Click headers to sort data
|
||||||
|
|
||||||
# Comprehensive result tracking
|
## Current Project State
|
||||||
@dataclass
|
|
||||||
class ImportResult:
|
|
||||||
success: bool, entries: List[HostEntry], errors: List[str]
|
|
||||||
warnings: List[str], total_processed: int, successfully_imported: int
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Filtering System Architecture
|
### Production Application Status
|
||||||
```python
|
- **Fully functional TUI**: `uv run hosts` launches polished application with advanced Phase 4 features
|
||||||
# Comprehensive filtering capabilities
|
- **Complete edit capabilities**: Add/delete/edit entries, search functionality, and comprehensive modals
|
||||||
class EntryFilter:
|
- **Advanced TUI architecture**: Modular handlers (table, details, edit, navigation) with professional interface
|
||||||
def apply_filters(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]
|
- **Near-complete test coverage**: 147 of 150 tests passing (98% success rate, 3 minor test failures)
|
||||||
def filter_by_status(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]
|
- **Clean code quality**: All ruff linting and formatting checks passing
|
||||||
def filter_by_dns_type(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]
|
- **Robust modular architecture**: Handler-based design ready for Phase 5 advanced features
|
||||||
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
|
### Memory Bank Update Summary
|
||||||
@dataclass
|
All memory bank files have been reviewed and updated to reflect current state:
|
||||||
class FilterOptions:
|
- ✅ **activeContext.md**: Updated with current completion status and next steps
|
||||||
# Status filtering: show_active, show_inactive, active_only, inactive_only
|
- ✅ **progress.md**: Corrected test status and development stage
|
||||||
# DNS type filtering: show_dns_entries, show_ip_entries, dns_only, ip_only
|
- ✅ **techContext.md**: Updated development workflow and current state
|
||||||
# Resolution filtering: show_resolved, show_unresolved, mismatch_only
|
- ✅ **projectbrief.md**: Confirmed project foundation and test status
|
||||||
# Search filtering: search_term, search_in_hostnames, search_in_comments, search_in_ips
|
- ✅ **systemPatterns.md**: Validated architecture and implementation patterns
|
||||||
```
|
- ✅ **productContext.md**: Confirmed product goals and user experience
|
||||||
|
|
||||||
## Current Test Results
|
## Active Decisions and Considerations
|
||||||
- **Total Tests:** 302 comprehensive tests
|
|
||||||
- **Passing:** 301 tests (99.7% success rate)
|
|
||||||
- **DNS Tests:** 27 tests covering resolution, status tracking, and integration
|
|
||||||
- **Import/Export Tests:** 24 tests covering multi-format operations and validation
|
|
||||||
- **Filtering Tests:** 27 tests covering all filter types and preset management
|
|
||||||
- **Core Functionality:** All foundational features fully tested and working
|
|
||||||
|
|
||||||
## Development Patterns Established
|
### Architecture Decisions Validated
|
||||||
- **Async DNS Operations:** Proper async/await patterns with timeout handling and error management
|
- ✅ **Layered architecture**: Successfully implemented with clear separation and extensibility
|
||||||
- **Multi-Format Data Operations:** Consistent import/export patterns with validation and error reporting
|
- ✅ **Reactive UI**: Textual's reactive system working excellently with complex state
|
||||||
- **Advanced Filtering Logic:** Flexible filter combination with preset management and statistics
|
- ✅ **Data models**: Dataclasses with validation proving robust and extensible
|
||||||
- **Test-Driven Development:** Comprehensive test coverage with mock-based isolation
|
- ✅ **File parsing**: Comprehensive parser handling all edge cases flawlessly
|
||||||
- **Professional TUI Design:** Consistent modal dialogs, keyboard shortcuts, and user feedback
|
- ✅ **Configuration system**: JSON-based persistence working reliably
|
||||||
- **Clean Architecture:** Clear separation between core business logic and UI components
|
- ✅ **Modal system**: Professional dialog system with proper keyboard handling
|
||||||
|
- ✅ **Permission management**: Secure sudo handling with proper lifecycle management
|
||||||
|
|
||||||
## Current Project State - PRODUCTION READY
|
### Design Patterns Implemented
|
||||||
The hosts TUI application has achieved **production maturity** with:
|
- ✅ **Reactive patterns**: Using Textual's reactive attributes for complex state management
|
||||||
- **Complete Feature Set:** DNS resolution, import/export, advanced filtering, and comprehensive editing
|
- ✅ **Data validation**: Comprehensive validation in models, parser, and configuration
|
||||||
- **Professional Interface:** Enhanced visual design with modal dialogs and intuitive navigation
|
- ✅ **Error handling**: Graceful degradation and user feedback throughout
|
||||||
- **Robust Architecture:** Clean, maintainable code with excellent separation of concerns
|
- ✅ **Modal pattern**: Professional modal dialogs with proper lifecycle management
|
||||||
- **Exceptional Test Coverage:** 301/302 tests passing with comprehensive validation
|
- ✅ **Configuration pattern**: Centralized settings with persistence and defaults
|
||||||
- **Advanced Capabilities:** Multi-format data exchange, preset management, and async operations
|
- ✅ **Command pattern**: Implemented for edit operations with save confirmation
|
||||||
- **Production Quality:** Error handling, validation, user feedback, and graceful degradation
|
- ✅ **Permission pattern**: Secure privilege escalation and management
|
||||||
|
- 🔄 **Observer pattern**: Will implement for advanced state change notifications
|
||||||
|
|
||||||
## Next Development Opportunities
|
## Important Patterns and Preferences
|
||||||
The application is ready for:
|
|
||||||
- **Production Deployment:** All core and advanced functionality working reliably
|
|
||||||
- **Performance Optimization:** Large file handling and batch operation improvements
|
|
||||||
- **User Experience Enhancements:** Additional UI polish and workflow optimizations
|
|
||||||
- **Extended DNS Features:** Advanced DNS management and monitoring capabilities
|
|
||||||
- **Integration Features:** API integrations, configuration management, and automation support
|
|
||||||
|
|
||||||
The hosts TUI application represents a comprehensive, professional-grade tool for hosts file management with advanced DNS integration capabilities.
|
### 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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
|
||||||
|
|
|
@ -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
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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()
|
|
|
@ -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.
|
||||||
|
|
|
@ -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("")
|
||||||
|
|
|
@ -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(
|
||||||
|
@ -294,7 +238,19 @@ class HostsManagerApp(App):
|
||||||
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."""
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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 "✅"
|
|
|
@ -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:
|
||||||
|
@ -150,13 +29,6 @@ class EditHandler:
|
||||||
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"]
|
||||||
|
@ -220,93 +91,11 @@ class EditHandler:
|
||||||
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
|
# Find currently focused field and move to next
|
||||||
fields = [radio_set]
|
for i, field in enumerate(fields):
|
||||||
|
if field.has_focus:
|
||||||
# Add IP or DNS field based on visibility
|
next_field = fields[(i + 1) % len(fields)]
|
||||||
try:
|
next_field.focus()
|
||||||
ip_section = self.app.query_one("#edit-ip-section")
|
break
|
||||||
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
|
|
||||||
for i, field in enumerate(fields):
|
|
||||||
if field.has_focus:
|
|
||||||
next_field = fields[(i + 1) % len(fields)]
|
|
||||||
next_field.focus()
|
|
||||||
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
|
# Find currently focused field and move to previous
|
||||||
fields = [radio_set]
|
for i, field in enumerate(fields):
|
||||||
|
if field.has_focus:
|
||||||
# Add IP or DNS field based on visibility
|
prev_field = fields[(i - 1) % len(fields)]
|
||||||
try:
|
prev_field.focus()
|
||||||
ip_section = self.app.query_one("#edit-ip-section")
|
break
|
||||||
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
|
|
||||||
for i, field in enumerate(fields):
|
|
||||||
if field.has_focus:
|
|
||||||
prev_field = fields[(i - 1) % len(fields)]
|
|
||||||
prev_field.focus()
|
|
||||||
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.
|
||||||
|
|
|
@ -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
|
|
|
@ -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),
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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":
|
||||||
|
|
|
@ -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")
|
|
|
@ -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
|
|
|
@ -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"
|
|
|
@ -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
|
|
|
@ -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
14
uv.lock
generated
|
@ -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"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue