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
 | 
					## What Works
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Project Foundation
 | 
					### Project Foundation ✅ COMPLETE
 | 
				
			||||||
- ✅ **uv project initialized**: Basic Python 3.13 project with uv configuration
 | 
					- ✅ **uv project initialized**: Basic Python 3.13 project with uv configuration
 | 
				
			||||||
- ✅ **Code quality setup**: ruff configured for linting and formatting
 | 
					- ✅ **Code quality setup**: ruff configured for linting and formatting
 | 
				
			||||||
- ✅ **Memory bank complete**: All core documentation files created and populated
 | 
					- ✅ **Memory bank complete**: All core documentation files created and populated
 | 
				
			||||||
- ✅ **Architecture defined**: Clear layered architecture and design patterns established
 | 
					- ✅ **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
 | 
					### Documentation
 | 
				
			||||||
- ✅ **Project brief**: Comprehensive project definition and requirements
 | 
					- ✅ **Project brief**: Comprehensive project definition and requirements
 | 
				
			||||||
- ✅ **Product context**: User experience goals and problem definition
 | 
					- ✅ **Product context**: User experience goals and problem definition
 | 
				
			||||||
| 
						 | 
					@ -17,117 +31,162 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## What's Left to Build
 | 
					## What's Left to Build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Phase 1: Foundation (Immediate)
 | 
					### Phase 2: Enhanced Read-Only Features (Next)
 | 
				
			||||||
- ❌ **Project structure**: Create proper `src/hosts/` package structure
 | 
					- ❌ **Entry selection highlighting**: Visual feedback for selected entries
 | 
				
			||||||
- ❌ **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
 | 
					 | 
				
			||||||
- ❌ **Sorting**: Sort entries by IP, hostname, or comments
 | 
					- ❌ **Sorting**: Sort entries by IP, hostname, or comments
 | 
				
			||||||
- ❌ **Filtering**: Filter entries by active/inactive status
 | 
					- ❌ **Filtering**: Filter entries by active/inactive status
 | 
				
			||||||
- ❌ **Search**: Find entries by hostname or IP
 | 
					- ❌ **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
 | 
					- ❌ **Permission management**: Sudo request and management
 | 
				
			||||||
- ❌ **Edit mode toggle**: Switch between read-only and edit modes
 | 
					- ❌ **Edit mode toggle**: Switch between read-only and edit modes
 | 
				
			||||||
- ❌ **Entry activation**: Toggle entries active/inactive
 | 
					- ❌ **Entry activation**: Toggle entries active/inactive
 | 
				
			||||||
- ❌ **Entry reordering**: Move entries up/down in the list
 | 
					- ❌ **Entry reordering**: Move entries up/down in the list
 | 
				
			||||||
- ❌ **Entry editing**: Modify IP addresses, hostnames, comments
 | 
					- ❌ **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
 | 
					### Phase 5: Advanced Features
 | 
				
			||||||
- ❌ **DNS resolution**: Resolve hostnames to IP addresses
 | 
					- ❌ **DNS resolution**: Resolve hostnames to IP addresses
 | 
				
			||||||
- ❌ **IP comparison**: Compare stored vs resolved IPs
 | 
					- ❌ **IP comparison**: Compare stored vs resolved IPs
 | 
				
			||||||
- ❌ **CNAME support**: Store DNS names alongside IP addresses
 | 
					- ❌ **CNAME support**: Store DNS names alongside IP addresses
 | 
				
			||||||
- ❌ **Undo/Redo**: Command pattern implementation
 | 
					- ❌ **Import/Export**: Support for different file formats
 | 
				
			||||||
- ❌ **File validation**: Comprehensive validation before saving
 | 
					- ❌ **Configuration**: User preferences and settings
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Phase 6: Polish
 | 
					### Phase 6: Polish
 | 
				
			||||||
- ❌ **Error handling**: Graceful error handling and user feedback
 | 
					- ❌ **Error handling**: Enhanced error handling and user feedback
 | 
				
			||||||
- ❌ **Help system**: In-app help and keyboard shortcuts
 | 
					 | 
				
			||||||
- ❌ **Configuration**: User preferences and settings
 | 
					 | 
				
			||||||
- ❌ **Performance**: Optimization for large hosts files
 | 
					- ❌ **Performance**: Optimization for large hosts files
 | 
				
			||||||
 | 
					- ❌ **Accessibility**: Screen reader support and keyboard accessibility
 | 
				
			||||||
 | 
					- ❌ **Documentation**: User manual and installation guide
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Current Status
 | 
					## Current Status
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Development Stage
 | 
					### Development Stage
 | 
				
			||||||
**Stage**: Project Initialization
 | 
					**Stage**: Phase 1 Complete - Foundation Established
 | 
				
			||||||
**Progress**: 10% (Foundation documentation complete)
 | 
					**Progress**: 25% (Core functionality working)
 | 
				
			||||||
**Next Milestone**: Basic project structure and dependencies
 | 
					**Next Milestone**: Enhanced read-only features
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Immediate Blockers
 | 
					### Phase 1 Achievements
 | 
				
			||||||
1. **Project structure**: Need to create proper package layout
 | 
					1. ✅ **Fully functional TUI**: Application successfully loads and displays hosts file
 | 
				
			||||||
2. **Dependencies**: Must add textual framework to begin TUI development
 | 
					2. ✅ **Robust parsing**: Handles comments, inactive entries, IPv4/IPv6 addresses
 | 
				
			||||||
3. **Entry point**: Configure uv to run the application properly
 | 
					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
 | 
					### Recent Accomplishments
 | 
				
			||||||
- Completed comprehensive project planning and documentation
 | 
					- Successfully implemented complete Phase 1 foundation
 | 
				
			||||||
- Established clear architecture and design patterns
 | 
					- Created robust data models with validation
 | 
				
			||||||
- Created memory bank system for project continuity
 | 
					- Built comprehensive hosts file parser with comment preservation
 | 
				
			||||||
- Defined development phases and priorities
 | 
					- 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
 | 
					## Known Issues
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Current Limitations
 | 
					### Current Limitations
 | 
				
			||||||
- **Placeholder implementation**: main.py only prints hello message
 | 
					- **Help system**: Currently shows status message instead of modal
 | 
				
			||||||
- **Missing dependencies**: Core frameworks not yet added
 | 
					- **Entry highlighting**: Basic selection without visual enhancement
 | 
				
			||||||
- **No package structure**: Files not organized in proper Python package
 | 
					- **No edit capabilities**: Read-only mode only (by design for Phase 1)
 | 
				
			||||||
- **No tests**: Testing framework not yet configured
 | 
					- **No sorting/filtering**: Basic display only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Technical Debt
 | 
					### Technical Debt
 | 
				
			||||||
- **Temporary main.py**: Needs to be moved to proper location
 | 
					- **Help modal**: Need to implement proper screen for help
 | 
				
			||||||
- **Missing type hints**: Will need comprehensive typing
 | 
					- **Visual polish**: Entry highlighting and status indicators need improvement
 | 
				
			||||||
- **No error handling**: Basic error handling patterns needed
 | 
					- **Error messages**: Could be more user-friendly
 | 
				
			||||||
- **No logging**: Logging system not yet implemented
 | 
					- **Performance**: Not yet optimized for very large hosts files
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Evolution of Project Decisions
 | 
					## Evolution of Project Decisions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Initial Decisions (Current)
 | 
					### Confirmed Decisions
 | 
				
			||||||
- **Python 3.13**: Chosen for modern features and performance
 | 
					- **Python 3.13**: Excellent choice for modern features
 | 
				
			||||||
- **Textual**: Selected for rich TUI capabilities
 | 
					- **Textual**: Perfect for rich TUI development
 | 
				
			||||||
- **uv**: Adopted for fast package management
 | 
					- **uv**: Fast and reliable package management
 | 
				
			||||||
- **ruff**: Chosen for code quality and speed
 | 
					- **ruff**: Excellent code quality tooling
 | 
				
			||||||
 | 
					- **Dataclasses**: Clean and efficient for data models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Architecture Evolution
 | 
					### Architecture Validation
 | 
				
			||||||
- **Layered approach**: Decided on clear separation of concerns
 | 
					- **Layered approach**: Proven effective with clear separation
 | 
				
			||||||
- **Command pattern**: Chosen for undo/redo functionality
 | 
					- **Parser design**: Robust handling of real-world hosts files
 | 
				
			||||||
- **Immutable state**: Selected for predictable state management
 | 
					- **Reactive UI**: Textual's reactive system working well
 | 
				
			||||||
- **Permission model**: Explicit edit mode for safety
 | 
					- **Test-driven**: Comprehensive testing paying dividends
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Design Considerations
 | 
					### Design Successes
 | 
				
			||||||
- **Safety first**: Read-only default mode prioritized
 | 
					- **Safety first**: Read-only default working as intended
 | 
				
			||||||
- **User experience**: Keyboard-driven interface emphasized
 | 
					- **File integrity**: Atomic operations and backup system solid
 | 
				
			||||||
- **File integrity**: Atomic operations and validation required
 | 
					- **User experience**: Keyboard navigation intuitive
 | 
				
			||||||
- **Performance**: Responsive UI for large files planned
 | 
					- **Code organization**: Package structure clean and maintainable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Success Metrics Progress
 | 
					## Success Metrics Progress
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Completed Metrics
 | 
					### Completed Metrics ✅
 | 
				
			||||||
- ✅ **Project documentation**: Comprehensive planning complete
 | 
					- ✅ **Functional prototype**: TUI application fully working
 | 
				
			||||||
- ✅ **Architecture clarity**: Clear technical direction established
 | 
					- ✅ **File parsing**: Robust hosts file reading and writing
 | 
				
			||||||
- ✅ **Development setup**: Basic environment ready
 | 
					- ✅ **Code quality**: All quality checks passing
 | 
				
			||||||
 | 
					- ✅ **Test coverage**: Comprehensive test suite implemented
 | 
				
			||||||
 | 
					- ✅ **Architecture**: Clean, maintainable codebase structure
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Pending Metrics
 | 
					### Next Phase Metrics
 | 
				
			||||||
- ❌ **Functional prototype**: Basic TUI not yet implemented
 | 
					- ❌ **Enhanced UX**: Improved visual feedback and interactions
 | 
				
			||||||
- ❌ **File parsing**: Hosts file reading not yet working
 | 
					- ❌ **Data manipulation**: Sorting and filtering capabilities
 | 
				
			||||||
- ❌ **User testing**: No user interface to test yet
 | 
					- ❌ **User testing**: Feedback on current interface
 | 
				
			||||||
- ❌ **Performance benchmarks**: No code to benchmark yet
 | 
					- ❌ **Performance benchmarks**: Testing with large hosts files
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Next Session Priorities
 | 
					## Next Session Priorities
 | 
				
			||||||
 | 
					
 | 
				
			||||||
1. **Create project structure**: Set up src/hosts/ package layout
 | 
					### Phase 2 Implementation
 | 
				
			||||||
2. **Add dependencies**: Install textual and pytest
 | 
					1. **Visual enhancements**: Improve entry highlighting and status indicators
 | 
				
			||||||
3. **Implement data models**: Create HostEntry and HostsFile classes
 | 
					2. **Sorting functionality**: Implement sort by IP, hostname, status
 | 
				
			||||||
4. **Basic parser**: Read and parse simple hosts file format
 | 
					3. **Filtering system**: Add active/inactive filtering
 | 
				
			||||||
5. **Minimal TUI**: Create basic application shell
 | 
					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]
 | 
					[project]
 | 
				
			||||||
name = "hosts"
 | 
					name = "hosts"
 | 
				
			||||||
version = "0.1.0"
 | 
					version = "0.1.0"
 | 
				
			||||||
description = "Add your description here"
 | 
					description = "A Python TUI application for managing /etc/hosts files"
 | 
				
			||||||
readme = "README.md"
 | 
					readme = "README.md"
 | 
				
			||||||
requires-python = ">=3.13"
 | 
					requires-python = ">=3.13"
 | 
				
			||||||
dependencies = [
 | 
					dependencies = [
 | 
				
			||||||
 | 
					    "textual>=0.57.0",
 | 
				
			||||||
 | 
					    "pytest>=8.1.1",
 | 
				
			||||||
    "ruff>=0.12.5",
 | 
					    "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
 | 
					revision = 2
 | 
				
			||||||
requires-python = ">=3.13"
 | 
					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]]
 | 
					[[package]]
 | 
				
			||||||
name = "hosts"
 | 
					name = "hosts"
 | 
				
			||||||
version = "0.1.0"
 | 
					version = "0.1.0"
 | 
				
			||||||
source = { virtual = "." }
 | 
					source = { editable = "." }
 | 
				
			||||||
dependencies = [
 | 
					dependencies = [
 | 
				
			||||||
 | 
					    { name = "pytest" },
 | 
				
			||||||
    { name = "ruff" },
 | 
					    { name = "ruff" },
 | 
				
			||||||
 | 
					    { name = "textual" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[package.metadata]
 | 
					[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]]
 | 
					[[package]]
 | 
				
			||||||
name = "ruff"
 | 
					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/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" },
 | 
					    { 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