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 0000000..2850133 Binary files /dev/null and b/src/hosts/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/hosts/__pycache__/main.cpython-313.pyc b/src/hosts/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..5ea8c52 Binary files /dev/null and b/src/hosts/__pycache__/main.cpython-313.pyc differ 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 0000000..db285f7 Binary files /dev/null and b/src/hosts/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/hosts/core/__pycache__/models.cpython-313.pyc b/src/hosts/core/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..6e0750b Binary files /dev/null and b/src/hosts/core/__pycache__/models.cpython-313.pyc differ 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 0000000..7b02e32 Binary files /dev/null and b/src/hosts/core/__pycache__/parser.cpython-313.pyc differ diff --git a/src/hosts/core/models.py b/src/hosts/core/models.py new file mode 100644 index 0000000..c1ec12c --- /dev/null +++ b/src/hosts/core/models.py @@ -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 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 0000000..4041fd1 Binary files /dev/null and b/tests/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/__pycache__/test_models.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_models.cpython-313-pytest-8.4.1.pyc new file mode 100644 index 0000000..8f91d04 Binary files /dev/null and b/tests/__pycache__/test_models.cpython-313-pytest-8.4.1.pyc differ 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 0000000..0093170 Binary files /dev/null and b/tests/__pycache__/test_parser.cpython-313-pytest-8.4.1.pyc differ 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" }, +]