Add unit tests for HostEntry and HostsFile models, and implement HostsParser tests
- Created comprehensive unit tests for HostEntry class, covering creation, validation, and conversion to/from hosts file lines. - Developed unit tests for HostsFile class, including entry management, sorting, and retrieval of active/inactive entries. - Implemented tests for HostsParser class, validating parsing and serialization of hosts files, handling comments, and file operations. - Ensured coverage for edge cases such as empty files, invalid entries, and file permission checks.
This commit is contained in:
		
							parent
							
								
									40a1e67949
								
							
						
					
					
						commit
						2decad8047
					
				
					 21 changed files with 1691 additions and 75 deletions
				
			
		| 
						 | 
				
			
			@ -2,12 +2,26 @@
 | 
			
		|||
 | 
			
		||||
## What Works
 | 
			
		||||
 | 
			
		||||
### Project Foundation
 | 
			
		||||
### Project Foundation ✅ COMPLETE
 | 
			
		||||
- ✅ **uv project initialized**: Basic Python 3.13 project with uv configuration
 | 
			
		||||
- ✅ **Code quality setup**: ruff configured for linting and formatting
 | 
			
		||||
- ✅ **Memory bank complete**: All core documentation files created and populated
 | 
			
		||||
- ✅ **Architecture defined**: Clear layered architecture and design patterns established
 | 
			
		||||
 | 
			
		||||
### Phase 1: Foundation ✅ COMPLETE
 | 
			
		||||
- ✅ **Project structure**: Created proper `src/hosts/` package structure with core and tui modules
 | 
			
		||||
- ✅ **Dependencies**: Added textual, pytest, ruff and configured properly in pyproject.toml
 | 
			
		||||
- ✅ **Entry point**: Configured proper application entry point (`hosts` command)
 | 
			
		||||
- ✅ **Core models**: Implemented HostEntry and HostsFile data classes with full validation
 | 
			
		||||
- ✅ **Hosts parser**: Created comprehensive parser for reading/writing `/etc/hosts` files
 | 
			
		||||
- ✅ **Basic TUI**: Implemented main application with two-pane layout
 | 
			
		||||
- ✅ **File loading**: Successfully reads and parses existing hosts file
 | 
			
		||||
- ✅ **Entry display**: Shows hosts entries in left pane with proper formatting
 | 
			
		||||
- ✅ **Detail view**: Shows selected entry details in right pane
 | 
			
		||||
- ✅ **Navigation**: Keyboard navigation between entries working
 | 
			
		||||
- ✅ **Testing**: Comprehensive test suite with 42 passing tests
 | 
			
		||||
- ✅ **Code quality**: All linting checks passing
 | 
			
		||||
 | 
			
		||||
### Documentation
 | 
			
		||||
- ✅ **Project brief**: Comprehensive project definition and requirements
 | 
			
		||||
- ✅ **Product context**: User experience goals and problem definition
 | 
			
		||||
| 
						 | 
				
			
			@ -17,117 +31,162 @@
 | 
			
		|||
 | 
			
		||||
## What's Left to Build
 | 
			
		||||
 | 
			
		||||
### Phase 1: Foundation (Immediate)
 | 
			
		||||
- ❌ **Project structure**: Create proper `src/hosts/` package structure
 | 
			
		||||
- ❌ **Dependencies**: Add textual, pytest, and other required packages
 | 
			
		||||
- ❌ **Entry point**: Configure proper application entry point in pyproject.toml
 | 
			
		||||
- ❌ **Core models**: Implement HostEntry and HostsFile data classes
 | 
			
		||||
- ❌ **Hosts parser**: Create parser for reading/writing `/etc/hosts` files
 | 
			
		||||
 | 
			
		||||
### Phase 2: Core Functionality
 | 
			
		||||
- ❌ **Basic TUI**: Implement main application with two-pane layout
 | 
			
		||||
- ❌ **File loading**: Read and parse existing hosts file
 | 
			
		||||
- ❌ **Entry display**: Show hosts entries in left pane
 | 
			
		||||
- ❌ **Detail view**: Show selected entry details in right pane
 | 
			
		||||
- ❌ **Navigation**: Keyboard navigation between entries
 | 
			
		||||
 | 
			
		||||
### Phase 3: Read-Only Features
 | 
			
		||||
- ❌ **Entry selection**: Highlight and select entries
 | 
			
		||||
### Phase 2: Enhanced Read-Only Features (Next)
 | 
			
		||||
- ❌ **Entry selection highlighting**: Visual feedback for selected entries
 | 
			
		||||
- ❌ **Sorting**: Sort entries by IP, hostname, or comments
 | 
			
		||||
- ❌ **Filtering**: Filter entries by active/inactive status
 | 
			
		||||
- ❌ **Search**: Find entries by hostname or IP
 | 
			
		||||
- ❌ **Help screen**: Proper modal help dialog
 | 
			
		||||
- ❌ **Status indicators**: Better visual distinction for active/inactive entries
 | 
			
		||||
 | 
			
		||||
### Phase 4: Edit Mode
 | 
			
		||||
### Phase 3: Edit Mode Foundation
 | 
			
		||||
- ❌ **Permission management**: Sudo request and management
 | 
			
		||||
- ❌ **Edit mode toggle**: Switch between read-only and edit modes
 | 
			
		||||
- ❌ **Entry activation**: Toggle entries active/inactive
 | 
			
		||||
- ❌ **Entry reordering**: Move entries up/down in the list
 | 
			
		||||
- ❌ **Entry editing**: Modify IP addresses, hostnames, comments
 | 
			
		||||
- ❌ **File backup**: Automatic backup before modifications
 | 
			
		||||
 | 
			
		||||
### Phase 4: Advanced Edit Features
 | 
			
		||||
- ❌ **Add new entries**: Create new host entries
 | 
			
		||||
- ❌ **Delete entries**: Remove host entries
 | 
			
		||||
- ❌ **Bulk operations**: Select and modify multiple entries
 | 
			
		||||
- ❌ **Validation**: Real-time validation of IP addresses and hostnames
 | 
			
		||||
- ❌ **Undo/Redo**: Command pattern implementation
 | 
			
		||||
 | 
			
		||||
### Phase 5: Advanced Features
 | 
			
		||||
- ❌ **DNS resolution**: Resolve hostnames to IP addresses
 | 
			
		||||
- ❌ **IP comparison**: Compare stored vs resolved IPs
 | 
			
		||||
- ❌ **CNAME support**: Store DNS names alongside IP addresses
 | 
			
		||||
- ❌ **Undo/Redo**: Command pattern implementation
 | 
			
		||||
- ❌ **File validation**: Comprehensive validation before saving
 | 
			
		||||
- ❌ **Import/Export**: Support for different file formats
 | 
			
		||||
- ❌ **Configuration**: User preferences and settings
 | 
			
		||||
 | 
			
		||||
### Phase 6: Polish
 | 
			
		||||
- ❌ **Error handling**: Graceful error handling and user feedback
 | 
			
		||||
- ❌ **Help system**: In-app help and keyboard shortcuts
 | 
			
		||||
- ❌ **Configuration**: User preferences and settings
 | 
			
		||||
- ❌ **Error handling**: Enhanced error handling and user feedback
 | 
			
		||||
- ❌ **Performance**: Optimization for large hosts files
 | 
			
		||||
- ❌ **Accessibility**: Screen reader support and keyboard accessibility
 | 
			
		||||
- ❌ **Documentation**: User manual and installation guide
 | 
			
		||||
 | 
			
		||||
## Current Status
 | 
			
		||||
 | 
			
		||||
### Development Stage
 | 
			
		||||
**Stage**: Project Initialization
 | 
			
		||||
**Progress**: 10% (Foundation documentation complete)
 | 
			
		||||
**Next Milestone**: Basic project structure and dependencies
 | 
			
		||||
**Stage**: Phase 1 Complete - Foundation Established
 | 
			
		||||
**Progress**: 25% (Core functionality working)
 | 
			
		||||
**Next Milestone**: Enhanced read-only features
 | 
			
		||||
 | 
			
		||||
### Immediate Blockers
 | 
			
		||||
1. **Project structure**: Need to create proper package layout
 | 
			
		||||
2. **Dependencies**: Must add textual framework to begin TUI development
 | 
			
		||||
3. **Entry point**: Configure uv to run the application properly
 | 
			
		||||
### Phase 1 Achievements
 | 
			
		||||
1. ✅ **Fully functional TUI**: Application successfully loads and displays hosts file
 | 
			
		||||
2. ✅ **Robust parsing**: Handles comments, inactive entries, IPv4/IPv6 addresses
 | 
			
		||||
3. ✅ **Clean architecture**: Well-structured codebase with separation of concerns
 | 
			
		||||
4. ✅ **Comprehensive testing**: 42 tests covering models and parser functionality
 | 
			
		||||
5. ✅ **Code quality**: All linting and formatting checks passing
 | 
			
		||||
 | 
			
		||||
### Immediate Next Steps
 | 
			
		||||
1. **Enhanced UI**: Improve visual feedback and entry highlighting
 | 
			
		||||
2. **Sorting/Filtering**: Add basic data manipulation features
 | 
			
		||||
3. **Help system**: Implement proper help modal
 | 
			
		||||
4. **Status improvements**: Better visual indicators for entry states
 | 
			
		||||
 | 
			
		||||
### Recent Accomplishments
 | 
			
		||||
- Completed comprehensive project planning and documentation
 | 
			
		||||
- Established clear architecture and design patterns
 | 
			
		||||
- Created memory bank system for project continuity
 | 
			
		||||
- Defined development phases and priorities
 | 
			
		||||
- Successfully implemented complete Phase 1 foundation
 | 
			
		||||
- Created robust data models with validation
 | 
			
		||||
- Built comprehensive hosts file parser with comment preservation
 | 
			
		||||
- Developed functional TUI application with two-pane layout
 | 
			
		||||
- Established comprehensive testing framework
 | 
			
		||||
- Achieved clean code quality standards
 | 
			
		||||
 | 
			
		||||
## Technical Implementation Details
 | 
			
		||||
 | 
			
		||||
### Core Components Working
 | 
			
		||||
- **HostEntry**: Data class with IP/hostname validation, active/inactive state
 | 
			
		||||
- **HostsFile**: Container with entry management, sorting, and search capabilities
 | 
			
		||||
- **HostsParser**: File I/O with atomic writes, backup creation, permission checking
 | 
			
		||||
- **HostsManagerApp**: Textual-based TUI with reactive state management
 | 
			
		||||
 | 
			
		||||
### Test Coverage
 | 
			
		||||
- **Models**: 27 tests covering all data model functionality
 | 
			
		||||
- **Parser**: 15 tests covering file operations and edge cases
 | 
			
		||||
- **Coverage**: All core functionality thoroughly tested
 | 
			
		||||
 | 
			
		||||
### Code Quality
 | 
			
		||||
- **Linting**: All ruff checks passing
 | 
			
		||||
- **Type hints**: Comprehensive typing throughout codebase
 | 
			
		||||
- **Documentation**: Detailed docstrings and comments
 | 
			
		||||
- **Error handling**: Proper exception handling in core components
 | 
			
		||||
 | 
			
		||||
## Known Issues
 | 
			
		||||
 | 
			
		||||
### Current Limitations
 | 
			
		||||
- **Placeholder implementation**: main.py only prints hello message
 | 
			
		||||
- **Missing dependencies**: Core frameworks not yet added
 | 
			
		||||
- **No package structure**: Files not organized in proper Python package
 | 
			
		||||
- **No tests**: Testing framework not yet configured
 | 
			
		||||
- **Help system**: Currently shows status message instead of modal
 | 
			
		||||
- **Entry highlighting**: Basic selection without visual enhancement
 | 
			
		||||
- **No edit capabilities**: Read-only mode only (by design for Phase 1)
 | 
			
		||||
- **No sorting/filtering**: Basic display only
 | 
			
		||||
 | 
			
		||||
### Technical Debt
 | 
			
		||||
- **Temporary main.py**: Needs to be moved to proper location
 | 
			
		||||
- **Missing type hints**: Will need comprehensive typing
 | 
			
		||||
- **No error handling**: Basic error handling patterns needed
 | 
			
		||||
- **No logging**: Logging system not yet implemented
 | 
			
		||||
- **Help modal**: Need to implement proper screen for help
 | 
			
		||||
- **Visual polish**: Entry highlighting and status indicators need improvement
 | 
			
		||||
- **Error messages**: Could be more user-friendly
 | 
			
		||||
- **Performance**: Not yet optimized for very large hosts files
 | 
			
		||||
 | 
			
		||||
## Evolution of Project Decisions
 | 
			
		||||
 | 
			
		||||
### Initial Decisions (Current)
 | 
			
		||||
- **Python 3.13**: Chosen for modern features and performance
 | 
			
		||||
- **Textual**: Selected for rich TUI capabilities
 | 
			
		||||
- **uv**: Adopted for fast package management
 | 
			
		||||
- **ruff**: Chosen for code quality and speed
 | 
			
		||||
### Confirmed Decisions
 | 
			
		||||
- **Python 3.13**: Excellent choice for modern features
 | 
			
		||||
- **Textual**: Perfect for rich TUI development
 | 
			
		||||
- **uv**: Fast and reliable package management
 | 
			
		||||
- **ruff**: Excellent code quality tooling
 | 
			
		||||
- **Dataclasses**: Clean and efficient for data models
 | 
			
		||||
 | 
			
		||||
### Architecture Evolution
 | 
			
		||||
- **Layered approach**: Decided on clear separation of concerns
 | 
			
		||||
- **Command pattern**: Chosen for undo/redo functionality
 | 
			
		||||
- **Immutable state**: Selected for predictable state management
 | 
			
		||||
- **Permission model**: Explicit edit mode for safety
 | 
			
		||||
### Architecture Validation
 | 
			
		||||
- **Layered approach**: Proven effective with clear separation
 | 
			
		||||
- **Parser design**: Robust handling of real-world hosts files
 | 
			
		||||
- **Reactive UI**: Textual's reactive system working well
 | 
			
		||||
- **Test-driven**: Comprehensive testing paying dividends
 | 
			
		||||
 | 
			
		||||
### Design Considerations
 | 
			
		||||
- **Safety first**: Read-only default mode prioritized
 | 
			
		||||
- **User experience**: Keyboard-driven interface emphasized
 | 
			
		||||
- **File integrity**: Atomic operations and validation required
 | 
			
		||||
- **Performance**: Responsive UI for large files planned
 | 
			
		||||
### Design Successes
 | 
			
		||||
- **Safety first**: Read-only default working as intended
 | 
			
		||||
- **File integrity**: Atomic operations and backup system solid
 | 
			
		||||
- **User experience**: Keyboard navigation intuitive
 | 
			
		||||
- **Code organization**: Package structure clean and maintainable
 | 
			
		||||
 | 
			
		||||
## Success Metrics Progress
 | 
			
		||||
 | 
			
		||||
### Completed Metrics
 | 
			
		||||
- ✅ **Project documentation**: Comprehensive planning complete
 | 
			
		||||
- ✅ **Architecture clarity**: Clear technical direction established
 | 
			
		||||
- ✅ **Development setup**: Basic environment ready
 | 
			
		||||
### Completed Metrics ✅
 | 
			
		||||
- ✅ **Functional prototype**: TUI application fully working
 | 
			
		||||
- ✅ **File parsing**: Robust hosts file reading and writing
 | 
			
		||||
- ✅ **Code quality**: All quality checks passing
 | 
			
		||||
- ✅ **Test coverage**: Comprehensive test suite implemented
 | 
			
		||||
- ✅ **Architecture**: Clean, maintainable codebase structure
 | 
			
		||||
 | 
			
		||||
### Pending Metrics
 | 
			
		||||
- ❌ **Functional prototype**: Basic TUI not yet implemented
 | 
			
		||||
- ❌ **File parsing**: Hosts file reading not yet working
 | 
			
		||||
- ❌ **User testing**: No user interface to test yet
 | 
			
		||||
- ❌ **Performance benchmarks**: No code to benchmark yet
 | 
			
		||||
### Next Phase Metrics
 | 
			
		||||
- ❌ **Enhanced UX**: Improved visual feedback and interactions
 | 
			
		||||
- ❌ **Data manipulation**: Sorting and filtering capabilities
 | 
			
		||||
- ❌ **User testing**: Feedback on current interface
 | 
			
		||||
- ❌ **Performance benchmarks**: Testing with large hosts files
 | 
			
		||||
 | 
			
		||||
## Next Session Priorities
 | 
			
		||||
 | 
			
		||||
1. **Create project structure**: Set up src/hosts/ package layout
 | 
			
		||||
2. **Add dependencies**: Install textual and pytest
 | 
			
		||||
3. **Implement data models**: Create HostEntry and HostsFile classes
 | 
			
		||||
4. **Basic parser**: Read and parse simple hosts file format
 | 
			
		||||
5. **Minimal TUI**: Create basic application shell
 | 
			
		||||
### Phase 2 Implementation
 | 
			
		||||
1. **Visual enhancements**: Improve entry highlighting and status indicators
 | 
			
		||||
2. **Sorting functionality**: Implement sort by IP, hostname, status
 | 
			
		||||
3. **Filtering system**: Add active/inactive filtering
 | 
			
		||||
4. **Help modal**: Create proper help screen
 | 
			
		||||
5. **Search capability**: Basic hostname/IP search
 | 
			
		||||
 | 
			
		||||
The project is well-planned and ready for implementation to begin.
 | 
			
		||||
### Quality Improvements
 | 
			
		||||
1. **Error handling**: More user-friendly error messages
 | 
			
		||||
2. **Status feedback**: Better user feedback for operations
 | 
			
		||||
3. **Performance testing**: Test with large hosts files
 | 
			
		||||
4. **Documentation**: Update README with usage instructions
 | 
			
		||||
 | 
			
		||||
## Phase 1 Success Summary
 | 
			
		||||
 | 
			
		||||
Phase 1 has been **successfully completed** with all core objectives achieved:
 | 
			
		||||
 | 
			
		||||
- ✅ **Solid foundation**: Robust architecture and codebase
 | 
			
		||||
- ✅ **Working application**: Functional TUI that reads and displays hosts files
 | 
			
		||||
- ✅ **Quality standards**: Clean code with comprehensive testing
 | 
			
		||||
- ✅ **User experience**: Intuitive keyboard-driven interface
 | 
			
		||||
- ✅ **File safety**: Proper parsing with comment preservation
 | 
			
		||||
 | 
			
		||||
The project is ready to move into Phase 2 with enhanced read-only features.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,9 +1,24 @@
 | 
			
		|||
[project]
 | 
			
		||||
name = "hosts"
 | 
			
		||||
version = "0.1.0"
 | 
			
		||||
description = "Add your description here"
 | 
			
		||||
description = "A Python TUI application for managing /etc/hosts files"
 | 
			
		||||
readme = "README.md"
 | 
			
		||||
requires-python = ">=3.13"
 | 
			
		||||
dependencies = [
 | 
			
		||||
    "textual>=0.57.0",
 | 
			
		||||
    "pytest>=8.1.1",
 | 
			
		||||
    "ruff>=0.12.5",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[project.scripts]
 | 
			
		||||
hosts = "hosts.main:main"
 | 
			
		||||
 | 
			
		||||
[tool.uv]
 | 
			
		||||
package = true
 | 
			
		||||
 | 
			
		||||
[build-system]
 | 
			
		||||
requires = ["hatchling"]
 | 
			
		||||
build-backend = "hatchling.build"
 | 
			
		||||
 | 
			
		||||
[tool.hatch.build.targets.wheel]
 | 
			
		||||
packages = ["src/hosts"]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										8
									
								
								src/hosts/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/hosts/__init__.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,8 @@
 | 
			
		|||
"""
 | 
			
		||||
hosts - A Python TUI application for managing /etc/hosts files.
 | 
			
		||||
 | 
			
		||||
This package provides a modern, user-friendly terminal interface for
 | 
			
		||||
managing hostname entries in the system hosts file.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
__version__ = "0.1.0"
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								src/hosts/__pycache__/__init__.cpython-313.pyc
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/hosts/__pycache__/__init__.cpython-313.pyc
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								src/hosts/__pycache__/main.cpython-313.pyc
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/hosts/__pycache__/main.cpython-313.pyc
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										6
									
								
								src/hosts/core/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/hosts/core/__init__.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
"""
 | 
			
		||||
Core business logic for the hosts TUI application.
 | 
			
		||||
 | 
			
		||||
This module contains the data models, parsing logic, and core operations
 | 
			
		||||
for managing hosts file entries.
 | 
			
		||||
"""
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								src/hosts/core/__pycache__/__init__.cpython-313.pyc
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/hosts/core/__pycache__/__init__.cpython-313.pyc
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								src/hosts/core/__pycache__/models.cpython-313.pyc
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/hosts/core/__pycache__/models.cpython-313.pyc
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								src/hosts/core/__pycache__/parser.cpython-313.pyc
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/hosts/core/__pycache__/parser.cpython-313.pyc
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										217
									
								
								src/hosts/core/models.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								src/hosts/core/models.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,217 @@
 | 
			
		|||
"""
 | 
			
		||||
Data models for the hosts TUI application.
 | 
			
		||||
 | 
			
		||||
This module defines the core data structures used throughout the application
 | 
			
		||||
for representing hosts file entries and the overall hosts file structure.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
from dataclasses import dataclass, field
 | 
			
		||||
from typing import List, Optional
 | 
			
		||||
import ipaddress
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class HostEntry:
 | 
			
		||||
    """
 | 
			
		||||
    Represents a single entry in the hosts file.
 | 
			
		||||
    
 | 
			
		||||
    Attributes:
 | 
			
		||||
        ip_address: The IP address (IPv4 or IPv6)
 | 
			
		||||
        hostnames: List of hostnames mapped to this IP
 | 
			
		||||
        comment: Optional comment for this entry
 | 
			
		||||
        is_active: Whether this entry is active (not commented out)
 | 
			
		||||
        dns_name: Optional DNS name for CNAME-like functionality
 | 
			
		||||
    """
 | 
			
		||||
    ip_address: str
 | 
			
		||||
    hostnames: List[str]
 | 
			
		||||
    comment: Optional[str] = None
 | 
			
		||||
    is_active: bool = True
 | 
			
		||||
    dns_name: Optional[str] = None
 | 
			
		||||
 | 
			
		||||
    def __post_init__(self):
 | 
			
		||||
        """Validate the entry after initialization."""
 | 
			
		||||
        self.validate()
 | 
			
		||||
 | 
			
		||||
    def validate(self) -> None:
 | 
			
		||||
        """
 | 
			
		||||
        Validate the host entry data.
 | 
			
		||||
        
 | 
			
		||||
        Raises:
 | 
			
		||||
            ValueError: If the IP address or hostnames are invalid
 | 
			
		||||
        """
 | 
			
		||||
        # Validate IP address
 | 
			
		||||
        try:
 | 
			
		||||
            ipaddress.ip_address(self.ip_address)
 | 
			
		||||
        except ValueError as e:
 | 
			
		||||
            raise ValueError(f"Invalid IP address '{self.ip_address}': {e}")
 | 
			
		||||
 | 
			
		||||
        # Validate hostnames
 | 
			
		||||
        if not self.hostnames:
 | 
			
		||||
            raise ValueError("At least one hostname is required")
 | 
			
		||||
 | 
			
		||||
        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 self.hostnames:
 | 
			
		||||
            if not hostname_pattern.match(hostname):
 | 
			
		||||
                raise ValueError(f"Invalid hostname '{hostname}'")
 | 
			
		||||
 | 
			
		||||
    def to_hosts_line(self) -> str:
 | 
			
		||||
        """
 | 
			
		||||
        Convert this entry to a hosts file line.
 | 
			
		||||
        
 | 
			
		||||
        Returns:
 | 
			
		||||
            String representation suitable for writing to hosts file
 | 
			
		||||
        """
 | 
			
		||||
        line_parts = []
 | 
			
		||||
        
 | 
			
		||||
        # Add comment prefix if inactive
 | 
			
		||||
        if not self.is_active:
 | 
			
		||||
            line_parts.append("#")
 | 
			
		||||
        
 | 
			
		||||
        # Add IP and hostnames
 | 
			
		||||
        line_parts.append(self.ip_address)
 | 
			
		||||
        line_parts.extend(self.hostnames)
 | 
			
		||||
        
 | 
			
		||||
        # Add comment if present
 | 
			
		||||
        if self.comment:
 | 
			
		||||
            line_parts.append(f"# {self.comment}")
 | 
			
		||||
        
 | 
			
		||||
        return " ".join(line_parts)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def from_hosts_line(cls, line: str) -> Optional['HostEntry']:
 | 
			
		||||
        """
 | 
			
		||||
        Parse a hosts file line into a HostEntry.
 | 
			
		||||
        
 | 
			
		||||
        Args:
 | 
			
		||||
            line: A line from the hosts file
 | 
			
		||||
            
 | 
			
		||||
        Returns:
 | 
			
		||||
            HostEntry instance or None if line is empty/comment-only
 | 
			
		||||
        """
 | 
			
		||||
        original_line = line.strip()
 | 
			
		||||
        if not original_line:
 | 
			
		||||
            return None
 | 
			
		||||
        
 | 
			
		||||
        # Check if line is commented out (inactive)
 | 
			
		||||
        is_active = True
 | 
			
		||||
        if original_line.startswith('#'):
 | 
			
		||||
            is_active = False
 | 
			
		||||
            line = original_line[1:].strip()
 | 
			
		||||
        
 | 
			
		||||
        # Handle comment-only lines
 | 
			
		||||
        if not line or line.startswith('#'):
 | 
			
		||||
            return None
 | 
			
		||||
        
 | 
			
		||||
        # Split line into parts
 | 
			
		||||
        parts = line.split()
 | 
			
		||||
        if len(parts) < 2:
 | 
			
		||||
            return None
 | 
			
		||||
        
 | 
			
		||||
        ip_address = parts[0]
 | 
			
		||||
        hostnames = []
 | 
			
		||||
        comment = None
 | 
			
		||||
        
 | 
			
		||||
        # Parse hostnames and comments
 | 
			
		||||
        for i, part in enumerate(parts[1:], 1):
 | 
			
		||||
            if part.startswith('#'):
 | 
			
		||||
                # Everything from here is a comment
 | 
			
		||||
                comment = ' '.join(parts[i:]).lstrip('# ')
 | 
			
		||||
                break
 | 
			
		||||
            else:
 | 
			
		||||
                hostnames.append(part)
 | 
			
		||||
        
 | 
			
		||||
        if not hostnames:
 | 
			
		||||
            return None
 | 
			
		||||
        
 | 
			
		||||
        try:
 | 
			
		||||
            return cls(
 | 
			
		||||
                ip_address=ip_address,
 | 
			
		||||
                hostnames=hostnames,
 | 
			
		||||
                comment=comment,
 | 
			
		||||
                is_active=is_active
 | 
			
		||||
            )
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            # Skip invalid entries
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class HostsFile:
 | 
			
		||||
    """
 | 
			
		||||
    Represents the complete hosts file structure.
 | 
			
		||||
    
 | 
			
		||||
    Attributes:
 | 
			
		||||
        entries: List of host entries
 | 
			
		||||
        header_comments: Comments at the beginning of the file
 | 
			
		||||
        footer_comments: Comments at the end of the file
 | 
			
		||||
    """
 | 
			
		||||
    entries: List[HostEntry] = field(default_factory=list)
 | 
			
		||||
    header_comments: List[str] = field(default_factory=list)
 | 
			
		||||
    footer_comments: List[str] = field(default_factory=list)
 | 
			
		||||
 | 
			
		||||
    def add_entry(self, entry: HostEntry) -> None:
 | 
			
		||||
        """Add a new entry to the hosts file."""
 | 
			
		||||
        entry.validate()
 | 
			
		||||
        self.entries.append(entry)
 | 
			
		||||
 | 
			
		||||
    def remove_entry(self, index: int) -> None:
 | 
			
		||||
        """Remove an entry by index."""
 | 
			
		||||
        if 0 <= index < len(self.entries):
 | 
			
		||||
            del self.entries[index]
 | 
			
		||||
 | 
			
		||||
    def toggle_entry(self, index: int) -> None:
 | 
			
		||||
        """Toggle the active state of an entry."""
 | 
			
		||||
        if 0 <= index < len(self.entries):
 | 
			
		||||
            self.entries[index].is_active = not self.entries[index].is_active
 | 
			
		||||
 | 
			
		||||
    def get_active_entries(self) -> List[HostEntry]:
 | 
			
		||||
        """Get all active entries."""
 | 
			
		||||
        return [entry for entry in self.entries if entry.is_active]
 | 
			
		||||
 | 
			
		||||
    def get_inactive_entries(self) -> List[HostEntry]:
 | 
			
		||||
        """Get all inactive entries."""
 | 
			
		||||
        return [entry for entry in self.entries if not entry.is_active]
 | 
			
		||||
 | 
			
		||||
    def sort_by_ip(self) -> None:
 | 
			
		||||
        """Sort entries by IP address."""
 | 
			
		||||
        self.entries.sort(key=lambda entry: ipaddress.ip_address(entry.ip_address))
 | 
			
		||||
 | 
			
		||||
    def sort_by_hostname(self) -> None:
 | 
			
		||||
        """Sort entries by first hostname."""
 | 
			
		||||
        self.entries.sort(key=lambda entry: entry.hostnames[0].lower())
 | 
			
		||||
 | 
			
		||||
    def find_entries_by_hostname(self, hostname: str) -> List[int]:
 | 
			
		||||
        """
 | 
			
		||||
        Find entry indices that contain the given hostname.
 | 
			
		||||
        
 | 
			
		||||
        Args:
 | 
			
		||||
            hostname: Hostname to search for
 | 
			
		||||
            
 | 
			
		||||
        Returns:
 | 
			
		||||
            List of indices where the hostname is found
 | 
			
		||||
        """
 | 
			
		||||
        indices = []
 | 
			
		||||
        for i, entry in enumerate(self.entries):
 | 
			
		||||
            if hostname.lower() in [h.lower() for h in entry.hostnames]:
 | 
			
		||||
                indices.append(i)
 | 
			
		||||
        return indices
 | 
			
		||||
 | 
			
		||||
    def find_entries_by_ip(self, ip_address: str) -> List[int]:
 | 
			
		||||
        """
 | 
			
		||||
        Find entry indices that have the given IP address.
 | 
			
		||||
        
 | 
			
		||||
        Args:
 | 
			
		||||
            ip_address: IP address to search for
 | 
			
		||||
            
 | 
			
		||||
        Returns:
 | 
			
		||||
            List of indices where the IP is found
 | 
			
		||||
        """
 | 
			
		||||
        indices = []
 | 
			
		||||
        for i, entry in enumerate(self.entries):
 | 
			
		||||
            if entry.ip_address == ip_address:
 | 
			
		||||
                indices.append(i)
 | 
			
		||||
        return indices
 | 
			
		||||
							
								
								
									
										221
									
								
								src/hosts/core/parser.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								src/hosts/core/parser.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,221 @@
 | 
			
		|||
"""
 | 
			
		||||
Hosts file parser for the hosts TUI application.
 | 
			
		||||
 | 
			
		||||
This module handles reading and writing hosts files, preserving comments
 | 
			
		||||
and maintaining file structure integrity.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import os
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from .models import HostEntry, HostsFile
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HostsParser:
 | 
			
		||||
    """
 | 
			
		||||
    Parser for reading and writing hosts files.
 | 
			
		||||
    
 | 
			
		||||
    Handles the complete hosts file format including comments,
 | 
			
		||||
    blank lines, and both active and inactive entries.
 | 
			
		||||
    """
 | 
			
		||||
    
 | 
			
		||||
    def __init__(self, file_path: str = "/etc/hosts"):
 | 
			
		||||
        """
 | 
			
		||||
        Initialize the parser with a hosts file path.
 | 
			
		||||
        
 | 
			
		||||
        Args:
 | 
			
		||||
            file_path: Path to the hosts file (default: /etc/hosts)
 | 
			
		||||
        """
 | 
			
		||||
        self.file_path = Path(file_path)
 | 
			
		||||
    
 | 
			
		||||
    def parse(self) -> HostsFile:
 | 
			
		||||
        """
 | 
			
		||||
        Parse the hosts file into a HostsFile object.
 | 
			
		||||
        
 | 
			
		||||
        Returns:
 | 
			
		||||
            HostsFile object containing all parsed entries and comments
 | 
			
		||||
            
 | 
			
		||||
        Raises:
 | 
			
		||||
            FileNotFoundError: If the hosts file doesn't exist
 | 
			
		||||
            PermissionError: If the file cannot be read
 | 
			
		||||
        """
 | 
			
		||||
        if not self.file_path.exists():
 | 
			
		||||
            raise FileNotFoundError(f"Hosts file not found: {self.file_path}")
 | 
			
		||||
        
 | 
			
		||||
        try:
 | 
			
		||||
            with open(self.file_path, 'r', encoding='utf-8') as f:
 | 
			
		||||
                lines = f.readlines()
 | 
			
		||||
        except PermissionError:
 | 
			
		||||
            raise PermissionError(f"Permission denied reading hosts file: {self.file_path}")
 | 
			
		||||
        
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        entries_started = False
 | 
			
		||||
        
 | 
			
		||||
        for line_num, line in enumerate(lines, 1):
 | 
			
		||||
            stripped_line = line.strip()
 | 
			
		||||
            
 | 
			
		||||
            # Try to parse as a host entry
 | 
			
		||||
            entry = HostEntry.from_hosts_line(stripped_line)
 | 
			
		||||
            
 | 
			
		||||
            if entry is not None:
 | 
			
		||||
                # This is a valid host entry
 | 
			
		||||
                hosts_file.entries.append(entry)
 | 
			
		||||
                entries_started = True
 | 
			
		||||
            elif stripped_line and not entries_started:
 | 
			
		||||
                # This is a comment before any entries (header)
 | 
			
		||||
                if stripped_line.startswith('#'):
 | 
			
		||||
                    comment_text = stripped_line[1:].strip()
 | 
			
		||||
                    hosts_file.header_comments.append(comment_text)
 | 
			
		||||
                else:
 | 
			
		||||
                    # Non-comment, non-entry line before entries
 | 
			
		||||
                    hosts_file.header_comments.append(stripped_line)
 | 
			
		||||
            elif stripped_line and entries_started:
 | 
			
		||||
                # This is a comment after entries have started
 | 
			
		||||
                if stripped_line.startswith('#'):
 | 
			
		||||
                    comment_text = stripped_line[1:].strip()
 | 
			
		||||
                    hosts_file.footer_comments.append(comment_text)
 | 
			
		||||
                else:
 | 
			
		||||
                    # Non-comment, non-entry line after entries
 | 
			
		||||
                    hosts_file.footer_comments.append(stripped_line)
 | 
			
		||||
            # Empty lines are ignored but structure is preserved in serialization
 | 
			
		||||
        
 | 
			
		||||
        return hosts_file
 | 
			
		||||
    
 | 
			
		||||
    def serialize(self, hosts_file: HostsFile) -> str:
 | 
			
		||||
        """
 | 
			
		||||
        Convert a HostsFile object back to hosts file format.
 | 
			
		||||
        
 | 
			
		||||
        Args:
 | 
			
		||||
            hosts_file: HostsFile object to serialize
 | 
			
		||||
            
 | 
			
		||||
        Returns:
 | 
			
		||||
            String representation of the hosts file
 | 
			
		||||
        """
 | 
			
		||||
        lines = []
 | 
			
		||||
        
 | 
			
		||||
        # Add header comments
 | 
			
		||||
        if hosts_file.header_comments:
 | 
			
		||||
            for comment in hosts_file.header_comments:
 | 
			
		||||
                if comment.strip():
 | 
			
		||||
                    lines.append(f"# {comment}")
 | 
			
		||||
                else:
 | 
			
		||||
                    lines.append("#")
 | 
			
		||||
            lines.append("")  # Blank line after header
 | 
			
		||||
        
 | 
			
		||||
        # Add host entries
 | 
			
		||||
        for entry in hosts_file.entries:
 | 
			
		||||
            lines.append(entry.to_hosts_line())
 | 
			
		||||
        
 | 
			
		||||
        # Add footer comments
 | 
			
		||||
        if hosts_file.footer_comments:
 | 
			
		||||
            lines.append("")  # Blank line before footer
 | 
			
		||||
            for comment in hosts_file.footer_comments:
 | 
			
		||||
                if comment.strip():
 | 
			
		||||
                    lines.append(f"# {comment}")
 | 
			
		||||
                else:
 | 
			
		||||
                    lines.append("#")
 | 
			
		||||
        
 | 
			
		||||
        return "\n".join(lines) + "\n"
 | 
			
		||||
    
 | 
			
		||||
    def write(self, hosts_file: HostsFile, backup: bool = True) -> None:
 | 
			
		||||
        """
 | 
			
		||||
        Write a HostsFile object to the hosts file.
 | 
			
		||||
        
 | 
			
		||||
        Args:
 | 
			
		||||
            hosts_file: HostsFile object to write
 | 
			
		||||
            backup: Whether to create a backup before writing
 | 
			
		||||
            
 | 
			
		||||
        Raises:
 | 
			
		||||
            PermissionError: If the file cannot be written
 | 
			
		||||
            OSError: If there's an error during file operations
 | 
			
		||||
        """
 | 
			
		||||
        # Create backup if requested
 | 
			
		||||
        if backup and self.file_path.exists():
 | 
			
		||||
            backup_path = self.file_path.with_suffix('.bak')
 | 
			
		||||
            try:
 | 
			
		||||
                import shutil
 | 
			
		||||
                shutil.copy2(self.file_path, backup_path)
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                raise OSError(f"Failed to create backup: {e}")
 | 
			
		||||
        
 | 
			
		||||
        # Serialize the hosts file
 | 
			
		||||
        content = self.serialize(hosts_file)
 | 
			
		||||
        
 | 
			
		||||
        # Write atomically using a temporary file
 | 
			
		||||
        temp_path = self.file_path.with_suffix('.tmp')
 | 
			
		||||
        try:
 | 
			
		||||
            with open(temp_path, 'w', encoding='utf-8') as f:
 | 
			
		||||
                f.write(content)
 | 
			
		||||
            
 | 
			
		||||
            # Atomic move
 | 
			
		||||
            temp_path.replace(self.file_path)
 | 
			
		||||
            
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            # Clean up temp file if it exists
 | 
			
		||||
            if temp_path.exists():
 | 
			
		||||
                temp_path.unlink()
 | 
			
		||||
            raise OSError(f"Failed to write hosts file: {e}")
 | 
			
		||||
    
 | 
			
		||||
    def validate_write_permissions(self) -> bool:
 | 
			
		||||
        """
 | 
			
		||||
        Check if we have write permissions to the hosts file.
 | 
			
		||||
        
 | 
			
		||||
        Returns:
 | 
			
		||||
            True if we can write to the file, False otherwise
 | 
			
		||||
        """
 | 
			
		||||
        try:
 | 
			
		||||
            # Check if file exists and is writable
 | 
			
		||||
            if self.file_path.exists():
 | 
			
		||||
                return os.access(self.file_path, os.W_OK)
 | 
			
		||||
            else:
 | 
			
		||||
                # Check if parent directory is writable
 | 
			
		||||
                return os.access(self.file_path.parent, os.W_OK)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return False
 | 
			
		||||
    
 | 
			
		||||
    def get_file_info(self) -> dict:
 | 
			
		||||
        """
 | 
			
		||||
        Get information about the hosts file.
 | 
			
		||||
        
 | 
			
		||||
        Returns:
 | 
			
		||||
            Dictionary with file information
 | 
			
		||||
        """
 | 
			
		||||
        info = {
 | 
			
		||||
            'path': str(self.file_path),
 | 
			
		||||
            'exists': self.file_path.exists(),
 | 
			
		||||
            'readable': False,
 | 
			
		||||
            'writable': False,
 | 
			
		||||
            'size': 0,
 | 
			
		||||
            'modified': None
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        if info['exists']:
 | 
			
		||||
            try:
 | 
			
		||||
                info['readable'] = os.access(self.file_path, os.R_OK)
 | 
			
		||||
                info['writable'] = os.access(self.file_path, os.W_OK)
 | 
			
		||||
                stat = self.file_path.stat()
 | 
			
		||||
                info['size'] = stat.st_size
 | 
			
		||||
                info['modified'] = stat.st_mtime
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
        
 | 
			
		||||
        return info
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HostsParserError(Exception):
 | 
			
		||||
    """Base exception for hosts parser errors."""
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HostsFileNotFoundError(HostsParserError):
 | 
			
		||||
    """Raised when the hosts file is not found."""
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HostsPermissionError(HostsParserError):
 | 
			
		||||
    """Raised when there are permission issues with the hosts file."""
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HostsValidationError(HostsParserError):
 | 
			
		||||
    """Raised when hosts file content is invalid."""
 | 
			
		||||
    pass
 | 
			
		||||
							
								
								
									
										251
									
								
								src/hosts/main.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										251
									
								
								src/hosts/main.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,251 @@
 | 
			
		|||
"""
 | 
			
		||||
Main entry point for the hosts TUI application.
 | 
			
		||||
 | 
			
		||||
This module contains the main application class and entry point function.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
from textual.app import App, ComposeResult
 | 
			
		||||
from textual.containers import Horizontal, Vertical
 | 
			
		||||
from textual.widgets import Header, Footer, Static, ListView, ListItem, Label
 | 
			
		||||
from textual.binding import Binding
 | 
			
		||||
from textual.reactive import reactive
 | 
			
		||||
 | 
			
		||||
from .core.parser import HostsParser
 | 
			
		||||
from .core.models import HostsFile
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HostsManagerApp(App):
 | 
			
		||||
    """
 | 
			
		||||
    Main application class for the hosts TUI manager.
 | 
			
		||||
    
 | 
			
		||||
    Provides a two-pane interface for managing hosts file entries
 | 
			
		||||
    with read-only mode by default and explicit edit mode.
 | 
			
		||||
    """
 | 
			
		||||
    
 | 
			
		||||
    CSS = """
 | 
			
		||||
    .hosts-container {
 | 
			
		||||
        height: 100%;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    .left-pane {
 | 
			
		||||
        width: 60%;
 | 
			
		||||
        border: solid $primary;
 | 
			
		||||
        margin: 1;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    .right-pane {
 | 
			
		||||
        width: 40%;
 | 
			
		||||
        border: solid $primary;
 | 
			
		||||
        margin: 1;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    .entry-active {
 | 
			
		||||
        color: $success;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    .entry-inactive {
 | 
			
		||||
        color: $warning;
 | 
			
		||||
        text-style: italic;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    .status-bar {
 | 
			
		||||
        background: $surface;
 | 
			
		||||
        color: $text;
 | 
			
		||||
        height: 1;
 | 
			
		||||
        padding: 0 1;
 | 
			
		||||
    }
 | 
			
		||||
    """
 | 
			
		||||
    
 | 
			
		||||
    BINDINGS = [
 | 
			
		||||
        Binding("q", "quit", "Quit"),
 | 
			
		||||
        Binding("r", "reload", "Reload"),
 | 
			
		||||
        Binding("h", "help", "Help"),
 | 
			
		||||
        ("ctrl+c", "quit", "Quit"),
 | 
			
		||||
    ]
 | 
			
		||||
    
 | 
			
		||||
    # Reactive attributes
 | 
			
		||||
    hosts_file: reactive[HostsFile] = reactive(HostsFile())
 | 
			
		||||
    selected_entry_index: reactive[int] = reactive(0)
 | 
			
		||||
    edit_mode: reactive[bool] = reactive(False)
 | 
			
		||||
    
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.parser = HostsParser()
 | 
			
		||||
        self.title = "Hosts Manager"
 | 
			
		||||
        self.sub_title = "Read-only mode"
 | 
			
		||||
    
 | 
			
		||||
    def compose(self) -> ComposeResult:
 | 
			
		||||
        """Create the application layout."""
 | 
			
		||||
        yield Header()
 | 
			
		||||
        
 | 
			
		||||
        with Horizontal(classes="hosts-container"):
 | 
			
		||||
            with Vertical(classes="left-pane"):
 | 
			
		||||
                yield Static("Hosts Entries", id="left-header")
 | 
			
		||||
                yield ListView(id="entries-list")
 | 
			
		||||
            
 | 
			
		||||
            with Vertical(classes="right-pane"):
 | 
			
		||||
                yield Static("Entry Details", id="right-header")
 | 
			
		||||
                yield Static("", id="entry-details")
 | 
			
		||||
        
 | 
			
		||||
        yield Static("", classes="status-bar", id="status")
 | 
			
		||||
        yield Footer()
 | 
			
		||||
    
 | 
			
		||||
    def on_ready(self) -> None:
 | 
			
		||||
        """Initialize the application when ready."""
 | 
			
		||||
        self.load_hosts_file()
 | 
			
		||||
        self.update_status()
 | 
			
		||||
    
 | 
			
		||||
    def load_hosts_file(self) -> None:
 | 
			
		||||
        """Load the hosts file and populate the interface."""
 | 
			
		||||
        # Remember current selection for restoration
 | 
			
		||||
        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]
 | 
			
		||||
        
 | 
			
		||||
        try:
 | 
			
		||||
            self.hosts_file = self.parser.parse()
 | 
			
		||||
            self.populate_entries_list()
 | 
			
		||||
            
 | 
			
		||||
            # Restore cursor position with a timer to ensure ListView is fully rendered
 | 
			
		||||
            self.set_timer(0.1, lambda: self.restore_cursor_position(current_entry))
 | 
			
		||||
            
 | 
			
		||||
            self.update_entry_details()
 | 
			
		||||
            self.log(f"Loaded {len(self.hosts_file.entries)} entries from hosts file")
 | 
			
		||||
        except FileNotFoundError:
 | 
			
		||||
            self.log("Hosts file not found")
 | 
			
		||||
            self.update_status("Error: Hosts file not found")
 | 
			
		||||
        except PermissionError:
 | 
			
		||||
            self.log("Permission denied reading hosts file")
 | 
			
		||||
            self.update_status("Error: Permission denied")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            self.log(f"Error loading hosts file: {e}")
 | 
			
		||||
            self.update_status(f"Error: {e}")
 | 
			
		||||
    
 | 
			
		||||
    def populate_entries_list(self) -> None:
 | 
			
		||||
        """Populate the left pane with hosts entries."""
 | 
			
		||||
        entries_list = self.query_one("#entries-list", ListView)
 | 
			
		||||
        entries_list.clear()
 | 
			
		||||
        
 | 
			
		||||
        for i, entry in enumerate(self.hosts_file.entries):
 | 
			
		||||
            # Format entry display
 | 
			
		||||
            hostnames_str = ", ".join(entry.hostnames)
 | 
			
		||||
            display_text = f"{entry.ip_address} → {hostnames_str}"
 | 
			
		||||
            
 | 
			
		||||
            if entry.comment:
 | 
			
		||||
                display_text += f" # {entry.comment}"
 | 
			
		||||
            
 | 
			
		||||
            # Create list item with appropriate styling
 | 
			
		||||
            item = ListItem(
 | 
			
		||||
                Label(display_text),
 | 
			
		||||
                classes="entry-active" if entry.is_active else "entry-inactive"
 | 
			
		||||
            )
 | 
			
		||||
            entries_list.append(item)
 | 
			
		||||
    
 | 
			
		||||
    def restore_cursor_position(self, previous_entry) -> None:
 | 
			
		||||
        """Restore cursor position after reload, maintaining selection if possible."""
 | 
			
		||||
        if not self.hosts_file.entries:
 | 
			
		||||
            self.selected_entry_index = 0
 | 
			
		||||
            return
 | 
			
		||||
        
 | 
			
		||||
        if previous_entry is None:
 | 
			
		||||
            # No previous selection, start at first entry
 | 
			
		||||
            self.selected_entry_index = 0
 | 
			
		||||
        else:
 | 
			
		||||
            # Try to find the same entry in the reloaded file
 | 
			
		||||
            for i, entry in enumerate(self.hosts_file.entries):
 | 
			
		||||
                if (entry.ip_address == previous_entry.ip_address and 
 | 
			
		||||
                    entry.hostnames == previous_entry.hostnames and
 | 
			
		||||
                    entry.comment == previous_entry.comment):
 | 
			
		||||
                    self.selected_entry_index = i
 | 
			
		||||
                    break
 | 
			
		||||
            else:
 | 
			
		||||
                # Entry not found, default to first entry
 | 
			
		||||
                self.selected_entry_index = 0
 | 
			
		||||
        
 | 
			
		||||
        # Update the ListView selection and ensure it's highlighted
 | 
			
		||||
        entries_list = self.query_one("#entries-list", ListView)
 | 
			
		||||
        if entries_list.children and self.selected_entry_index < len(entries_list.children):
 | 
			
		||||
            # Set the index and focus the ListView
 | 
			
		||||
            entries_list.index = self.selected_entry_index
 | 
			
		||||
            entries_list.focus()
 | 
			
		||||
            # Force refresh of the selection highlighting
 | 
			
		||||
            entries_list.refresh()
 | 
			
		||||
            # Update the details pane to match the selection
 | 
			
		||||
            self.update_entry_details()
 | 
			
		||||
    
 | 
			
		||||
    def update_entry_details(self) -> None:
 | 
			
		||||
        """Update the right pane with selected entry details."""
 | 
			
		||||
        details_widget = self.query_one("#entry-details", Static)
 | 
			
		||||
        
 | 
			
		||||
        if not self.hosts_file.entries:
 | 
			
		||||
            details_widget.update("No entries loaded")
 | 
			
		||||
            return
 | 
			
		||||
        
 | 
			
		||||
        if self.selected_entry_index >= len(self.hosts_file.entries):
 | 
			
		||||
            self.selected_entry_index = 0
 | 
			
		||||
        
 | 
			
		||||
        entry = self.hosts_file.entries[self.selected_entry_index]
 | 
			
		||||
        
 | 
			
		||||
        details_lines = [
 | 
			
		||||
            f"IP Address: {entry.ip_address}",
 | 
			
		||||
            f"Hostnames: {', '.join(entry.hostnames)}",
 | 
			
		||||
            f"Status: {'Active' if entry.is_active else 'Inactive'}",
 | 
			
		||||
        ]
 | 
			
		||||
        
 | 
			
		||||
        if entry.comment:
 | 
			
		||||
            details_lines.append(f"Comment: {entry.comment}")
 | 
			
		||||
        
 | 
			
		||||
        if entry.dns_name:
 | 
			
		||||
            details_lines.append(f"DNS Name: {entry.dns_name}")
 | 
			
		||||
        
 | 
			
		||||
        details_widget.update("\n".join(details_lines))
 | 
			
		||||
    
 | 
			
		||||
    def update_status(self, message: str = "") -> None:
 | 
			
		||||
        """Update the status bar."""
 | 
			
		||||
        status_widget = self.query_one("#status", Static)
 | 
			
		||||
        
 | 
			
		||||
        if message:
 | 
			
		||||
            status_widget.update(message)
 | 
			
		||||
        else:
 | 
			
		||||
            mode = "Edit mode" if self.edit_mode else "Read-only mode"
 | 
			
		||||
            entry_count = len(self.hosts_file.entries)
 | 
			
		||||
            active_count = len(self.hosts_file.get_active_entries())
 | 
			
		||||
            
 | 
			
		||||
            status_text = f"{mode} | {entry_count} entries ({active_count} active)"
 | 
			
		||||
            
 | 
			
		||||
            # Add file info
 | 
			
		||||
            file_info = self.parser.get_file_info()
 | 
			
		||||
            if file_info['exists']:
 | 
			
		||||
                status_text += f" | {file_info['path']}"
 | 
			
		||||
            
 | 
			
		||||
            status_widget.update(status_text)
 | 
			
		||||
    
 | 
			
		||||
    def on_list_view_selected(self, event: ListView.Selected) -> None:
 | 
			
		||||
        """Handle entry selection in the left pane."""
 | 
			
		||||
        if event.list_view.id == "entries-list":
 | 
			
		||||
            self.selected_entry_index = event.list_view.index or 0
 | 
			
		||||
            self.update_entry_details()
 | 
			
		||||
    
 | 
			
		||||
    def action_reload(self) -> None:
 | 
			
		||||
        """Reload the hosts file."""
 | 
			
		||||
        self.load_hosts_file()
 | 
			
		||||
        self.update_status("Hosts file reloaded")
 | 
			
		||||
    
 | 
			
		||||
    def action_help(self) -> None:
 | 
			
		||||
        """Show help information."""
 | 
			
		||||
        # For now, just update the status with help info
 | 
			
		||||
        self.update_status("Help: ↑/↓ Navigate, r Reload, q Quit, h Help")
 | 
			
		||||
    
 | 
			
		||||
    def action_quit(self) -> None:
 | 
			
		||||
        """Quit the application."""
 | 
			
		||||
        self.exit()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    """Main entry point for the hosts application."""
 | 
			
		||||
    app = HostsManagerApp()
 | 
			
		||||
    app.run()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    main()
 | 
			
		||||
							
								
								
									
										6
									
								
								src/hosts/tui/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/hosts/tui/__init__.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
"""
 | 
			
		||||
TUI components for the hosts application.
 | 
			
		||||
 | 
			
		||||
This module contains the Textual-based user interface components
 | 
			
		||||
for displaying and interacting with hosts file entries.
 | 
			
		||||
"""
 | 
			
		||||
							
								
								
									
										6
									
								
								tests/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								tests/__init__.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
"""
 | 
			
		||||
Test suite for the hosts TUI application.
 | 
			
		||||
 | 
			
		||||
This module contains unit tests, integration tests, and TUI tests
 | 
			
		||||
for validating the functionality of the hosts manager.
 | 
			
		||||
"""
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								tests/__pycache__/__init__.cpython-313.pyc
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								tests/__pycache__/__init__.cpython-313.pyc
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								tests/__pycache__/test_models.cpython-313-pytest-8.4.1.pyc
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								tests/__pycache__/test_models.cpython-313-pytest-8.4.1.pyc
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								tests/__pycache__/test_parser.cpython-313-pytest-8.4.1.pyc
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								tests/__pycache__/test_parser.cpython-313-pytest-8.4.1.pyc
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										298
									
								
								tests/test_models.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										298
									
								
								tests/test_models.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,298 @@
 | 
			
		|||
"""
 | 
			
		||||
Tests for the hosts data models.
 | 
			
		||||
 | 
			
		||||
This module contains unit tests for the HostEntry and HostsFile classes,
 | 
			
		||||
validating their functionality and data integrity.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
from hosts.core.models import HostEntry, HostsFile
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestHostEntry:
 | 
			
		||||
    """Test cases for the HostEntry class."""
 | 
			
		||||
    
 | 
			
		||||
    def test_host_entry_creation(self):
 | 
			
		||||
        """Test basic host entry creation."""
 | 
			
		||||
        entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
 | 
			
		||||
        assert entry.ip_address == "127.0.0.1"
 | 
			
		||||
        assert entry.hostnames == ["localhost"]
 | 
			
		||||
        assert entry.is_active is True
 | 
			
		||||
        assert entry.comment is None
 | 
			
		||||
        assert entry.dns_name is None
 | 
			
		||||
    
 | 
			
		||||
    def test_host_entry_with_comment(self):
 | 
			
		||||
        """Test host entry creation with comment."""
 | 
			
		||||
        entry = HostEntry(
 | 
			
		||||
            ip_address="192.168.1.1",
 | 
			
		||||
            hostnames=["router", "gateway"],
 | 
			
		||||
            comment="Local router"
 | 
			
		||||
        )
 | 
			
		||||
        assert entry.comment == "Local router"
 | 
			
		||||
    
 | 
			
		||||
    def test_host_entry_inactive(self):
 | 
			
		||||
        """Test inactive host entry creation."""
 | 
			
		||||
        entry = HostEntry(
 | 
			
		||||
            ip_address="10.0.0.1",
 | 
			
		||||
            hostnames=["test.local"],
 | 
			
		||||
            is_active=False
 | 
			
		||||
        )
 | 
			
		||||
        assert entry.is_active is False
 | 
			
		||||
    
 | 
			
		||||
    def test_invalid_ip_address(self):
 | 
			
		||||
        """Test that invalid IP addresses raise ValueError."""
 | 
			
		||||
        with pytest.raises(ValueError, match="Invalid IP address"):
 | 
			
		||||
            HostEntry(ip_address="invalid.ip", hostnames=["test"])
 | 
			
		||||
    
 | 
			
		||||
    def test_empty_hostnames(self):
 | 
			
		||||
        """Test that empty hostnames list raises ValueError."""
 | 
			
		||||
        with pytest.raises(ValueError, match="At least one hostname is required"):
 | 
			
		||||
            HostEntry(ip_address="127.0.0.1", hostnames=[])
 | 
			
		||||
    
 | 
			
		||||
    def test_invalid_hostname(self):
 | 
			
		||||
        """Test that invalid hostnames raise ValueError."""
 | 
			
		||||
        with pytest.raises(ValueError, match="Invalid hostname"):
 | 
			
		||||
            HostEntry(ip_address="127.0.0.1", hostnames=["invalid..hostname"])
 | 
			
		||||
    
 | 
			
		||||
    def test_ipv6_address(self):
 | 
			
		||||
        """Test IPv6 address support."""
 | 
			
		||||
        entry = HostEntry(ip_address="::1", hostnames=["localhost"])
 | 
			
		||||
        assert entry.ip_address == "::1"
 | 
			
		||||
    
 | 
			
		||||
    def test_to_hosts_line_active(self):
 | 
			
		||||
        """Test conversion to hosts file line format for active entry."""
 | 
			
		||||
        entry = HostEntry(
 | 
			
		||||
            ip_address="127.0.0.1",
 | 
			
		||||
            hostnames=["localhost", "local"],
 | 
			
		||||
            comment="Loopback"
 | 
			
		||||
        )
 | 
			
		||||
        line = entry.to_hosts_line()
 | 
			
		||||
        assert line == "127.0.0.1 localhost local # Loopback"
 | 
			
		||||
    
 | 
			
		||||
    def test_to_hosts_line_inactive(self):
 | 
			
		||||
        """Test conversion to hosts file line format for inactive entry."""
 | 
			
		||||
        entry = HostEntry(
 | 
			
		||||
            ip_address="192.168.1.1",
 | 
			
		||||
            hostnames=["router"],
 | 
			
		||||
            is_active=False
 | 
			
		||||
        )
 | 
			
		||||
        line = entry.to_hosts_line()
 | 
			
		||||
        assert line == "# 192.168.1.1 router"
 | 
			
		||||
    
 | 
			
		||||
    def test_from_hosts_line_simple(self):
 | 
			
		||||
        """Test parsing simple hosts file line."""
 | 
			
		||||
        line = "127.0.0.1 localhost"
 | 
			
		||||
        entry = HostEntry.from_hosts_line(line)
 | 
			
		||||
        
 | 
			
		||||
        assert entry is not None
 | 
			
		||||
        assert entry.ip_address == "127.0.0.1"
 | 
			
		||||
        assert entry.hostnames == ["localhost"]
 | 
			
		||||
        assert entry.is_active is True
 | 
			
		||||
        assert entry.comment is None
 | 
			
		||||
    
 | 
			
		||||
    def test_from_hosts_line_with_comment(self):
 | 
			
		||||
        """Test parsing hosts file line with comment."""
 | 
			
		||||
        line = "192.168.1.1 router gateway # Local network"
 | 
			
		||||
        entry = HostEntry.from_hosts_line(line)
 | 
			
		||||
        
 | 
			
		||||
        assert entry is not None
 | 
			
		||||
        assert entry.ip_address == "192.168.1.1"
 | 
			
		||||
        assert entry.hostnames == ["router", "gateway"]
 | 
			
		||||
        assert entry.comment == "Local network"
 | 
			
		||||
    
 | 
			
		||||
    def test_from_hosts_line_inactive(self):
 | 
			
		||||
        """Test parsing inactive hosts file line."""
 | 
			
		||||
        line = "# 10.0.0.1 test.local"
 | 
			
		||||
        entry = HostEntry.from_hosts_line(line)
 | 
			
		||||
        
 | 
			
		||||
        assert entry is not None
 | 
			
		||||
        assert entry.ip_address == "10.0.0.1"
 | 
			
		||||
        assert entry.hostnames == ["test.local"]
 | 
			
		||||
        assert entry.is_active is False
 | 
			
		||||
    
 | 
			
		||||
    def test_from_hosts_line_empty(self):
 | 
			
		||||
        """Test parsing empty line returns None."""
 | 
			
		||||
        assert HostEntry.from_hosts_line("") is None
 | 
			
		||||
        assert HostEntry.from_hosts_line("   ") is None
 | 
			
		||||
    
 | 
			
		||||
    def test_from_hosts_line_comment_only(self):
 | 
			
		||||
        """Test parsing comment-only line returns None."""
 | 
			
		||||
        assert HostEntry.from_hosts_line("# This is just a comment") is None
 | 
			
		||||
    
 | 
			
		||||
    def test_from_hosts_line_invalid(self):
 | 
			
		||||
        """Test parsing invalid line returns None."""
 | 
			
		||||
        assert HostEntry.from_hosts_line("invalid line") is None
 | 
			
		||||
        assert HostEntry.from_hosts_line("192.168.1.1") is None  # No hostname
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestHostsFile:
 | 
			
		||||
    """Test cases for the HostsFile class."""
 | 
			
		||||
    
 | 
			
		||||
    def test_hosts_file_creation(self):
 | 
			
		||||
        """Test basic hosts file creation."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        assert len(hosts_file.entries) == 0
 | 
			
		||||
        assert len(hosts_file.header_comments) == 0
 | 
			
		||||
        assert len(hosts_file.footer_comments) == 0
 | 
			
		||||
    
 | 
			
		||||
    def test_add_entry(self):
 | 
			
		||||
        """Test adding entries to hosts file."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
 | 
			
		||||
        
 | 
			
		||||
        hosts_file.add_entry(entry)
 | 
			
		||||
        assert len(hosts_file.entries) == 1
 | 
			
		||||
        assert hosts_file.entries[0] == entry
 | 
			
		||||
    
 | 
			
		||||
    def test_add_invalid_entry(self):
 | 
			
		||||
        """Test that adding invalid entry raises ValueError."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        
 | 
			
		||||
        with pytest.raises(ValueError):
 | 
			
		||||
            # This will fail validation in add_entry
 | 
			
		||||
            invalid_entry = HostEntry.__new__(HostEntry)  # Bypass __init__
 | 
			
		||||
            invalid_entry.ip_address = "invalid"
 | 
			
		||||
            invalid_entry.hostnames = ["test"]
 | 
			
		||||
            hosts_file.add_entry(invalid_entry)
 | 
			
		||||
    
 | 
			
		||||
    def test_remove_entry(self):
 | 
			
		||||
        """Test removing entries from hosts file."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
 | 
			
		||||
        entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
 | 
			
		||||
        
 | 
			
		||||
        hosts_file.add_entry(entry1)
 | 
			
		||||
        hosts_file.add_entry(entry2)
 | 
			
		||||
        
 | 
			
		||||
        hosts_file.remove_entry(0)
 | 
			
		||||
        assert len(hosts_file.entries) == 1
 | 
			
		||||
        assert hosts_file.entries[0] == entry2
 | 
			
		||||
    
 | 
			
		||||
    def test_remove_entry_invalid_index(self):
 | 
			
		||||
        """Test removing entry with invalid index does nothing."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
 | 
			
		||||
        hosts_file.add_entry(entry)
 | 
			
		||||
        
 | 
			
		||||
        hosts_file.remove_entry(10)  # Invalid index
 | 
			
		||||
        assert len(hosts_file.entries) == 1
 | 
			
		||||
    
 | 
			
		||||
    def test_toggle_entry(self):
 | 
			
		||||
        """Test toggling entry active state."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
 | 
			
		||||
        hosts_file.add_entry(entry)
 | 
			
		||||
        
 | 
			
		||||
        assert entry.is_active is True
 | 
			
		||||
        hosts_file.toggle_entry(0)
 | 
			
		||||
        assert entry.is_active is False
 | 
			
		||||
        hosts_file.toggle_entry(0)
 | 
			
		||||
        assert entry.is_active is True
 | 
			
		||||
    
 | 
			
		||||
    def test_get_active_entries(self):
 | 
			
		||||
        """Test getting only active entries."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
 | 
			
		||||
        inactive_entry = HostEntry(
 | 
			
		||||
            ip_address="192.168.1.1",
 | 
			
		||||
            hostnames=["router"],
 | 
			
		||||
            is_active=False
 | 
			
		||||
        )
 | 
			
		||||
        
 | 
			
		||||
        hosts_file.add_entry(active_entry)
 | 
			
		||||
        hosts_file.add_entry(inactive_entry)
 | 
			
		||||
        
 | 
			
		||||
        active_entries = hosts_file.get_active_entries()
 | 
			
		||||
        assert len(active_entries) == 1
 | 
			
		||||
        assert active_entries[0] == active_entry
 | 
			
		||||
    
 | 
			
		||||
    def test_get_inactive_entries(self):
 | 
			
		||||
        """Test getting only inactive entries."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
 | 
			
		||||
        inactive_entry = HostEntry(
 | 
			
		||||
            ip_address="192.168.1.1",
 | 
			
		||||
            hostnames=["router"],
 | 
			
		||||
            is_active=False
 | 
			
		||||
        )
 | 
			
		||||
        
 | 
			
		||||
        hosts_file.add_entry(active_entry)
 | 
			
		||||
        hosts_file.add_entry(inactive_entry)
 | 
			
		||||
        
 | 
			
		||||
        inactive_entries = hosts_file.get_inactive_entries()
 | 
			
		||||
        assert len(inactive_entries) == 1
 | 
			
		||||
        assert inactive_entries[0] == inactive_entry
 | 
			
		||||
    
 | 
			
		||||
    def test_sort_by_ip(self):
 | 
			
		||||
        """Test sorting entries by IP address."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
 | 
			
		||||
        entry2 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
 | 
			
		||||
        entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["test"])
 | 
			
		||||
        
 | 
			
		||||
        hosts_file.add_entry(entry1)
 | 
			
		||||
        hosts_file.add_entry(entry2)
 | 
			
		||||
        hosts_file.add_entry(entry3)
 | 
			
		||||
        
 | 
			
		||||
        hosts_file.sort_by_ip()
 | 
			
		||||
        
 | 
			
		||||
        assert hosts_file.entries[0].ip_address == "10.0.0.1"
 | 
			
		||||
        assert hosts_file.entries[1].ip_address == "127.0.0.1"
 | 
			
		||||
        assert hosts_file.entries[2].ip_address == "192.168.1.1"
 | 
			
		||||
    
 | 
			
		||||
    def test_sort_by_hostname(self):
 | 
			
		||||
        """Test sorting entries by hostname."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["zebra"])
 | 
			
		||||
        entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["alpha"])
 | 
			
		||||
        entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["beta"])
 | 
			
		||||
        
 | 
			
		||||
        hosts_file.add_entry(entry1)
 | 
			
		||||
        hosts_file.add_entry(entry2)
 | 
			
		||||
        hosts_file.add_entry(entry3)
 | 
			
		||||
        
 | 
			
		||||
        hosts_file.sort_by_hostname()
 | 
			
		||||
        
 | 
			
		||||
        assert hosts_file.entries[0].hostnames[0] == "alpha"
 | 
			
		||||
        assert hosts_file.entries[1].hostnames[0] == "beta"
 | 
			
		||||
        assert hosts_file.entries[2].hostnames[0] == "zebra"
 | 
			
		||||
    
 | 
			
		||||
    def test_find_entries_by_hostname(self):
 | 
			
		||||
        """Test finding entries by hostname."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost", "local"])
 | 
			
		||||
        entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
 | 
			
		||||
        entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["test", "localhost"])
 | 
			
		||||
        
 | 
			
		||||
        hosts_file.add_entry(entry1)
 | 
			
		||||
        hosts_file.add_entry(entry2)
 | 
			
		||||
        hosts_file.add_entry(entry3)
 | 
			
		||||
        
 | 
			
		||||
        indices = hosts_file.find_entries_by_hostname("localhost")
 | 
			
		||||
        assert indices == [0, 2]
 | 
			
		||||
        
 | 
			
		||||
        indices = hosts_file.find_entries_by_hostname("router")
 | 
			
		||||
        assert indices == [1]
 | 
			
		||||
        
 | 
			
		||||
        indices = hosts_file.find_entries_by_hostname("nonexistent")
 | 
			
		||||
        assert indices == []
 | 
			
		||||
    
 | 
			
		||||
    def test_find_entries_by_ip(self):
 | 
			
		||||
        """Test finding entries by IP address."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
 | 
			
		||||
        entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
 | 
			
		||||
        entry3 = HostEntry(ip_address="127.0.0.1", hostnames=["local"])
 | 
			
		||||
        
 | 
			
		||||
        hosts_file.add_entry(entry1)
 | 
			
		||||
        hosts_file.add_entry(entry2)
 | 
			
		||||
        hosts_file.add_entry(entry3)
 | 
			
		||||
        
 | 
			
		||||
        indices = hosts_file.find_entries_by_ip("127.0.0.1")
 | 
			
		||||
        assert indices == [0, 2]
 | 
			
		||||
        
 | 
			
		||||
        indices = hosts_file.find_entries_by_ip("192.168.1.1")
 | 
			
		||||
        assert indices == [1]
 | 
			
		||||
        
 | 
			
		||||
        indices = hosts_file.find_entries_by_ip("10.0.0.1")
 | 
			
		||||
        assert indices == []
 | 
			
		||||
							
								
								
									
										353
									
								
								tests/test_parser.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										353
									
								
								tests/test_parser.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,353 @@
 | 
			
		|||
"""
 | 
			
		||||
Tests for the hosts file parser.
 | 
			
		||||
 | 
			
		||||
This module contains unit tests for the HostsParser class,
 | 
			
		||||
validating file parsing and serialization functionality.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
import tempfile
 | 
			
		||||
import os
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from hosts.core.parser import HostsParser
 | 
			
		||||
from hosts.core.models import HostEntry, HostsFile
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestHostsParser:
 | 
			
		||||
    """Test cases for the HostsParser class."""
 | 
			
		||||
    
 | 
			
		||||
    def test_parser_initialization(self):
 | 
			
		||||
        """Test parser initialization with default and custom paths."""
 | 
			
		||||
        # Default path
 | 
			
		||||
        parser = HostsParser()
 | 
			
		||||
        assert str(parser.file_path) == "/etc/hosts"
 | 
			
		||||
        
 | 
			
		||||
        # Custom path
 | 
			
		||||
        custom_path = "/tmp/test_hosts"
 | 
			
		||||
        parser = HostsParser(custom_path)
 | 
			
		||||
        assert str(parser.file_path) == custom_path
 | 
			
		||||
    
 | 
			
		||||
    def test_parse_simple_hosts_file(self):
 | 
			
		||||
        """Test parsing a simple hosts file."""
 | 
			
		||||
        content = """127.0.0.1 localhost
 | 
			
		||||
192.168.1.1 router
 | 
			
		||||
"""
 | 
			
		||||
        
 | 
			
		||||
        with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
 | 
			
		||||
            f.write(content)
 | 
			
		||||
            f.flush()
 | 
			
		||||
            
 | 
			
		||||
            parser = HostsParser(f.name)
 | 
			
		||||
            hosts_file = parser.parse()
 | 
			
		||||
            
 | 
			
		||||
            assert len(hosts_file.entries) == 2
 | 
			
		||||
            
 | 
			
		||||
            # Check first entry
 | 
			
		||||
            entry1 = hosts_file.entries[0]
 | 
			
		||||
            assert entry1.ip_address == "127.0.0.1"
 | 
			
		||||
            assert entry1.hostnames == ["localhost"]
 | 
			
		||||
            assert entry1.is_active is True
 | 
			
		||||
            assert entry1.comment is None
 | 
			
		||||
            
 | 
			
		||||
            # Check second entry
 | 
			
		||||
            entry2 = hosts_file.entries[1]
 | 
			
		||||
            assert entry2.ip_address == "192.168.1.1"
 | 
			
		||||
            assert entry2.hostnames == ["router"]
 | 
			
		||||
            assert entry2.is_active is True
 | 
			
		||||
            assert entry2.comment is None
 | 
			
		||||
        
 | 
			
		||||
        os.unlink(f.name)
 | 
			
		||||
    
 | 
			
		||||
    def test_parse_hosts_file_with_comments(self):
 | 
			
		||||
        """Test parsing hosts file with comments and inactive entries."""
 | 
			
		||||
        content = """# This is a header comment
 | 
			
		||||
# Another header comment
 | 
			
		||||
 | 
			
		||||
127.0.0.1 localhost loopback # Loopback address
 | 
			
		||||
192.168.1.1 router gateway # Local router
 | 
			
		||||
# 10.0.0.1 test.local # Disabled test entry
 | 
			
		||||
 | 
			
		||||
# Footer comment
 | 
			
		||||
"""
 | 
			
		||||
        
 | 
			
		||||
        with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
 | 
			
		||||
            f.write(content)
 | 
			
		||||
            f.flush()
 | 
			
		||||
            
 | 
			
		||||
            parser = HostsParser(f.name)
 | 
			
		||||
            hosts_file = parser.parse()
 | 
			
		||||
            
 | 
			
		||||
            # Check header comments
 | 
			
		||||
            assert len(hosts_file.header_comments) == 2
 | 
			
		||||
            assert hosts_file.header_comments[0] == "This is a header comment"
 | 
			
		||||
            assert hosts_file.header_comments[1] == "Another header comment"
 | 
			
		||||
            
 | 
			
		||||
            # Check entries
 | 
			
		||||
            assert len(hosts_file.entries) == 3
 | 
			
		||||
            
 | 
			
		||||
            # Active entry with comment
 | 
			
		||||
            entry1 = hosts_file.entries[0]
 | 
			
		||||
            assert entry1.ip_address == "127.0.0.1"
 | 
			
		||||
            assert entry1.hostnames == ["localhost", "loopback"]
 | 
			
		||||
            assert entry1.comment == "Loopback address"
 | 
			
		||||
            assert entry1.is_active is True
 | 
			
		||||
            
 | 
			
		||||
            # Another active entry
 | 
			
		||||
            entry2 = hosts_file.entries[1]
 | 
			
		||||
            assert entry2.ip_address == "192.168.1.1"
 | 
			
		||||
            assert entry2.hostnames == ["router", "gateway"]
 | 
			
		||||
            assert entry2.comment == "Local router"
 | 
			
		||||
            assert entry2.is_active is True
 | 
			
		||||
            
 | 
			
		||||
            # Inactive entry
 | 
			
		||||
            entry3 = hosts_file.entries[2]
 | 
			
		||||
            assert entry3.ip_address == "10.0.0.1"
 | 
			
		||||
            assert entry3.hostnames == ["test.local"]
 | 
			
		||||
            assert entry3.comment == "Disabled test entry"
 | 
			
		||||
            assert entry3.is_active is False
 | 
			
		||||
            
 | 
			
		||||
            # Check footer comments
 | 
			
		||||
            assert len(hosts_file.footer_comments) == 1
 | 
			
		||||
            assert hosts_file.footer_comments[0] == "Footer comment"
 | 
			
		||||
        
 | 
			
		||||
        os.unlink(f.name)
 | 
			
		||||
    
 | 
			
		||||
    def test_parse_empty_file(self):
 | 
			
		||||
        """Test parsing an empty hosts file."""
 | 
			
		||||
        with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
 | 
			
		||||
            f.write("")
 | 
			
		||||
            f.flush()
 | 
			
		||||
            
 | 
			
		||||
            parser = HostsParser(f.name)
 | 
			
		||||
            hosts_file = parser.parse()
 | 
			
		||||
            
 | 
			
		||||
            assert len(hosts_file.entries) == 0
 | 
			
		||||
            assert len(hosts_file.header_comments) == 0
 | 
			
		||||
            assert len(hosts_file.footer_comments) == 0
 | 
			
		||||
        
 | 
			
		||||
        os.unlink(f.name)
 | 
			
		||||
    
 | 
			
		||||
    def test_parse_comments_only_file(self):
 | 
			
		||||
        """Test parsing a file with only comments."""
 | 
			
		||||
        content = """# This is a comment
 | 
			
		||||
# Another comment
 | 
			
		||||
# Yet another comment
 | 
			
		||||
"""
 | 
			
		||||
        
 | 
			
		||||
        with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
 | 
			
		||||
            f.write(content)
 | 
			
		||||
            f.flush()
 | 
			
		||||
            
 | 
			
		||||
            parser = HostsParser(f.name)
 | 
			
		||||
            hosts_file = parser.parse()
 | 
			
		||||
            
 | 
			
		||||
            assert len(hosts_file.entries) == 0
 | 
			
		||||
            assert len(hosts_file.header_comments) == 3
 | 
			
		||||
            assert hosts_file.header_comments[0] == "This is a comment"
 | 
			
		||||
            assert hosts_file.header_comments[1] == "Another comment"
 | 
			
		||||
            assert hosts_file.header_comments[2] == "Yet another comment"
 | 
			
		||||
        
 | 
			
		||||
        os.unlink(f.name)
 | 
			
		||||
    
 | 
			
		||||
    def test_parse_nonexistent_file(self):
 | 
			
		||||
        """Test parsing a nonexistent file raises FileNotFoundError."""
 | 
			
		||||
        parser = HostsParser("/nonexistent/path/hosts")
 | 
			
		||||
        
 | 
			
		||||
        with pytest.raises(FileNotFoundError):
 | 
			
		||||
            parser.parse()
 | 
			
		||||
    
 | 
			
		||||
    def test_serialize_simple_hosts_file(self):
 | 
			
		||||
        """Test serializing a simple hosts file."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
 | 
			
		||||
        entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
 | 
			
		||||
        
 | 
			
		||||
        hosts_file.add_entry(entry1)
 | 
			
		||||
        hosts_file.add_entry(entry2)
 | 
			
		||||
        
 | 
			
		||||
        parser = HostsParser()
 | 
			
		||||
        content = parser.serialize(hosts_file)
 | 
			
		||||
        
 | 
			
		||||
        expected = """127.0.0.1 localhost
 | 
			
		||||
192.168.1.1 router
 | 
			
		||||
"""
 | 
			
		||||
        assert content == expected
 | 
			
		||||
    
 | 
			
		||||
    def test_serialize_hosts_file_with_comments(self):
 | 
			
		||||
        """Test serializing hosts file with comments."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        hosts_file.header_comments = ["Header comment 1", "Header comment 2"]
 | 
			
		||||
        hosts_file.footer_comments = ["Footer comment"]
 | 
			
		||||
        
 | 
			
		||||
        entry1 = HostEntry(
 | 
			
		||||
            ip_address="127.0.0.1",
 | 
			
		||||
            hostnames=["localhost"],
 | 
			
		||||
            comment="Loopback"
 | 
			
		||||
        )
 | 
			
		||||
        entry2 = HostEntry(
 | 
			
		||||
            ip_address="10.0.0.1",
 | 
			
		||||
            hostnames=["test"],
 | 
			
		||||
            is_active=False
 | 
			
		||||
        )
 | 
			
		||||
        
 | 
			
		||||
        hosts_file.add_entry(entry1)
 | 
			
		||||
        hosts_file.add_entry(entry2)
 | 
			
		||||
        
 | 
			
		||||
        parser = HostsParser()
 | 
			
		||||
        content = parser.serialize(hosts_file)
 | 
			
		||||
        
 | 
			
		||||
        expected = """# Header comment 1
 | 
			
		||||
# Header comment 2
 | 
			
		||||
 | 
			
		||||
127.0.0.1 localhost # Loopback
 | 
			
		||||
# 10.0.0.1 test
 | 
			
		||||
 | 
			
		||||
# Footer comment
 | 
			
		||||
"""
 | 
			
		||||
        assert content == expected
 | 
			
		||||
    
 | 
			
		||||
    def test_serialize_empty_hosts_file(self):
 | 
			
		||||
        """Test serializing an empty hosts file."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        parser = HostsParser()
 | 
			
		||||
        content = parser.serialize(hosts_file)
 | 
			
		||||
        
 | 
			
		||||
        assert content == "\n"
 | 
			
		||||
    
 | 
			
		||||
    def test_write_hosts_file(self):
 | 
			
		||||
        """Test writing hosts file to disk."""
 | 
			
		||||
        hosts_file = HostsFile()
 | 
			
		||||
        entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
 | 
			
		||||
        hosts_file.add_entry(entry)
 | 
			
		||||
        
 | 
			
		||||
        with tempfile.NamedTemporaryFile(delete=False) as f:
 | 
			
		||||
            parser = HostsParser(f.name)
 | 
			
		||||
            parser.write(hosts_file, backup=False)
 | 
			
		||||
            
 | 
			
		||||
            # Read back and verify
 | 
			
		||||
            with open(f.name, 'r') as read_file:
 | 
			
		||||
                content = read_file.read()
 | 
			
		||||
                assert content == "127.0.0.1 localhost\n"
 | 
			
		||||
        
 | 
			
		||||
        os.unlink(f.name)
 | 
			
		||||
    
 | 
			
		||||
    def test_write_hosts_file_with_backup(self):
 | 
			
		||||
        """Test writing hosts file with backup creation."""
 | 
			
		||||
        # Create initial file
 | 
			
		||||
        initial_content = "192.168.1.1 router\n"
 | 
			
		||||
        
 | 
			
		||||
        with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
 | 
			
		||||
            f.write(initial_content)
 | 
			
		||||
            f.flush()
 | 
			
		||||
            
 | 
			
		||||
            # Create new hosts file to write
 | 
			
		||||
            hosts_file = HostsFile()
 | 
			
		||||
            entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
 | 
			
		||||
            hosts_file.add_entry(entry)
 | 
			
		||||
            
 | 
			
		||||
            parser = HostsParser(f.name)
 | 
			
		||||
            parser.write(hosts_file, backup=True)
 | 
			
		||||
            
 | 
			
		||||
            # Check that backup was created
 | 
			
		||||
            backup_path = Path(f.name).with_suffix('.bak')
 | 
			
		||||
            assert backup_path.exists()
 | 
			
		||||
            
 | 
			
		||||
            # Check backup content
 | 
			
		||||
            with open(backup_path, 'r') as backup_file:
 | 
			
		||||
                backup_content = backup_file.read()
 | 
			
		||||
                assert backup_content == initial_content
 | 
			
		||||
            
 | 
			
		||||
            # Check new content
 | 
			
		||||
            with open(f.name, 'r') as new_file:
 | 
			
		||||
                new_content = new_file.read()
 | 
			
		||||
                assert new_content == "127.0.0.1 localhost\n"
 | 
			
		||||
            
 | 
			
		||||
            # Cleanup
 | 
			
		||||
            os.unlink(backup_path)
 | 
			
		||||
        
 | 
			
		||||
        os.unlink(f.name)
 | 
			
		||||
    
 | 
			
		||||
    def test_validate_write_permissions(self):
 | 
			
		||||
        """Test write permission validation."""
 | 
			
		||||
        # Test with a temporary file (should be writable)
 | 
			
		||||
        with tempfile.NamedTemporaryFile() as f:
 | 
			
		||||
            parser = HostsParser(f.name)
 | 
			
		||||
            assert parser.validate_write_permissions() is True
 | 
			
		||||
        
 | 
			
		||||
        # Test with a nonexistent file in /tmp (should be writable)
 | 
			
		||||
        parser = HostsParser("/tmp/test_hosts_nonexistent")
 | 
			
		||||
        assert parser.validate_write_permissions() is True
 | 
			
		||||
        
 | 
			
		||||
        # Test with a path that likely doesn't have write permissions
 | 
			
		||||
        parser = HostsParser("/root/test_hosts")
 | 
			
		||||
        # This might be True if running as root, so we can't assert False
 | 
			
		||||
        result = parser.validate_write_permissions()
 | 
			
		||||
        assert isinstance(result, bool)
 | 
			
		||||
    
 | 
			
		||||
    def test_get_file_info(self):
 | 
			
		||||
        """Test getting file information."""
 | 
			
		||||
        content = "127.0.0.1 localhost\n"
 | 
			
		||||
        
 | 
			
		||||
        with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
 | 
			
		||||
            f.write(content)
 | 
			
		||||
            f.flush()
 | 
			
		||||
            
 | 
			
		||||
            parser = HostsParser(f.name)
 | 
			
		||||
            info = parser.get_file_info()
 | 
			
		||||
            
 | 
			
		||||
            assert info['path'] == f.name
 | 
			
		||||
            assert info['exists'] is True
 | 
			
		||||
            assert info['readable'] is True
 | 
			
		||||
            assert info['size'] == len(content)
 | 
			
		||||
            assert info['modified'] is not None
 | 
			
		||||
            assert isinstance(info['modified'], float)
 | 
			
		||||
        
 | 
			
		||||
        os.unlink(f.name)
 | 
			
		||||
    
 | 
			
		||||
    def test_get_file_info_nonexistent(self):
 | 
			
		||||
        """Test getting file information for nonexistent file."""
 | 
			
		||||
        parser = HostsParser("/nonexistent/path")
 | 
			
		||||
        info = parser.get_file_info()
 | 
			
		||||
        
 | 
			
		||||
        assert info['path'] == "/nonexistent/path"
 | 
			
		||||
        assert info['exists'] is False
 | 
			
		||||
        assert info['readable'] is False
 | 
			
		||||
        assert info['writable'] is False
 | 
			
		||||
        assert info['size'] == 0
 | 
			
		||||
        assert info['modified'] is None
 | 
			
		||||
    
 | 
			
		||||
    def test_round_trip_parsing(self):
 | 
			
		||||
        """Test that parsing and serializing preserves content."""
 | 
			
		||||
        original_content = """# System hosts file
 | 
			
		||||
# Do not edit manually
 | 
			
		||||
 | 
			
		||||
127.0.0.1 localhost loopback # Local loopback
 | 
			
		||||
::1 localhost # IPv6 loopback
 | 
			
		||||
192.168.1.1 router gateway # Local router
 | 
			
		||||
# 10.0.0.1 test.local # Test entry (disabled)
 | 
			
		||||
 | 
			
		||||
# End of file
 | 
			
		||||
"""
 | 
			
		||||
        
 | 
			
		||||
        with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
 | 
			
		||||
            f.write(original_content)
 | 
			
		||||
            f.flush()
 | 
			
		||||
            
 | 
			
		||||
            # Parse and serialize
 | 
			
		||||
            parser = HostsParser(f.name)
 | 
			
		||||
            hosts_file = parser.parse()
 | 
			
		||||
            
 | 
			
		||||
            # Write back and read
 | 
			
		||||
            parser.write(hosts_file, backup=False)
 | 
			
		||||
            
 | 
			
		||||
            with open(f.name, 'r') as read_file:
 | 
			
		||||
                final_content = read_file.read()
 | 
			
		||||
            
 | 
			
		||||
            # The content should be functionally equivalent
 | 
			
		||||
            # (though formatting might differ slightly)
 | 
			
		||||
            assert "127.0.0.1 localhost loopback # Local loopback" in final_content
 | 
			
		||||
            assert "::1 localhost # IPv6 loopback" in final_content
 | 
			
		||||
            assert "192.168.1.1 router gateway # Local router" in final_content
 | 
			
		||||
            assert "# 10.0.0.1 test.local # Test entry (disabled)" in final_content
 | 
			
		||||
        
 | 
			
		||||
        os.unlink(f.name)
 | 
			
		||||
							
								
								
									
										180
									
								
								uv.lock
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										180
									
								
								uv.lock
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -2,16 +2,158 @@ version = 1
 | 
			
		|||
revision = 2
 | 
			
		||||
requires-python = ">=3.13"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "colorama"
 | 
			
		||||
version = "0.4.6"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "hosts"
 | 
			
		||||
version = "0.1.0"
 | 
			
		||||
source = { virtual = "." }
 | 
			
		||||
source = { editable = "." }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "pytest" },
 | 
			
		||||
    { name = "ruff" },
 | 
			
		||||
    { name = "textual" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[package.metadata]
 | 
			
		||||
requires-dist = [{ name = "ruff", specifier = ">=0.12.5" }]
 | 
			
		||||
requires-dist = [
 | 
			
		||||
    { name = "pytest", specifier = ">=8.1.1" },
 | 
			
		||||
    { name = "ruff", specifier = ">=0.12.5" },
 | 
			
		||||
    { name = "textual", specifier = ">=0.57.0" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "iniconfig"
 | 
			
		||||
version = "2.1.0"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "linkify-it-py"
 | 
			
		||||
version = "2.0.3"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "uc-micro-py" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "markdown-it-py"
 | 
			
		||||
version = "3.0.0"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "mdurl" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[package.optional-dependencies]
 | 
			
		||||
linkify = [
 | 
			
		||||
    { name = "linkify-it-py" },
 | 
			
		||||
]
 | 
			
		||||
plugins = [
 | 
			
		||||
    { name = "mdit-py-plugins" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "mdit-py-plugins"
 | 
			
		||||
version = "0.4.2"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "markdown-it-py" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "mdurl"
 | 
			
		||||
version = "0.1.2"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "packaging"
 | 
			
		||||
version = "25.0"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "platformdirs"
 | 
			
		||||
version = "4.3.8"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "pluggy"
 | 
			
		||||
version = "1.6.0"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "pygments"
 | 
			
		||||
version = "2.19.2"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "pytest"
 | 
			
		||||
version = "8.4.1"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "colorama", marker = "sys_platform == 'win32'" },
 | 
			
		||||
    { name = "iniconfig" },
 | 
			
		||||
    { name = "packaging" },
 | 
			
		||||
    { name = "pluggy" },
 | 
			
		||||
    { name = "pygments" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
 | 
			
		||||
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" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "rich"
 | 
			
		||||
version = "14.1.0"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "markdown-it-py" },
 | 
			
		||||
    { name = "pygments" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "ruff"
 | 
			
		||||
| 
						 | 
				
			
			@ -37,3 +179,37 @@ wheels = [
 | 
			
		|||
    { url = "https://files.pythonhosted.org/packages/bc/0f/ab16e8259493137598b9149734fec2e06fdeda9837e6f634f5c4e35916da/ruff-0.12.5-py3-none-win_amd64.whl", hash = "sha256:ae0d90cf5f49466c954991b9d8b953bd093c32c27608e409ae3564c63c5306a5", size = 12882273, upload-time = "2025-07-24T13:26:32.929Z" },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/00/db/c376b0661c24cf770cb8815268190668ec1330eba8374a126ceef8c72d55/ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805", size = 11951564, upload-time = "2025-07-24T13:26:34.994Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "textual"
 | 
			
		||||
version = "5.0.1"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "markdown-it-py", extra = ["linkify", "plugins"] },
 | 
			
		||||
    { name = "platformdirs" },
 | 
			
		||||
    { name = "pygments" },
 | 
			
		||||
    { name = "rich" },
 | 
			
		||||
    { name = "typing-extensions" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/45/44120c661037e64b80518871a800a0bd18c13aab4b68711b774f3b9d58b1/textual-5.0.1.tar.gz", hash = "sha256:c6e20489ee585ec3fa43b011aa575f52e4fafad550e040bff9f53a464897feb6", size = 1611533, upload-time = "2025-07-25T19:50:59.72Z" }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/cf/94/976d89db23efed9f3114403faf3f767ec707bfca469a93d0fb715cd352fa/textual-5.0.1-py3-none-any.whl", hash = "sha256:816eab21d22a702b3858ee23615abccaf157c05d386e82968000084c3c2c26aa", size = 699674, upload-time = "2025-07-25T19:50:57.686Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "typing-extensions"
 | 
			
		||||
version = "4.14.1"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "uc-micro-py"
 | 
			
		||||
version = "1.0.3"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" },
 | 
			
		||||
]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue