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:
Philip Henning 2025-07-29 14:52:31 +02:00
parent 40a1e67949
commit 2decad8047
21 changed files with 1691 additions and 75 deletions

View file

@ -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.

View file

@ -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
View 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"

Binary file not shown.

Binary file not shown.

View 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.
"""

Binary file not shown.

Binary file not shown.

Binary file not shown.

217
src/hosts/core/models.py Normal file
View 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
View 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
View 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()

View 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
View 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.
"""

Binary file not shown.

298
tests/test_models.py Normal file
View 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
View 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
View file

@ -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" },
]