From 2decad80474d45e1c9cecf5b7e1db3f20eab9fce Mon Sep 17 00:00:00 2001 From: phg Date: Tue, 29 Jul 2025 14:52:31 +0200 Subject: [PATCH] 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. --- main.py => main_old.py | 0 memory-bank/progress.md | 203 ++++++---- pyproject.toml | 17 +- src/hosts/__init__.py | 8 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 403 bytes src/hosts/__pycache__/main.cpython-313.pyc | Bin 0 -> 12013 bytes src/hosts/core/__init__.py | 6 + .../core/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 349 bytes .../core/__pycache__/models.cpython-313.pyc | Bin 0 -> 9488 bytes .../core/__pycache__/parser.cpython-313.pyc | Bin 0 -> 9126 bytes src/hosts/core/models.py | 217 +++++++++++ src/hosts/core/parser.py | 221 +++++++++++ src/hosts/main.py | 251 +++++++++++++ src/hosts/tui/__init__.py | 6 + tests/__init__.py | 6 + tests/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 346 bytes .../test_models.cpython-313-pytest-8.4.1.pyc | Bin 0 -> 46934 bytes .../test_parser.cpython-313-pytest-8.4.1.pyc | Bin 0 -> 51808 bytes tests/test_models.py | 298 +++++++++++++++ tests/test_parser.py | 353 ++++++++++++++++++ uv.lock | 180 ++++++++- 21 files changed, 1691 insertions(+), 75 deletions(-) rename main.py => main_old.py (100%) create mode 100644 src/hosts/__init__.py create mode 100644 src/hosts/__pycache__/__init__.cpython-313.pyc create mode 100644 src/hosts/__pycache__/main.cpython-313.pyc create mode 100644 src/hosts/core/__init__.py create mode 100644 src/hosts/core/__pycache__/__init__.cpython-313.pyc create mode 100644 src/hosts/core/__pycache__/models.cpython-313.pyc create mode 100644 src/hosts/core/__pycache__/parser.cpython-313.pyc create mode 100644 src/hosts/core/models.py create mode 100644 src/hosts/core/parser.py create mode 100644 src/hosts/main.py create mode 100644 src/hosts/tui/__init__.py create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-313.pyc create mode 100644 tests/__pycache__/test_models.cpython-313-pytest-8.4.1.pyc create mode 100644 tests/__pycache__/test_parser.cpython-313-pytest-8.4.1.pyc create mode 100644 tests/test_models.py create mode 100644 tests/test_parser.py diff --git a/main.py b/main_old.py similarity index 100% rename from main.py rename to main_old.py diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 61a8718..370a5f0 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -2,12 +2,26 @@ ## What Works -### Project Foundation +### Project Foundation ✅ COMPLETE - ✅ **uv project initialized**: Basic Python 3.13 project with uv configuration - ✅ **Code quality setup**: ruff configured for linting and formatting - ✅ **Memory bank complete**: All core documentation files created and populated - ✅ **Architecture defined**: Clear layered architecture and design patterns established +### Phase 1: Foundation ✅ COMPLETE +- ✅ **Project structure**: Created proper `src/hosts/` package structure with core and tui modules +- ✅ **Dependencies**: Added textual, pytest, ruff and configured properly in pyproject.toml +- ✅ **Entry point**: Configured proper application entry point (`hosts` command) +- ✅ **Core models**: Implemented HostEntry and HostsFile data classes with full validation +- ✅ **Hosts parser**: Created comprehensive parser for reading/writing `/etc/hosts` files +- ✅ **Basic TUI**: Implemented main application with two-pane layout +- ✅ **File loading**: Successfully reads and parses existing hosts file +- ✅ **Entry display**: Shows hosts entries in left pane with proper formatting +- ✅ **Detail view**: Shows selected entry details in right pane +- ✅ **Navigation**: Keyboard navigation between entries working +- ✅ **Testing**: Comprehensive test suite with 42 passing tests +- ✅ **Code quality**: All linting checks passing + ### Documentation - ✅ **Project brief**: Comprehensive project definition and requirements - ✅ **Product context**: User experience goals and problem definition @@ -17,117 +31,162 @@ ## What's Left to Build -### Phase 1: Foundation (Immediate) -- ❌ **Project structure**: Create proper `src/hosts/` package structure -- ❌ **Dependencies**: Add textual, pytest, and other required packages -- ❌ **Entry point**: Configure proper application entry point in pyproject.toml -- ❌ **Core models**: Implement HostEntry and HostsFile data classes -- ❌ **Hosts parser**: Create parser for reading/writing `/etc/hosts` files - -### Phase 2: Core Functionality -- ❌ **Basic TUI**: Implement main application with two-pane layout -- ❌ **File loading**: Read and parse existing hosts file -- ❌ **Entry display**: Show hosts entries in left pane -- ❌ **Detail view**: Show selected entry details in right pane -- ❌ **Navigation**: Keyboard navigation between entries - -### Phase 3: Read-Only Features -- ❌ **Entry selection**: Highlight and select entries +### Phase 2: Enhanced Read-Only Features (Next) +- ❌ **Entry selection highlighting**: Visual feedback for selected entries - ❌ **Sorting**: Sort entries by IP, hostname, or comments - ❌ **Filtering**: Filter entries by active/inactive status - ❌ **Search**: Find entries by hostname or IP +- ❌ **Help screen**: Proper modal help dialog +- ❌ **Status indicators**: Better visual distinction for active/inactive entries -### Phase 4: Edit Mode +### Phase 3: Edit Mode Foundation - ❌ **Permission management**: Sudo request and management - ❌ **Edit mode toggle**: Switch between read-only and edit modes - ❌ **Entry activation**: Toggle entries active/inactive - ❌ **Entry reordering**: Move entries up/down in the list - ❌ **Entry editing**: Modify IP addresses, hostnames, comments +- ❌ **File backup**: Automatic backup before modifications + +### Phase 4: Advanced Edit Features +- ❌ **Add new entries**: Create new host entries +- ❌ **Delete entries**: Remove host entries +- ❌ **Bulk operations**: Select and modify multiple entries +- ❌ **Validation**: Real-time validation of IP addresses and hostnames +- ❌ **Undo/Redo**: Command pattern implementation ### Phase 5: Advanced Features - ❌ **DNS resolution**: Resolve hostnames to IP addresses - ❌ **IP comparison**: Compare stored vs resolved IPs - ❌ **CNAME support**: Store DNS names alongside IP addresses -- ❌ **Undo/Redo**: Command pattern implementation -- ❌ **File validation**: Comprehensive validation before saving +- ❌ **Import/Export**: Support for different file formats +- ❌ **Configuration**: User preferences and settings ### Phase 6: Polish -- ❌ **Error handling**: Graceful error handling and user feedback -- ❌ **Help system**: In-app help and keyboard shortcuts -- ❌ **Configuration**: User preferences and settings +- ❌ **Error handling**: Enhanced error handling and user feedback - ❌ **Performance**: Optimization for large hosts files +- ❌ **Accessibility**: Screen reader support and keyboard accessibility +- ❌ **Documentation**: User manual and installation guide ## Current Status ### Development Stage -**Stage**: Project Initialization -**Progress**: 10% (Foundation documentation complete) -**Next Milestone**: Basic project structure and dependencies +**Stage**: Phase 1 Complete - Foundation Established +**Progress**: 25% (Core functionality working) +**Next Milestone**: Enhanced read-only features -### Immediate Blockers -1. **Project structure**: Need to create proper package layout -2. **Dependencies**: Must add textual framework to begin TUI development -3. **Entry point**: Configure uv to run the application properly +### Phase 1 Achievements +1. ✅ **Fully functional TUI**: Application successfully loads and displays hosts file +2. ✅ **Robust parsing**: Handles comments, inactive entries, IPv4/IPv6 addresses +3. ✅ **Clean architecture**: Well-structured codebase with separation of concerns +4. ✅ **Comprehensive testing**: 42 tests covering models and parser functionality +5. ✅ **Code quality**: All linting and formatting checks passing + +### Immediate Next Steps +1. **Enhanced UI**: Improve visual feedback and entry highlighting +2. **Sorting/Filtering**: Add basic data manipulation features +3. **Help system**: Implement proper help modal +4. **Status improvements**: Better visual indicators for entry states ### Recent Accomplishments -- Completed comprehensive project planning and documentation -- Established clear architecture and design patterns -- Created memory bank system for project continuity -- Defined development phases and priorities +- Successfully implemented complete Phase 1 foundation +- Created robust data models with validation +- Built comprehensive hosts file parser with comment preservation +- Developed functional TUI application with two-pane layout +- Established comprehensive testing framework +- Achieved clean code quality standards + +## Technical Implementation Details + +### Core Components Working +- **HostEntry**: Data class with IP/hostname validation, active/inactive state +- **HostsFile**: Container with entry management, sorting, and search capabilities +- **HostsParser**: File I/O with atomic writes, backup creation, permission checking +- **HostsManagerApp**: Textual-based TUI with reactive state management + +### Test Coverage +- **Models**: 27 tests covering all data model functionality +- **Parser**: 15 tests covering file operations and edge cases +- **Coverage**: All core functionality thoroughly tested + +### Code Quality +- **Linting**: All ruff checks passing +- **Type hints**: Comprehensive typing throughout codebase +- **Documentation**: Detailed docstrings and comments +- **Error handling**: Proper exception handling in core components ## Known Issues ### Current Limitations -- **Placeholder implementation**: main.py only prints hello message -- **Missing dependencies**: Core frameworks not yet added -- **No package structure**: Files not organized in proper Python package -- **No tests**: Testing framework not yet configured +- **Help system**: Currently shows status message instead of modal +- **Entry highlighting**: Basic selection without visual enhancement +- **No edit capabilities**: Read-only mode only (by design for Phase 1) +- **No sorting/filtering**: Basic display only ### Technical Debt -- **Temporary main.py**: Needs to be moved to proper location -- **Missing type hints**: Will need comprehensive typing -- **No error handling**: Basic error handling patterns needed -- **No logging**: Logging system not yet implemented +- **Help modal**: Need to implement proper screen for help +- **Visual polish**: Entry highlighting and status indicators need improvement +- **Error messages**: Could be more user-friendly +- **Performance**: Not yet optimized for very large hosts files ## Evolution of Project Decisions -### Initial Decisions (Current) -- **Python 3.13**: Chosen for modern features and performance -- **Textual**: Selected for rich TUI capabilities -- **uv**: Adopted for fast package management -- **ruff**: Chosen for code quality and speed +### Confirmed Decisions +- **Python 3.13**: Excellent choice for modern features +- **Textual**: Perfect for rich TUI development +- **uv**: Fast and reliable package management +- **ruff**: Excellent code quality tooling +- **Dataclasses**: Clean and efficient for data models -### Architecture Evolution -- **Layered approach**: Decided on clear separation of concerns -- **Command pattern**: Chosen for undo/redo functionality -- **Immutable state**: Selected for predictable state management -- **Permission model**: Explicit edit mode for safety +### Architecture Validation +- **Layered approach**: Proven effective with clear separation +- **Parser design**: Robust handling of real-world hosts files +- **Reactive UI**: Textual's reactive system working well +- **Test-driven**: Comprehensive testing paying dividends -### Design Considerations -- **Safety first**: Read-only default mode prioritized -- **User experience**: Keyboard-driven interface emphasized -- **File integrity**: Atomic operations and validation required -- **Performance**: Responsive UI for large files planned +### Design Successes +- **Safety first**: Read-only default working as intended +- **File integrity**: Atomic operations and backup system solid +- **User experience**: Keyboard navigation intuitive +- **Code organization**: Package structure clean and maintainable ## Success Metrics Progress -### Completed Metrics -- ✅ **Project documentation**: Comprehensive planning complete -- ✅ **Architecture clarity**: Clear technical direction established -- ✅ **Development setup**: Basic environment ready +### Completed Metrics ✅ +- ✅ **Functional prototype**: TUI application fully working +- ✅ **File parsing**: Robust hosts file reading and writing +- ✅ **Code quality**: All quality checks passing +- ✅ **Test coverage**: Comprehensive test suite implemented +- ✅ **Architecture**: Clean, maintainable codebase structure -### Pending Metrics -- ❌ **Functional prototype**: Basic TUI not yet implemented -- ❌ **File parsing**: Hosts file reading not yet working -- ❌ **User testing**: No user interface to test yet -- ❌ **Performance benchmarks**: No code to benchmark yet +### Next Phase Metrics +- ❌ **Enhanced UX**: Improved visual feedback and interactions +- ❌ **Data manipulation**: Sorting and filtering capabilities +- ❌ **User testing**: Feedback on current interface +- ❌ **Performance benchmarks**: Testing with large hosts files ## Next Session Priorities -1. **Create project structure**: Set up src/hosts/ package layout -2. **Add dependencies**: Install textual and pytest -3. **Implement data models**: Create HostEntry and HostsFile classes -4. **Basic parser**: Read and parse simple hosts file format -5. **Minimal TUI**: Create basic application shell +### Phase 2 Implementation +1. **Visual enhancements**: Improve entry highlighting and status indicators +2. **Sorting functionality**: Implement sort by IP, hostname, status +3. **Filtering system**: Add active/inactive filtering +4. **Help modal**: Create proper help screen +5. **Search capability**: Basic hostname/IP search -The project is well-planned and ready for implementation to begin. +### Quality Improvements +1. **Error handling**: More user-friendly error messages +2. **Status feedback**: Better user feedback for operations +3. **Performance testing**: Test with large hosts files +4. **Documentation**: Update README with usage instructions + +## Phase 1 Success Summary + +Phase 1 has been **successfully completed** with all core objectives achieved: + +- ✅ **Solid foundation**: Robust architecture and codebase +- ✅ **Working application**: Functional TUI that reads and displays hosts files +- ✅ **Quality standards**: Clean code with comprehensive testing +- ✅ **User experience**: Intuitive keyboard-driven interface +- ✅ **File safety**: Proper parsing with comment preservation + +The project is ready to move into Phase 2 with enhanced read-only features. diff --git a/pyproject.toml b/pyproject.toml index eba40d4..c83eefa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,24 @@ [project] name = "hosts" version = "0.1.0" -description = "Add your description here" +description = "A Python TUI application for managing /etc/hosts files" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "textual>=0.57.0", + "pytest>=8.1.1", "ruff>=0.12.5", ] + +[project.scripts] +hosts = "hosts.main:main" + +[tool.uv] +package = true + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/hosts"] diff --git a/src/hosts/__init__.py b/src/hosts/__init__.py new file mode 100644 index 0000000..2e4734f --- /dev/null +++ b/src/hosts/__init__.py @@ -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" diff --git a/src/hosts/__pycache__/__init__.cpython-313.pyc b/src/hosts/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..28501333ecad449a9d81426b446ce089579a8873 GIT binary patch literal 403 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 literal 0 HcmV?d00001 diff --git a/src/hosts/core/__init__.py b/src/hosts/core/__init__.py new file mode 100644 index 0000000..a5bf7b4 --- /dev/null +++ b/src/hosts/core/__init__.py @@ -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. +""" diff --git a/src/hosts/core/__pycache__/__init__.cpython-313.pyc b/src/hosts/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..db285f7c5b770f9fc4da4060505aa30484729d59 GIT binary patch literal 349 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 literal 0 HcmV?d00001 diff --git a/src/hosts/core/__pycache__/parser.cpython-313.pyc b/src/hosts/core/__pycache__/parser.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7b02e32e2b5800c3e4321ff348a9126c5062f8fb GIT binary patch literal 9126 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 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 diff --git a/src/hosts/core/parser.py b/src/hosts/core/parser.py new file mode 100644 index 0000000..bcfa34b --- /dev/null +++ b/src/hosts/core/parser.py @@ -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 diff --git a/src/hosts/main.py b/src/hosts/main.py new file mode 100644 index 0000000..19e1aa1 --- /dev/null +++ b/src/hosts/main.py @@ -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() diff --git a/src/hosts/tui/__init__.py b/src/hosts/tui/__init__.py new file mode 100644 index 0000000..3c7f367 --- /dev/null +++ b/src/hosts/tui/__init__.py @@ -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. +""" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b2d9261 --- /dev/null +++ b/tests/__init__.py @@ -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. +""" diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4041fd1248bfa41f300ab308419aac4ff5719211 GIT binary patch literal 346 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_parser.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_parser.cpython-313-pytest-8.4.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..00931700839df7ce60fc027390bd274b76a15b2d GIT binary patch literal 51808 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 NE2GpW1`rAMKLAYR(W(Fd literal 0 HcmV?d00001 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..fd22f16 --- /dev/null +++ b/tests/test_models.py @@ -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 == [] diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..b997a96 --- /dev/null +++ b/tests/test_parser.py @@ -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) diff --git a/uv.lock b/uv.lock index fd7de66..35e028b 100644 --- a/uv.lock +++ b/uv.lock @@ -2,16 +2,158 @@ version = 1 revision = 2 requires-python = ">=3.13" +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "hosts" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ + { name = "pytest" }, { name = "ruff" }, + { name = "textual" }, ] [package.metadata] -requires-dist = [{ name = "ruff", specifier = ">=0.12.5" }] +requires-dist = [ + { name = "pytest", specifier = ">=8.1.1" }, + { name = "ruff", specifier = ">=0.12.5" }, + { name = "textual", specifier = ">=0.57.0" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] +plugins = [ + { name = "mdit-py-plugins" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] [[package]] name = "ruff" @@ -37,3 +179,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/0f/ab16e8259493137598b9149734fec2e06fdeda9837e6f634f5c4e35916da/ruff-0.12.5-py3-none-win_amd64.whl", hash = "sha256:ae0d90cf5f49466c954991b9d8b953bd093c32c27608e409ae3564c63c5306a5", size = 12882273, upload-time = "2025-07-24T13:26:32.929Z" }, { url = "https://files.pythonhosted.org/packages/00/db/c376b0661c24cf770cb8815268190668ec1330eba8374a126ceef8c72d55/ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805", size = 11951564, upload-time = "2025-07-24T13:26:34.994Z" }, ] + +[[package]] +name = "textual" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify", "plugins"] }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/45/44120c661037e64b80518871a800a0bd18c13aab4b68711b774f3b9d58b1/textual-5.0.1.tar.gz", hash = "sha256:c6e20489ee585ec3fa43b011aa575f52e4fafad550e040bff9f53a464897feb6", size = 1611533, upload-time = "2025-07-25T19:50:59.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/94/976d89db23efed9f3114403faf3f767ec707bfca469a93d0fb715cd352fa/textual-5.0.1-py3-none-any.whl", hash = "sha256:816eab21d22a702b3858ee23615abccaf157c05d386e82968000084c3c2c26aa", size = 699674, upload-time = "2025-07-25T19:50:57.686Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, +]