Compare commits

..

3 commits

Author SHA1 Message Date
phg
1fddff91c8 Refactor tests for PermissionManager, HostsManager, HostEntry, HostsFile, and HostsParser
- Updated test cases in test_manager.py to improve readability and consistency.
- Simplified assertions and mock setups in tests for PermissionManager.
- Enhanced test coverage for HostsManager, including edit mode and entry manipulation tests.
- Improved test structure in test_models.py for HostEntry and HostsFile, ensuring clarity in test cases.
- Refined test cases in test_parser.py for better organization and readability.
- Adjusted test_save_confirmation_modal.py to maintain consistency in mocking and assertions.
2025-08-14 17:32:02 +02:00
phg
43fa8c871a Update pane titles in HostsManagerApp for clarity and edit mode indication 2025-08-14 17:08:55 +02:00
phg
14d7553674 Add CLAUDE.md for project guidance and development instructions 2025-08-14 16:59:27 +02:00
19 changed files with 1463 additions and 1042 deletions

87
CLAUDE.md Normal file
View file

@ -0,0 +1,87 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a Python TUI (Terminal User Interface) application for managing `/etc/hosts` files. The application provides a two-pane interface built with Textual, allowing users to view, edit, activate/deactivate, and reorder hostname entries in their system's hosts file with proper sudo permission handling.
## Development Commands
### Package Management (uv)
- `uv sync` - Update project environment and install dependencies
- `uv add <package>` - Add new dependency to project
- `uv run <command>` - Run commands in the project environment
### Running the Application
- `uv run hosts` - Launch the TUI application (main entry point)
- `uv run python -m hosts.main` - Alternative way to run the application
### Testing
- `uv run pytest` - Run all tests
- `uv run pytest tests/test_<module>.py` - Run specific test module
- `uv run pytest -k <test_name>` - Run tests matching pattern
### Code Quality
- `uv run ruff check` - Run linter checks
- `uv run ruff check --fix` - Auto-fix linting issues
- `uv run ruff format` - Format code
## Architecture
### Core Structure
The application follows a modular architecture with clear separation of concerns:
```
src/hosts/
├── core/ # Core business logic
│ ├── config.py # Configuration management
│ ├── manager.py # HostsManager with sudo permission handling
│ ├── models.py # Data models (HostEntry, HostsFile)
│ └── parser.py # Hosts file parsing/serialization
├── tui/ # Textual UI components
│ ├── app.py # Main application class (HostsManagerApp)
│ └── ... # Various handlers and modals
└── main.py # Entry point
```
### Key Components
**HostsManager** (`src/hosts/core/manager.py:106`): Central component that handles all edit operations with proper sudo permission management. Key features:
- Permission management through PermissionManager class
- Edit mode with backup creation and restoration
- Safe file operations with validation
- Entry manipulation (toggle, move, update)
**HostsParser** (`src/hosts/core/parser.py`): Handles reading/writing hosts files and maintains original formatting.
**HostsManagerApp** (`src/hosts/tui/app.py:26`): Main Textual application providing the two-pane interface with reactive state management.
### Permission Model
The application operates in two modes:
- **Read-only mode** (default): Safe browsing of hosts file entries
- **Edit mode**: Requires sudo permissions for modifications, includes automatic backup creation
### Memory Bank Integration
This project uses Cline's Memory Bank system (see `.clinerules`) for maintaining project context across sessions. Key files are in `memory-bank/` directory.
## Development Guidelines
### Testing Approach
- Tests are located in `tests/` directory
- Uses pytest framework
- Test individual modules with `uv run pytest tests/test_<module>.py`
### Code Style
- Uses ruff for both linting and formatting
- Configuration is embedded in `pyproject.toml`
- Run `uv run ruff check --fix && uv run ruff format` before committing
### Dependencies
- **Textual**: TUI framework for the interface
- **pytest**: Testing framework
- **ruff**: Linting and code formatting
- Managed via uv with dependencies declared in `pyproject.toml`
### File Permissions
When working on permission-related code, be aware that the application needs to handle sudo operations safely. The PermissionManager class in `src/hosts/core/manager.py:17` manages this complexity.

View file

@ -34,14 +34,14 @@ class Config:
"window_settings": { "window_settings": {
"last_sort_column": "", "last_sort_column": "",
"last_sort_ascending": True, "last_sort_ascending": True,
} },
} }
def load(self) -> None: def load(self) -> None:
"""Load configuration from file.""" """Load configuration from file."""
try: try:
if self.config_file.exists(): if self.config_file.exists():
with open(self.config_file, 'r') as f: with open(self.config_file, "r") as f:
loaded_settings = json.load(f) loaded_settings = json.load(f)
# Merge with defaults to ensure all keys exist # Merge with defaults to ensure all keys exist
self._settings.update(loaded_settings) self._settings.update(loaded_settings)
@ -55,7 +55,7 @@ class Config:
# Ensure config directory exists # Ensure config directory exists
self.config_dir.mkdir(parents=True, exist_ok=True) self.config_dir.mkdir(parents=True, exist_ok=True)
with open(self.config_file, 'w') as f: with open(self.config_file, "w") as f:
json.dump(self._settings, f, indent=2) json.dump(self._settings, f, indent=2)
except IOError: except IOError:
# Silently fail if we can't save config # Silently fail if we can't save config

View file

@ -26,20 +26,20 @@ class PermissionManager:
self.has_sudo = False self.has_sudo = False
self._sudo_validated = False self._sudo_validated = False
def request_sudo(self) -> Tuple[bool, str]: def request_sudo(self, password: str = None) -> Tuple[bool, str]:
""" """
Request sudo permissions for hosts file editing. Request sudo permissions for hosts file editing.
Args:
password: Optional password for sudo authentication
Returns: Returns:
Tuple of (success, message) Tuple of (success, message)
""" """
try: try:
# Test sudo access with a simple command # Test sudo access with a simple command
result = subprocess.run( result = subprocess.run(
['sudo', '-n', 'true'], ["sudo", "-n", "true"], capture_output=True, text=True, timeout=5
capture_output=True,
text=True,
timeout=5
) )
if result.returncode == 0: if result.returncode == 0:
@ -48,12 +48,17 @@ class PermissionManager:
self._sudo_validated = True self._sudo_validated = True
return True, "Sudo access already available" return True, "Sudo access already available"
# Need to prompt for password # If no password provided, indicate we need password input
if password is None:
return False, "Password required for sudo access"
# Use password for sudo authentication
result = subprocess.run( result = subprocess.run(
['sudo', '-v'], ["sudo", "-S", "-v"],
input=password + "\n",
capture_output=True, capture_output=True,
text=True, text=True,
timeout=30 timeout=10,
) )
if result.returncode == 0: if result.returncode == 0:
@ -61,7 +66,14 @@ class PermissionManager:
self._sudo_validated = True self._sudo_validated = True
return True, "Sudo access granted" return True, "Sudo access granted"
else: else:
return False, "Sudo access denied" # Check if it's a password error
if (
"incorrect password" in result.stderr.lower()
or "authentication failure" in result.stderr.lower()
):
return False, "Incorrect password"
else:
return False, f"Sudo access denied: {result.stderr}"
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return False, "Sudo request timed out" return False, "Sudo request timed out"
@ -84,9 +96,7 @@ class PermissionManager:
try: try:
# Test write access with sudo # Test write access with sudo
result = subprocess.run( result = subprocess.run(
['sudo', '-n', 'test', '-w', file_path], ["sudo", "-n", "test", "-w", file_path], capture_output=True, timeout=5
capture_output=True,
timeout=5
) )
return result.returncode == 0 return result.returncode == 0
except Exception: except Exception:
@ -95,7 +105,7 @@ class PermissionManager:
def release_sudo(self) -> None: def release_sudo(self) -> None:
"""Release sudo permissions.""" """Release sudo permissions."""
try: try:
subprocess.run(['sudo', '-k'], capture_output=True, timeout=5) subprocess.run(["sudo", "-k"], capture_output=True, timeout=5)
except Exception: except Exception:
pass pass
finally: finally:
@ -117,10 +127,13 @@ class HostsManager:
self.edit_mode = False self.edit_mode = False
self._backup_path: Optional[Path] = None self._backup_path: Optional[Path] = None
def enter_edit_mode(self) -> Tuple[bool, str]: def enter_edit_mode(self, password: str = None) -> Tuple[bool, str]:
""" """
Enter edit mode with proper permission management. Enter edit mode with proper permission management.
Args:
password: Optional password for sudo authentication
Returns: Returns:
Tuple of (success, message) Tuple of (success, message)
""" """
@ -128,9 +141,9 @@ class HostsManager:
return True, "Already in edit mode" return True, "Already in edit mode"
# Request sudo permissions # Request sudo permissions
success, message = self.permission_manager.request_sudo() success, message = self.permission_manager.request_sudo(password)
if not success: if not success:
return False, f"Cannot enter edit mode: {message}" return False, message
# Validate write permissions # Validate write permissions
if not self.permission_manager.validate_permissions(str(self.parser.file_path)): if not self.permission_manager.validate_permissions(str(self.parser.file_path)):
@ -220,8 +233,10 @@ class HostsManager:
return False, "Cannot move default system entries" return False, "Cannot move default system entries"
# Swap with previous entry # Swap with previous entry
hosts_file.entries[index], hosts_file.entries[index - 1] = \ hosts_file.entries[index], hosts_file.entries[index - 1] = (
hosts_file.entries[index - 1], hosts_file.entries[index] hosts_file.entries[index - 1],
hosts_file.entries[index],
)
return True, "Entry moved up" return True, "Entry moved up"
except Exception as e: except Exception as e:
return False, f"Error moving entry: {e}" return False, f"Error moving entry: {e}"
@ -252,15 +267,22 @@ class HostsManager:
return False, "Cannot move default system entries" return False, "Cannot move default system entries"
# Swap with next entry # Swap with next entry
hosts_file.entries[index], hosts_file.entries[index + 1] = \ hosts_file.entries[index], hosts_file.entries[index + 1] = (
hosts_file.entries[index + 1], hosts_file.entries[index] hosts_file.entries[index + 1],
hosts_file.entries[index],
)
return True, "Entry moved down" return True, "Entry moved down"
except Exception as e: except Exception as e:
return False, f"Error moving entry: {e}" return False, f"Error moving entry: {e}"
def update_entry(self, hosts_file: HostsFile, index: int, def update_entry(
ip_address: str, hostnames: list[str], self,
comment: Optional[str] = None) -> Tuple[bool, str]: hosts_file: HostsFile,
index: int,
ip_address: str,
hostnames: list[str],
comment: Optional[str] = None,
) -> Tuple[bool, str]:
""" """
Update an existing entry. Update an existing entry.
@ -293,7 +315,7 @@ class HostsManager:
hostnames=hostnames, hostnames=hostnames,
comment=comment, comment=comment,
is_active=hosts_file.entries[index].is_active, is_active=hosts_file.entries[index].is_active,
dns_name=hosts_file.entries[index].dns_name dns_name=hosts_file.entries[index].dns_name,
) )
# Replace the entry # Replace the entry
@ -326,17 +348,19 @@ class HostsManager:
content = self.parser.serialize(hosts_file) content = self.parser.serialize(hosts_file)
# Write to temporary file first # Write to temporary file first
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.hosts') as temp_file: with tempfile.NamedTemporaryFile(
mode="w", delete=False, suffix=".hosts"
) as temp_file:
temp_file.write(content) temp_file.write(content)
temp_path = temp_file.name temp_path = temp_file.name
try: try:
# Use sudo to copy the temp file to the hosts file # Use sudo to copy the temp file to the hosts file
result = subprocess.run( result = subprocess.run(
['sudo', 'cp', temp_path, str(self.parser.file_path)], ["sudo", "cp", temp_path, str(self.parser.file_path)],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10 timeout=10,
) )
if result.returncode == 0: if result.returncode == 0:
@ -369,10 +393,10 @@ class HostsManager:
try: try:
result = subprocess.run( result = subprocess.run(
['sudo', 'cp', str(self._backup_path), str(self.parser.file_path)], ["sudo", "cp", str(self._backup_path), str(self.parser.file_path)],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10 timeout=10,
) )
if result.returncode == 0: if result.returncode == 0:
@ -393,33 +417,39 @@ class HostsManager:
backup_dir.mkdir(exist_ok=True) backup_dir.mkdir(exist_ok=True)
import time import time
timestamp = int(time.time()) timestamp = int(time.time())
self._backup_path = backup_dir / f"hosts.backup.{timestamp}" self._backup_path = backup_dir / f"hosts.backup.{timestamp}"
# Copy current hosts file to backup # Copy current hosts file to backup
result = subprocess.run( result = subprocess.run(
['sudo', 'cp', str(self.parser.file_path), str(self._backup_path)], ["sudo", "cp", str(self.parser.file_path), str(self._backup_path)],
capture_output=True, capture_output=True,
timeout=10 timeout=10,
) )
if result.returncode != 0: if result.returncode != 0:
raise Exception(f"Failed to create backup: {result.stderr}") raise Exception(f"Failed to create backup: {result.stderr}")
# Make backup readable by user # Make backup readable by user
subprocess.run(['sudo', 'chmod', '644', str(self._backup_path)], capture_output=True) subprocess.run(
["sudo", "chmod", "644", str(self._backup_path)], capture_output=True
)
class EditModeError(Exception): class EditModeError(Exception):
"""Base exception for edit mode errors.""" """Base exception for edit mode errors."""
pass pass
class PermissionError(EditModeError): class PermissionError(EditModeError):
"""Raised when there are permission issues.""" """Raised when there are permission issues."""
pass pass
class ValidationError(EditModeError): class ValidationError(EditModeError):
"""Raised when validation fails.""" """Raised when validation fails."""
pass pass

View file

@ -23,6 +23,7 @@ class HostEntry:
is_active: Whether this entry is active (not commented out) is_active: Whether this entry is active (not commented out)
dns_name: Optional DNS name for CNAME-like functionality dns_name: Optional DNS name for CNAME-like functionality
""" """
ip_address: str ip_address: str
hostnames: List[str] hostnames: List[str]
comment: Optional[str] = None comment: Optional[str] = None
@ -51,7 +52,10 @@ class HostEntry:
] ]
for entry in default_entries: for entry in default_entries:
if entry["ip"] == self.ip_address and entry["hostname"] == canonical_hostname: if (
entry["ip"] == self.ip_address
and entry["hostname"] == canonical_hostname
):
return True return True
return False return False
@ -73,7 +77,7 @@ class HostEntry:
raise ValueError("At least one hostname is required") raise ValueError("At least one hostname is required")
hostname_pattern = re.compile( 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])?)*$' 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: for hostname in self.hostnames:
@ -104,7 +108,9 @@ class HostEntry:
# Build the canonical hostname part # Build the canonical hostname part
canonical_hostname = self.hostnames[0] if self.hostnames else "" canonical_hostname = self.hostnames[0] if self.hostnames else ""
hostname_tabs = self._calculate_tabs_needed(len(canonical_hostname), hostname_width) hostname_tabs = self._calculate_tabs_needed(
len(canonical_hostname), hostname_width
)
# Start building the line # Start building the line
line_parts.append(ip_part) line_parts.append(ip_part)
@ -147,7 +153,7 @@ class HostEntry:
return max(1, tabs_needed) return max(1, tabs_needed)
@classmethod @classmethod
def from_hosts_line(cls, line: str) -> Optional['HostEntry']: def from_hosts_line(cls, line: str) -> Optional["HostEntry"]:
""" """
Parse a hosts file line into a HostEntry. Parse a hosts file line into a HostEntry.
@ -163,18 +169,19 @@ class HostEntry:
# Check if line is commented out (inactive) # Check if line is commented out (inactive)
is_active = True is_active = True
if original_line.startswith('#'): if original_line.startswith("#"):
is_active = False is_active = False
line = original_line[1:].strip() line = original_line[1:].strip()
# Handle comment-only lines # Handle comment-only lines
if not line or line.startswith('#'): if not line or line.startswith("#"):
return None return None
# Split line into parts, handling both spaces and tabs # Split line into parts, handling both spaces and tabs
import re import re
# Split on any whitespace (spaces, tabs, or combinations) # Split on any whitespace (spaces, tabs, or combinations)
parts = re.split(r'\s+', line.strip()) parts = re.split(r"\s+", line.strip())
if len(parts) < 2: if len(parts) < 2:
return None return None
@ -184,9 +191,9 @@ class HostEntry:
# Parse hostnames and comments # Parse hostnames and comments
for i, part in enumerate(parts[1:], 1): for i, part in enumerate(parts[1:], 1):
if part.startswith('#'): if part.startswith("#"):
# Everything from here is a comment # Everything from here is a comment
comment = ' '.join(parts[i:]).lstrip('# ') comment = " ".join(parts[i:]).lstrip("# ")
break break
else: else:
hostnames.append(part) hostnames.append(part)
@ -199,7 +206,7 @@ class HostEntry:
ip_address=ip_address, ip_address=ip_address,
hostnames=hostnames, hostnames=hostnames,
comment=comment, comment=comment,
is_active=is_active is_active=is_active,
) )
except ValueError: except ValueError:
# Skip invalid entries # Skip invalid entries
@ -216,6 +223,7 @@ class HostsFile:
header_comments: Comments at the beginning of the file header_comments: Comments at the beginning of the file
footer_comments: Comments at the end of the file footer_comments: Comments at the end of the file
""" """
entries: List[HostEntry] = field(default_factory=list) entries: List[HostEntry] = field(default_factory=list)
header_comments: List[str] = field(default_factory=list) header_comments: List[str] = field(default_factory=list)
footer_comments: List[str] = field(default_factory=list) footer_comments: List[str] = field(default_factory=list)
@ -252,11 +260,13 @@ class HostsFile:
""" """
# Separate default and non-default entries # Separate default and non-default entries
default_entries = [entry for entry in self.entries if entry.is_default_entry()] default_entries = [entry for entry in self.entries if entry.is_default_entry()]
non_default_entries = [entry for entry in self.entries if not entry.is_default_entry()] non_default_entries = [
entry for entry in self.entries if not entry.is_default_entry()
]
def ip_sort_key(entry): def ip_sort_key(entry):
try: try:
ip_str = entry.ip_address.lstrip('# ') ip_str = entry.ip_address.lstrip("# ")
ip_obj = ipaddress.ip_address(ip_str) ip_obj = ipaddress.ip_address(ip_str)
# Create a tuple for sorting: (version, ip_int) # Create a tuple for sorting: (version, ip_int)
return (ip_obj.version, int(ip_obj)) return (ip_obj.version, int(ip_obj))
@ -275,8 +285,11 @@ class HostsFile:
# Sort default entries according to their fixed order # Sort default entries according to their fixed order
def default_sort_key(entry): def default_sort_key(entry):
for i, default in enumerate(default_order): for i, default in enumerate(default_order):
if (entry.ip_address == default["ip"] and if (
entry.hostnames and entry.hostnames[0] == default["hostname"]): entry.ip_address == default["ip"]
and entry.hostnames
and entry.hostnames[0] == default["hostname"]
):
return i return i
return 999 # fallback for any unexpected default entries return 999 # fallback for any unexpected default entries
@ -297,7 +310,9 @@ class HostsFile:
""" """
# Separate default and non-default entries # Separate default and non-default entries
default_entries = [entry for entry in self.entries if entry.is_default_entry()] default_entries = [entry for entry in self.entries if entry.is_default_entry()]
non_default_entries = [entry for entry in self.entries if not entry.is_default_entry()] non_default_entries = [
entry for entry in self.entries if not entry.is_default_entry()
]
def hostname_sort_key(entry): def hostname_sort_key(entry):
hostname = (entry.hostnames[0] if entry.hostnames else "").lower() hostname = (entry.hostnames[0] if entry.hostnames else "").lower()
@ -314,8 +329,11 @@ class HostsFile:
# Sort default entries according to their fixed order # Sort default entries according to their fixed order
def default_sort_key(entry): def default_sort_key(entry):
for i, default in enumerate(default_order): for i, default in enumerate(default_order):
if (entry.ip_address == default["ip"] and if (
entry.hostnames and entry.hostnames[0] == default["hostname"]): entry.ip_address == default["ip"]
and entry.hostnames
and entry.hostnames[0] == default["hostname"]
):
return i return i
return 999 # fallback for any unexpected default entries return 999 # fallback for any unexpected default entries

View file

@ -42,10 +42,12 @@ class HostsParser:
raise FileNotFoundError(f"Hosts file not found: {self.file_path}") raise FileNotFoundError(f"Hosts file not found: {self.file_path}")
try: try:
with open(self.file_path, 'r', encoding='utf-8') as f: with open(self.file_path, "r", encoding="utf-8") as f:
lines = f.readlines() lines = f.readlines()
except PermissionError: except PermissionError:
raise PermissionError(f"Permission denied reading hosts file: {self.file_path}") raise PermissionError(
f"Permission denied reading hosts file: {self.file_path}"
)
hosts_file = HostsFile() hosts_file = HostsFile()
entries_started = False entries_started = False
@ -62,7 +64,7 @@ class HostsParser:
entries_started = True entries_started = True
elif stripped_line and not entries_started: elif stripped_line and not entries_started:
# This is a comment before any entries (header) # This is a comment before any entries (header)
if stripped_line.startswith('#'): if stripped_line.startswith("#"):
comment_text = stripped_line[1:].strip() comment_text = stripped_line[1:].strip()
hosts_file.header_comments.append(comment_text) hosts_file.header_comments.append(comment_text)
else: else:
@ -70,7 +72,7 @@ class HostsParser:
hosts_file.header_comments.append(stripped_line) hosts_file.header_comments.append(stripped_line)
elif stripped_line and entries_started: elif stripped_line and entries_started:
# This is a comment after entries have started # This is a comment after entries have started
if stripped_line.startswith('#'): if stripped_line.startswith("#"):
comment_text = stripped_line[1:].strip() comment_text = stripped_line[1:].strip()
hosts_file.footer_comments.append(comment_text) hosts_file.footer_comments.append(comment_text)
else: else:
@ -140,20 +142,16 @@ class HostsParser:
# If no header exists, create default header # If no header exists, create default header
if not header_comments: if not header_comments:
return [ return ["#", "Host Database", "", management_line, "#"]
"#",
"Host Database",
"",
management_line,
"#"
]
# Check for enclosing comment patterns # Check for enclosing comment patterns
enclosing_pattern = self._detect_enclosing_pattern(header_comments) enclosing_pattern = self._detect_enclosing_pattern(header_comments)
if enclosing_pattern: if enclosing_pattern:
# Insert management line within the enclosing pattern # Insert management line within the enclosing pattern
return self._insert_in_enclosing_pattern(header_comments, management_line, enclosing_pattern) return self._insert_in_enclosing_pattern(
header_comments, management_line, enclosing_pattern
)
else: else:
# No enclosing pattern, append management line # No enclosing pattern, append management line
result = header_comments.copy() result = header_comments.copy()
@ -192,33 +190,39 @@ class HostsParser:
# Check for ### pattern # Check for ### pattern
if first_line == "###" and last_line == "###": if first_line == "###" and last_line == "###":
return { return {
'type': 'triple_hash', "type": "triple_hash",
'start_index': 0, "start_index": 0,
'end_index': last_pattern_index, "end_index": last_pattern_index,
'pattern': '###' "pattern": "###",
} }
# Check for # # pattern # Check for # # pattern
if first_line == "#" and last_line == "#": if first_line == "#" and last_line == "#":
return { return {
'type': 'single_hash', "type": "single_hash",
'start_index': 0, "start_index": 0,
'end_index': last_pattern_index, "end_index": last_pattern_index,
'pattern': '#' "pattern": "#",
} }
# Check for other repeating patterns (like ####, #####, etc.) # Check for other repeating patterns (like ####, #####, etc.)
if len(first_line) > 1 and first_line == last_line and all(c == '#' for c in first_line): if (
len(first_line) > 1
and first_line == last_line
and all(c == "#" for c in first_line)
):
return { return {
'type': 'repeating_hash', "type": "repeating_hash",
'start_index': 0, "start_index": 0,
'end_index': last_pattern_index, "end_index": last_pattern_index,
'pattern': first_line "pattern": first_line,
} }
return None return None
def _insert_in_enclosing_pattern(self, header_comments: list, management_line: str, pattern_info: dict) -> list: def _insert_in_enclosing_pattern(
self, header_comments: list, management_line: str, pattern_info: dict
) -> list:
""" """
Insert management line within an enclosing comment pattern. Insert management line within an enclosing comment pattern.
@ -233,7 +237,7 @@ class HostsParser:
result = header_comments.copy() result = header_comments.copy()
# Find the best insertion point (before the closing pattern) # Find the best insertion point (before the closing pattern)
insert_index = pattern_info['end_index'] insert_index = pattern_info["end_index"]
# Look for an empty line before the closing pattern to insert after it # Look for an empty line before the closing pattern to insert after it
# Otherwise, insert right before the closing pattern # Otherwise, insert right before the closing pattern
@ -294,9 +298,10 @@ class HostsParser:
""" """
# Create backup if requested # Create backup if requested
if backup and self.file_path.exists(): if backup and self.file_path.exists():
backup_path = self.file_path.with_suffix('.bak') backup_path = self.file_path.with_suffix(".bak")
try: try:
import shutil import shutil
shutil.copy2(self.file_path, backup_path) shutil.copy2(self.file_path, backup_path)
except Exception as e: except Exception as e:
raise OSError(f"Failed to create backup: {e}") raise OSError(f"Failed to create backup: {e}")
@ -305,9 +310,9 @@ class HostsParser:
content = self.serialize(hosts_file) content = self.serialize(hosts_file)
# Write atomically using a temporary file # Write atomically using a temporary file
temp_path = self.file_path.with_suffix('.tmp') temp_path = self.file_path.with_suffix(".tmp")
try: try:
with open(temp_path, 'w', encoding='utf-8') as f: with open(temp_path, "w", encoding="utf-8") as f:
f.write(content) f.write(content)
# Atomic move # Atomic move
@ -344,21 +349,21 @@ class HostsParser:
Dictionary with file information Dictionary with file information
""" """
info = { info = {
'path': str(self.file_path), "path": str(self.file_path),
'exists': self.file_path.exists(), "exists": self.file_path.exists(),
'readable': False, "readable": False,
'writable': False, "writable": False,
'size': 0, "size": 0,
'modified': None "modified": None,
} }
if info['exists']: if info["exists"]:
try: try:
info['readable'] = os.access(self.file_path, os.R_OK) info["readable"] = os.access(self.file_path, os.R_OK)
info['writable'] = os.access(self.file_path, os.W_OK) info["writable"] = os.access(self.file_path, os.W_OK)
stat = self.file_path.stat() stat = self.file_path.stat()
info['size'] = stat.st_size info["size"] = stat.st_size
info['modified'] = stat.st_mtime info["modified"] = stat.st_mtime
except Exception: except Exception:
pass pass
@ -367,19 +372,23 @@ class HostsParser:
class HostsParserError(Exception): class HostsParserError(Exception):
"""Base exception for hosts parser errors.""" """Base exception for hosts parser errors."""
pass pass
class HostsFileNotFoundError(HostsParserError): class HostsFileNotFoundError(HostsParserError):
"""Raised when the hosts file is not found.""" """Raised when the hosts file is not found."""
pass pass
class HostsPermissionError(HostsParserError): class HostsPermissionError(HostsParserError):
"""Raised when there are permission issues with the hosts file.""" """Raised when there are permission issues with the hosts file."""
pass pass
class HostsValidationError(HostsParserError): class HostsValidationError(HostsParserError):
"""Raised when hosts file content is invalid.""" """Raised when hosts file content is invalid."""
pass pass

View file

@ -15,6 +15,7 @@ from ..core.models import HostsFile
from ..core.config import Config from ..core.config import Config
from ..core.manager import HostsManager from ..core.manager import HostsManager
from .config_modal import ConfigModal from .config_modal import ConfigModal
from .password_modal import PasswordModal
from .styles import HOSTS_MANAGER_CSS from .styles import HOSTS_MANAGER_CSS
from .keybindings import HOSTS_MANAGER_BINDINGS from .keybindings import HOSTS_MANAGER_BINDINGS
from .table_handler import TableHandler from .table_handler import TableHandler
@ -68,14 +69,19 @@ class HostsManagerApp(App):
with Horizontal(classes="hosts-container"): with Horizontal(classes="hosts-container"):
# Left pane - entries table # Left pane - entries table
with Vertical(classes="left-pane"): with Vertical(classes="left-pane") as left_pane:
yield Static("Host Entries", id="entries-title") left_pane.border_title = "Host Entries"
yield DataTable(id="entries-table") yield DataTable(id="entries-table")
# Right pane - entry details or edit form # Right pane - entry details or edit form
with Vertical(classes="right-pane"): with Vertical(classes="right-pane") as right_pane:
yield Static("Entry Details", id="details-title") right_pane.border_title = "Entry Details"
yield DataTable(id="entry-details-table", show_header=False, show_cursor=False, disabled=True) yield DataTable(
id="entry-details-table",
show_header=False,
show_cursor=False,
disabled=True,
)
# Edit form (initially hidden) # Edit form (initially hidden)
with Vertical(id="entry-edit-form", classes="hidden"): with Vertical(id="entry-edit-form", classes="hidden"):
@ -84,7 +90,9 @@ class HostsManagerApp(App):
yield Label("Hostnames (comma-separated):") yield Label("Hostnames (comma-separated):")
yield Input(placeholder="Enter hostnames", id="hostname-input") yield Input(placeholder="Enter hostnames", id="hostname-input")
yield Label("Comment:") yield Label("Comment:")
yield Input(placeholder="Enter comment (optional)", id="comment-input") yield Input(
placeholder="Enter comment (optional)", id="comment-input"
)
yield Checkbox("Active", id="active-checkbox") yield Checkbox("Active", id="active-checkbox")
# Status bar for error/temporary messages (overlay, doesn't affect layout) # Status bar for error/temporary messages (overlay, doesn't affect layout)
@ -99,9 +107,8 @@ class HostsManagerApp(App):
try: try:
# Remember the currently selected entry before reload # Remember the currently selected entry before reload
previous_entry = None previous_entry = None
if ( if self.hosts_file.entries and self.selected_entry_index < len(
self.hosts_file.entries self.hosts_file.entries
and self.selected_entry_index < len(self.hosts_file.entries)
): ):
previous_entry = self.hosts_file.entries[self.selected_entry_index] previous_entry = self.hosts_file.entries[self.selected_entry_index]
@ -128,7 +135,7 @@ class HostsManagerApp(App):
else: else:
# Auto-clear regular message after 3 seconds # Auto-clear regular message after 3 seconds
self.set_timer(3.0, lambda: self._clear_status_message()) self.set_timer(3.0, lambda: self._clear_status_message())
except: except Exception:
# Fallback if status bar not found (during initialization) # Fallback if status bar not found (during initialization)
pass pass
@ -146,7 +153,7 @@ class HostsManagerApp(App):
status_bar = self.query_one("#status-bar", Static) status_bar = self.query_one("#status-bar", Static)
status_bar.update("") status_bar.update("")
status_bar.add_class("hidden") status_bar.add_class("hidden")
except: except Exception:
pass pass
# Event handlers # Event handlers
@ -154,8 +161,8 @@ class HostsManagerApp(App):
"""Handle row highlighting (cursor movement) in the DataTable.""" """Handle row highlighting (cursor movement) in the DataTable."""
if event.data_table.id == "entries-table": if event.data_table.id == "entries-table":
# Convert display index to actual index # Convert display index to actual index
self.selected_entry_index = self.table_handler.display_index_to_actual_index( self.selected_entry_index = (
event.cursor_row self.table_handler.display_index_to_actual_index(event.cursor_row)
) )
self.details_handler.update_entry_details() self.details_handler.update_entry_details()
@ -163,8 +170,8 @@ class HostsManagerApp(App):
"""Handle row selection in the DataTable.""" """Handle row selection in the DataTable."""
if event.data_table.id == "entries-table": if event.data_table.id == "entries-table":
# Convert display index to actual index # Convert display index to actual index
self.selected_entry_index = self.table_handler.display_index_to_actual_index( self.selected_entry_index = (
event.cursor_row self.table_handler.display_index_to_actual_index(event.cursor_row)
) )
self.details_handler.update_entry_details() self.details_handler.update_entry_details()
@ -213,6 +220,7 @@ class HostsManagerApp(App):
def action_config(self) -> None: def action_config(self) -> None:
"""Show configuration modal.""" """Show configuration modal."""
def handle_config_result(config_changed: bool) -> None: def handle_config_result(config_changed: bool) -> None:
if config_changed: if config_changed:
# Reload the table to apply new filtering # Reload the table to apply new filtering
@ -245,15 +253,42 @@ class HostsManagerApp(App):
else: else:
self.update_status(f"Error exiting edit mode: {message}") self.update_status(f"Error exiting edit mode: {message}")
else: else:
# Enter edit mode # Enter edit mode - first try without password
success, message = self.manager.enter_edit_mode() success, message = self.manager.enter_edit_mode()
if success: if success:
self.edit_mode = True self.edit_mode = True
self.sub_title = "Edit mode" self.sub_title = "Edit mode"
self.update_status(message) self.update_status(message)
elif "Password required" in message:
# Show password modal
self._request_sudo_password()
else: else:
self.update_status(f"Error entering edit mode: {message}") self.update_status(f"Error entering edit mode: {message}")
def _request_sudo_password(self) -> None:
"""Show password modal and attempt sudo authentication."""
def handle_password(password: str) -> None:
if password is None:
# User cancelled
self.update_status("Edit mode cancelled")
return
# Try to enter edit mode with password
success, message = self.manager.enter_edit_mode(password)
if success:
self.edit_mode = True
self.sub_title = "Edit mode"
self.update_status(message)
elif "Incorrect password" in message:
# Show error and try again
self.update_status("❌ Incorrect password. Please try again.")
self.set_timer(2.0, lambda: self._request_sudo_password())
else:
self.update_status(f"❌ Error entering edit mode: {message}")
self.push_screen(PasswordModal(), handle_password)
def action_edit_entry(self) -> None: def action_edit_entry(self) -> None:
"""Enter edit mode for the selected entry.""" """Enter edit mode for the selected entry."""
if not self.edit_mode: if not self.edit_mode:
@ -364,3 +399,11 @@ class HostsManagerApp(App):
def update_edit_form(self) -> None: def update_edit_form(self) -> None:
"""Update the edit form with current entry values.""" """Update the edit form with current entry values."""
self.details_handler.update_edit_form() self.details_handler.update_edit_form()
def watch_entry_edit_mode(self, entry_edit_mode: bool) -> None:
"""Update the right pane border title when entry edit mode changes."""
right_pane = self.query_one(".right-pane")
if entry_edit_mode:
right_pane.border_title = "Edit Entry"
else:
right_pane.border_title = "Entry Details"

View file

@ -79,12 +79,19 @@ class ConfigModal(ModalScreen):
"Show default system entries (localhost, broadcasthost)", "Show default system entries (localhost, broadcasthost)",
value=self.config.should_show_default_entries(), value=self.config.should_show_default_entries(),
id="show-defaults-checkbox", id="show-defaults-checkbox",
classes="config-option" classes="config-option",
) )
with Horizontal(classes="button-row"): with Horizontal(classes="button-row"):
yield Button("Save", variant="primary", id="save-button", classes="config-button") yield Button(
yield Button("Cancel", variant="default", id="cancel-button", classes="config-button") "Save", variant="primary", id="save-button", classes="config-button"
)
yield Button(
"Cancel",
variant="default",
id="cancel-button",
classes="config-button",
)
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses.""" """Handle button presses."""

View file

@ -5,7 +5,7 @@ This module handles the display and updating of entry details
and edit forms in the right pane. and edit forms in the right pane.
""" """
from textual.widgets import Static, Input, Checkbox, DataTable from textual.widgets import Input, Checkbox, DataTable
class DetailsHandler: class DetailsHandler:
@ -82,7 +82,9 @@ class DetailsHandler:
details_table.add_row("IP Address", entry.ip_address, key="ip") details_table.add_row("IP Address", entry.ip_address, key="ip")
details_table.add_row("Hostnames", ", ".join(entry.hostnames), key="hostnames") details_table.add_row("Hostnames", ", ".join(entry.hostnames), key="hostnames")
details_table.add_row("Comment", entry.comment or "", key="comment") details_table.add_row("Comment", entry.comment or "", key="comment")
details_table.add_row("Active", "Yes" if entry.is_active else "No", key="active") details_table.add_row(
"Active", "Yes" if entry.is_active else "No", key="active"
)
# Add DNS name if present (not in edit form but good to show) # Add DNS name if present (not in edit form but good to show)
if entry.dns_name: if entry.dns_name:

View file

@ -35,11 +35,18 @@ class NavigationHandler:
) )
if success: if success:
# Auto-save the changes immediately # Auto-save the changes immediately
save_success, save_message = self.app.manager.save_hosts_file(self.app.hosts_file) save_success, save_message = self.app.manager.save_hosts_file(
self.app.hosts_file
)
if save_success: if save_success:
self.app.table_handler.populate_entries_table() self.app.table_handler.populate_entries_table()
# Restore cursor position to the same entry # Restore cursor position to the same entry
self.app.set_timer(0.1, lambda: self.app.table_handler.restore_cursor_position(current_entry)) self.app.set_timer(
0.1,
lambda: self.app.table_handler.restore_cursor_position(
current_entry
),
)
self.app.details_handler.update_entry_details() self.app.details_handler.update_entry_details()
self.app.update_status(f"{message} - Changes saved automatically") self.app.update_status(f"{message} - Changes saved automatically")
else: else:
@ -64,7 +71,9 @@ class NavigationHandler:
) )
if success: if success:
# Auto-save the changes immediately # Auto-save the changes immediately
save_success, save_message = self.app.manager.save_hosts_file(self.app.hosts_file) save_success, save_message = self.app.manager.save_hosts_file(
self.app.hosts_file
)
if save_success: if save_success:
# Update the selection index to follow the moved entry # Update the selection index to follow the moved entry
if self.app.selected_entry_index > 0: if self.app.selected_entry_index > 0:
@ -101,7 +110,9 @@ class NavigationHandler:
) )
if success: if success:
# Auto-save the changes immediately # Auto-save the changes immediately
save_success, save_message = self.app.manager.save_hosts_file(self.app.hosts_file) save_success, save_message = self.app.manager.save_hosts_file(
self.app.hosts_file
)
if save_success: if save_success:
# Update the selection index to follow the moved entry # Update the selection index to follow the moved entry
if self.app.selected_entry_index < len(self.app.hosts_file.entries) - 1: if self.app.selected_entry_index < len(self.app.hosts_file.entries) - 1:

View file

@ -0,0 +1,152 @@
"""
Password input modal window for sudo authentication.
This module provides a secure password input modal for sudo operations.
"""
from textual.app import ComposeResult
from textual.containers import Vertical, Horizontal
from textual.widgets import Static, Button, Input
from textual.screen import ModalScreen
from textual.binding import Binding
class PasswordModal(ModalScreen):
"""
Modal screen for secure password input.
Provides a floating window for entering sudo password with proper masking.
"""
CSS = """
PasswordModal {
align: center middle;
}
.password-container {
width: 60;
height: 12;
background: $surface;
border: thick $primary;
padding: 1;
}
.password-title {
text-align: center;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.password-message {
text-align: center;
color: $text;
margin-bottom: 1;
}
.password-input {
margin: 1 0;
}
.button-row {
margin-top: 1;
align: center middle;
}
.password-button {
margin: 0 1;
min-width: 10;
}
.error-message {
color: $error;
text-align: center;
margin: 1 0;
}
"""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("enter", "submit", "Submit"),
]
def __init__(self, message: str = "Enter your password for sudo access:"):
super().__init__()
self.message = message
self.error_message = ""
def compose(self) -> ComposeResult:
"""Create the password modal layout."""
with Vertical(classes="password-container"):
yield Static("Sudo Authentication", classes="password-title")
yield Static(self.message, classes="password-message")
yield Input(
placeholder="Password",
password=True,
id="password-input",
classes="password-input",
)
# Error message placeholder (initially empty)
yield Static("", id="error-message", classes="error-message")
with Horizontal(classes="button-row"):
yield Button(
"OK", variant="primary", id="ok-button", classes="password-button"
)
yield Button(
"Cancel",
variant="default",
id="cancel-button",
classes="password-button",
)
def on_mount(self) -> None:
"""Focus the password input when modal opens."""
password_input = self.query_one("#password-input", Input)
password_input.focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "ok-button":
self.action_submit()
elif event.button.id == "cancel-button":
self.action_cancel()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle Enter key in password input field."""
if event.input.id == "password-input":
self.action_submit()
def action_submit(self) -> None:
"""Submit the password and close modal."""
password_input = self.query_one("#password-input", Input)
password = password_input.value
if not password:
self.show_error("Password cannot be empty")
return
# Clear any previous error
self.clear_error()
# Return the password
self.dismiss(password)
def action_cancel(self) -> None:
"""Cancel password input and close modal."""
self.dismiss(None)
def show_error(self, message: str) -> None:
"""Show an error message in the modal."""
error_static = self.query_one("#error-message", Static)
error_static.update(message)
# Keep focus on password input
password_input = self.query_one("#password-input", Input)
password_input.focus()
def clear_error(self) -> None:
"""Clear the error message."""
error_static = self.query_one("#error-message", Static)
error_static.update("")

View file

@ -160,7 +160,9 @@ class TableHandler:
# Update the DataTable cursor position using display index # Update the DataTable cursor position using display index
table = self.app.query_one("#entries-table", DataTable) table = self.app.query_one("#entries-table", DataTable)
display_index = self.actual_index_to_display_index(self.app.selected_entry_index) display_index = self.actual_index_to_display_index(
self.app.selected_entry_index
)
if table.row_count > 0 and display_index < table.row_count: if table.row_count > 0 and display_index < table.row_count:
# Move cursor to the selected row # Move cursor to the selected row
table.move_cursor(row=display_index) table.move_cursor(row=display_index)
@ -180,13 +182,14 @@ class TableHandler:
# Remember the currently selected entry # Remember the currently selected entry
current_entry = None current_entry = None
if self.app.hosts_file.entries and self.app.selected_entry_index < len(self.app.hosts_file.entries): if self.app.hosts_file.entries and self.app.selected_entry_index < len(
self.app.hosts_file.entries
):
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index] current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
# Sort the entries # Sort the entries
self.app.hosts_file.entries.sort( self.app.hosts_file.entries.sort(
key=lambda entry: entry.ip_address, key=lambda entry: entry.ip_address, reverse=not self.app.sort_ascending
reverse=not self.app.sort_ascending
) )
# Refresh the table and restore cursor position # Refresh the table and restore cursor position
@ -205,13 +208,15 @@ class TableHandler:
# Remember the currently selected entry # Remember the currently selected entry
current_entry = None current_entry = None
if self.app.hosts_file.entries and self.app.selected_entry_index < len(self.app.hosts_file.entries): if self.app.hosts_file.entries and self.app.selected_entry_index < len(
self.app.hosts_file.entries
):
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index] current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
# Sort the entries # Sort the entries
self.app.hosts_file.entries.sort( self.app.hosts_file.entries.sort(
key=lambda entry: entry.hostnames[0].lower() if entry.hostnames else "", key=lambda entry: entry.hostnames[0].lower() if entry.hostnames else "",
reverse=not self.app.sort_ascending reverse=not self.app.sort_ascending,
) )
# Refresh the table and restore cursor position # Refresh the table and restore cursor position

View file

@ -18,7 +18,7 @@ class TestConfig:
def test_config_initialization(self): def test_config_initialization(self):
"""Test basic config initialization with defaults.""" """Test basic config initialization with defaults."""
with patch.object(Config, 'load'): with patch.object(Config, "load"):
config = Config() config = Config()
# Check default settings # Check default settings
@ -29,24 +29,28 @@ class TestConfig:
def test_default_settings_structure(self): def test_default_settings_structure(self):
"""Test that default settings have the expected structure.""" """Test that default settings have the expected structure."""
with patch.object(Config, 'load'): with patch.object(Config, "load"):
config = Config() config = Config()
default_entries = config.get("default_entries", []) default_entries = config.get("default_entries", [])
assert len(default_entries) == 3 assert len(default_entries) == 3
# Check localhost entries # Check localhost entries
localhost_entries = [e for e in default_entries if e["hostname"] == "localhost"] localhost_entries = [
e for e in default_entries if e["hostname"] == "localhost"
]
assert len(localhost_entries) == 2 # IPv4 and IPv6 assert len(localhost_entries) == 2 # IPv4 and IPv6
# Check broadcasthost entry # Check broadcasthost entry
broadcast_entries = [e for e in default_entries if e["hostname"] == "broadcasthost"] broadcast_entries = [
e for e in default_entries if e["hostname"] == "broadcasthost"
]
assert len(broadcast_entries) == 1 assert len(broadcast_entries) == 1
assert broadcast_entries[0]["ip"] == "255.255.255.255" assert broadcast_entries[0]["ip"] == "255.255.255.255"
def test_config_paths(self): def test_config_paths(self):
"""Test that config paths are set correctly.""" """Test that config paths are set correctly."""
with patch.object(Config, 'load'): with patch.object(Config, "load"):
config = Config() config = Config()
expected_dir = Path.home() / ".config" / "hosts-manager" expected_dir = Path.home() / ".config" / "hosts-manager"
@ -57,7 +61,7 @@ class TestConfig:
def test_get_existing_key(self): def test_get_existing_key(self):
"""Test getting an existing configuration key.""" """Test getting an existing configuration key."""
with patch.object(Config, 'load'): with patch.object(Config, "load"):
config = Config() config = Config()
result = config.get("show_default_entries") result = config.get("show_default_entries")
@ -65,7 +69,7 @@ class TestConfig:
def test_get_nonexistent_key_with_default(self): def test_get_nonexistent_key_with_default(self):
"""Test getting a nonexistent key with default value.""" """Test getting a nonexistent key with default value."""
with patch.object(Config, 'load'): with patch.object(Config, "load"):
config = Config() config = Config()
result = config.get("nonexistent_key", "default_value") result = config.get("nonexistent_key", "default_value")
@ -73,7 +77,7 @@ class TestConfig:
def test_get_nonexistent_key_without_default(self): def test_get_nonexistent_key_without_default(self):
"""Test getting a nonexistent key without default value.""" """Test getting a nonexistent key without default value."""
with patch.object(Config, 'load'): with patch.object(Config, "load"):
config = Config() config = Config()
result = config.get("nonexistent_key") result = config.get("nonexistent_key")
@ -81,7 +85,7 @@ class TestConfig:
def test_set_configuration_value(self): def test_set_configuration_value(self):
"""Test setting a configuration value.""" """Test setting a configuration value."""
with patch.object(Config, 'load'): with patch.object(Config, "load"):
config = Config() config = Config()
config.set("test_key", "test_value") config.set("test_key", "test_value")
@ -89,7 +93,7 @@ class TestConfig:
def test_set_overwrites_existing_value(self): def test_set_overwrites_existing_value(self):
"""Test that setting overwrites existing values.""" """Test that setting overwrites existing values."""
with patch.object(Config, 'load'): with patch.object(Config, "load"):
config = Config() config = Config()
# Set initial value # Set initial value
@ -102,7 +106,7 @@ class TestConfig:
def test_is_default_entry_true(self): def test_is_default_entry_true(self):
"""Test identifying default entries correctly.""" """Test identifying default entries correctly."""
with patch.object(Config, 'load'): with patch.object(Config, "load"):
config = Config() config = Config()
# Test localhost IPv4 # Test localhost IPv4
@ -116,7 +120,7 @@ class TestConfig:
def test_is_default_entry_false(self): def test_is_default_entry_false(self):
"""Test that non-default entries are not identified as default.""" """Test that non-default entries are not identified as default."""
with patch.object(Config, 'load'): with patch.object(Config, "load"):
config = Config() config = Config()
# Test custom entries # Test custom entries
@ -126,14 +130,14 @@ class TestConfig:
def test_should_show_default_entries_default(self): def test_should_show_default_entries_default(self):
"""Test default value for show_default_entries.""" """Test default value for show_default_entries."""
with patch.object(Config, 'load'): with patch.object(Config, "load"):
config = Config() config = Config()
assert config.should_show_default_entries() is False assert config.should_show_default_entries() is False
def test_should_show_default_entries_configured(self): def test_should_show_default_entries_configured(self):
"""Test configured value for show_default_entries.""" """Test configured value for show_default_entries."""
with patch.object(Config, 'load'): with patch.object(Config, "load"):
config = Config() config = Config()
config.set("show_default_entries", True) config.set("show_default_entries", True)
@ -141,7 +145,7 @@ class TestConfig:
def test_toggle_show_default_entries(self): def test_toggle_show_default_entries(self):
"""Test toggling the show_default_entries setting.""" """Test toggling the show_default_entries setting."""
with patch.object(Config, 'load'), patch.object(Config, 'save') as mock_save: with patch.object(Config, "load"), patch.object(Config, "save") as mock_save:
config = Config() config = Config()
# Initial state should be False # Initial state should be False
@ -160,7 +164,7 @@ class TestConfig:
def test_load_nonexistent_file(self): def test_load_nonexistent_file(self):
"""Test loading config when file doesn't exist.""" """Test loading config when file doesn't exist."""
with patch('pathlib.Path.exists', return_value=False): with patch("pathlib.Path.exists", return_value=False):
config = Config() config = Config()
# Should use defaults when file doesn't exist # Should use defaults when file doesn't exist
@ -168,13 +172,12 @@ class TestConfig:
def test_load_existing_file(self): def test_load_existing_file(self):
"""Test loading config from existing file.""" """Test loading config from existing file."""
test_config = { test_config = {"show_default_entries": True, "custom_setting": "custom_value"}
"show_default_entries": True,
"custom_setting": "custom_value"
}
with patch('pathlib.Path.exists', return_value=True), \ with (
patch('builtins.open', mock_open(read_data=json.dumps(test_config))): patch("pathlib.Path.exists", return_value=True),
patch("builtins.open", mock_open(read_data=json.dumps(test_config))),
):
config = Config() config = Config()
# Should load values from file # Should load values from file
@ -186,8 +189,10 @@ class TestConfig:
def test_load_invalid_json(self): def test_load_invalid_json(self):
"""Test loading config with invalid JSON falls back to defaults.""" """Test loading config with invalid JSON falls back to defaults."""
with patch('pathlib.Path.exists', return_value=True), \ with (
patch('builtins.open', mock_open(read_data="invalid json")): patch("pathlib.Path.exists", return_value=True),
patch("builtins.open", mock_open(read_data="invalid json")),
):
config = Config() config = Config()
# Should use defaults when JSON is invalid # Should use defaults when JSON is invalid
@ -195,8 +200,10 @@ class TestConfig:
def test_load_file_io_error(self): def test_load_file_io_error(self):
"""Test loading config with file I/O error falls back to defaults.""" """Test loading config with file I/O error falls back to defaults."""
with patch('pathlib.Path.exists', return_value=True), \ with (
patch('builtins.open', side_effect=IOError("File error")): patch("pathlib.Path.exists", return_value=True),
patch("builtins.open", side_effect=IOError("File error")),
):
config = Config() config = Config()
# Should use defaults when file can't be read # Should use defaults when file can't be read
@ -204,9 +211,11 @@ class TestConfig:
def test_save_creates_directory(self): def test_save_creates_directory(self):
"""Test that save creates config directory if it doesn't exist.""" """Test that save creates config directory if it doesn't exist."""
with patch.object(Config, 'load'), \ with (
patch('pathlib.Path.mkdir') as mock_mkdir, \ patch.object(Config, "load"),
patch('builtins.open', mock_open()) as mock_file: patch("pathlib.Path.mkdir") as mock_mkdir,
patch("builtins.open", mock_open()) as mock_file,
):
config = Config() config = Config()
config.save() config.save()
@ -216,19 +225,21 @@ class TestConfig:
def test_save_writes_json(self): def test_save_writes_json(self):
"""Test that save writes configuration as JSON.""" """Test that save writes configuration as JSON."""
with patch.object(Config, 'load'), \ with (
patch('pathlib.Path.mkdir'), \ patch.object(Config, "load"),
patch('builtins.open', mock_open()) as mock_file: patch("pathlib.Path.mkdir"),
patch("builtins.open", mock_open()) as mock_file,
):
config = Config() config = Config()
config.set("test_key", "test_value") config.set("test_key", "test_value")
config.save() config.save()
# Check that file was opened for writing # Check that file was opened for writing
mock_file.assert_called_once_with(config.config_file, 'w') mock_file.assert_called_once_with(config.config_file, "w")
# Check that JSON was written # Check that JSON was written
handle = mock_file() handle = mock_file()
written_data = ''.join(call.args[0] for call in handle.write.call_args_list) written_data = "".join(call.args[0] for call in handle.write.call_args_list)
# Should be valid JSON containing our test data # Should be valid JSON containing our test data
parsed_data = json.loads(written_data) parsed_data = json.loads(written_data)
@ -236,9 +247,11 @@ class TestConfig:
def test_save_io_error_silent_fail(self): def test_save_io_error_silent_fail(self):
"""Test that save silently fails on I/O error.""" """Test that save silently fails on I/O error."""
with patch.object(Config, 'load'), \ with (
patch('pathlib.Path.mkdir'), \ patch.object(Config, "load"),
patch('builtins.open', side_effect=IOError("Write error")): patch("pathlib.Path.mkdir"),
patch("builtins.open", side_effect=IOError("Write error")),
):
config = Config() config = Config()
# Should not raise exception # Should not raise exception
@ -246,8 +259,10 @@ class TestConfig:
def test_save_directory_creation_error_silent_fail(self): def test_save_directory_creation_error_silent_fail(self):
"""Test that save silently fails on directory creation error.""" """Test that save silently fails on directory creation error."""
with patch.object(Config, 'load'), \ with (
patch('pathlib.Path.mkdir', side_effect=OSError("Permission denied")): patch.object(Config, "load"),
patch("pathlib.Path.mkdir", side_effect=OSError("Permission denied")),
):
config = Config() config = Config()
# Should not raise exception # Should not raise exception
@ -259,7 +274,7 @@ class TestConfig:
config_dir = Path(temp_dir) / "hosts-manager" config_dir = Path(temp_dir) / "hosts-manager"
config_file = config_dir / "config.json" config_file = config_dir / "config.json"
with patch.object(Config, '__init__', lambda self: None): with patch.object(Config, "__init__", lambda self: None):
config = Config() config = Config()
config.config_dir = config_dir config.config_dir = config_dir
config.config_file = config_file config.config_file = config_file

View file

@ -32,7 +32,7 @@ class TestConfigModal:
modal = ConfigModal(mock_config) modal = ConfigModal(mock_config)
# Test that compose method exists and is callable # Test that compose method exists and is callable
assert hasattr(modal, 'compose') assert hasattr(modal, "compose")
assert callable(modal.compose) assert callable(modal.compose)
def test_action_save_updates_config(self): def test_action_save_updates_config(self):
@ -171,7 +171,7 @@ class TestConfigModal:
modal = ConfigModal(mock_config) modal = ConfigModal(mock_config)
# Check that CSS is defined # Check that CSS is defined
assert hasattr(modal, 'CSS') assert hasattr(modal, "CSS")
assert isinstance(modal.CSS, str) assert isinstance(modal.CSS, str)
assert len(modal.CSS) > 0 assert len(modal.CSS) > 0
@ -207,12 +207,14 @@ class TestConfigModal:
# Test that compose method exists and has correct signature # Test that compose method exists and has correct signature
import inspect import inspect
sig = inspect.signature(modal.compose) sig = inspect.signature(modal.compose)
assert len(sig.parameters) == 0 # No parameters except self assert len(sig.parameters) == 0 # No parameters except self
# Test return type annotation if present # Test return type annotation if present
if sig.return_annotation != inspect.Signature.empty: if sig.return_annotation != inspect.Signature.empty:
from textual.app import ComposeResult from textual.app import ComposeResult
assert sig.return_annotation == ComposeResult assert sig.return_annotation == ComposeResult
def test_modal_inheritance(self): def test_modal_inheritance(self):
@ -221,8 +223,9 @@ class TestConfigModal:
modal = ConfigModal(mock_config) modal = ConfigModal(mock_config)
from textual.screen import ModalScreen from textual.screen import ModalScreen
assert isinstance(modal, ModalScreen) assert isinstance(modal, ModalScreen)
# Should have the config attribute # Should have the config attribute
assert hasattr(modal, 'config') assert hasattr(modal, "config")
assert modal.config == mock_config assert modal.config == mock_config

View file

@ -19,7 +19,7 @@ class TestHostsManagerApp:
def test_app_initialization(self): def test_app_initialization(self):
"""Test application initialization.""" """Test application initialization."""
with patch('hosts.tui.app.HostsParser'), patch('hosts.tui.app.Config'): with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp() app = HostsManagerApp()
assert app.title == "/etc/hosts Manager" assert app.title == "/etc/hosts Manager"
@ -31,11 +31,11 @@ class TestHostsManagerApp:
def test_app_compose_method_exists(self): def test_app_compose_method_exists(self):
"""Test that app has compose method.""" """Test that app has compose method."""
with patch('hosts.tui.app.HostsParser'), patch('hosts.tui.app.Config'): with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp() app = HostsManagerApp()
# Test that compose method exists and is callable # Test that compose method exists and is callable
assert hasattr(app, 'compose') assert hasattr(app, "compose")
assert callable(app.compose) assert callable(app.compose)
def test_load_hosts_file_success(self): def test_load_hosts_file_success(self):
@ -50,14 +50,15 @@ class TestHostsManagerApp:
mock_parser.parse.return_value = test_hosts mock_parser.parse.return_value = test_hosts
mock_parser.get_file_info.return_value = { mock_parser.get_file_info.return_value = {
'path': '/etc/hosts', "path": "/etc/hosts",
'exists': True, "exists": True,
'size': 100 "size": 100,
} }
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
app.populate_entries_table = Mock() app.populate_entries_table = Mock()
app.update_entry_details = Mock() app.update_entry_details = Mock()
@ -76,16 +77,19 @@ class TestHostsManagerApp:
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
mock_parser.parse.side_effect = FileNotFoundError("Hosts file not found") mock_parser.parse.side_effect = FileNotFoundError("Hosts file not found")
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
app.update_status = Mock() app.update_status = Mock()
app.load_hosts_file() app.load_hosts_file()
# Should handle error gracefully # Should handle error gracefully
app.update_status.assert_called_with("❌ Error loading hosts file: Hosts file not found") app.update_status.assert_called_with(
"❌ Error loading hosts file: Hosts file not found"
)
def test_load_hosts_file_permission_error(self): def test_load_hosts_file_permission_error(self):
"""Test handling of permission denied error.""" """Test handling of permission denied error."""
@ -93,16 +97,19 @@ class TestHostsManagerApp:
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
mock_parser.parse.side_effect = PermissionError("Permission denied") mock_parser.parse.side_effect = PermissionError("Permission denied")
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
app.update_status = Mock() app.update_status = Mock()
app.load_hosts_file() app.load_hosts_file()
# Should handle error gracefully # Should handle error gracefully
app.update_status.assert_called_with("❌ Error loading hosts file: Permission denied") app.update_status.assert_called_with(
"❌ Error loading hosts file: Permission denied"
)
def test_populate_entries_table_logic(self): def test_populate_entries_table_logic(self):
"""Test populating DataTable logic without UI dependencies.""" """Test populating DataTable logic without UI dependencies."""
@ -111,9 +118,10 @@ class TestHostsManagerApp:
mock_config.should_show_default_entries.return_value = True mock_config.should_show_default_entries.return_value = True
mock_config.is_default_entry.return_value = False mock_config.is_default_entry.return_value = False
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
# Mock the query_one method to return a mock table # Mock the query_one method to return a mock table
@ -124,9 +132,7 @@ class TestHostsManagerApp:
app.hosts_file = HostsFile() app.hosts_file = HostsFile()
active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
inactive_entry = HostEntry( inactive_entry = HostEntry(
ip_address="192.168.1.1", ip_address="192.168.1.1", hostnames=["router"], is_active=False
hostnames=["router"],
is_active=False
) )
app.hosts_file.add_entry(active_entry) app.hosts_file.add_entry(active_entry)
app.hosts_file.add_entry(inactive_entry) app.hosts_file.add_entry(inactive_entry)
@ -144,9 +150,10 @@ class TestHostsManagerApp:
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
mock_config.should_show_default_entries.return_value = True mock_config.should_show_default_entries.return_value = True
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
# Mock the query_one method to return DataTable mock # Mock the query_one method to return DataTable mock
@ -168,7 +175,7 @@ class TestHostsManagerApp:
test_entry = HostEntry( test_entry = HostEntry(
ip_address="127.0.0.1", ip_address="127.0.0.1",
hostnames=["localhost", "local"], hostnames=["localhost", "local"],
comment="Test comment" comment="Test comment",
) )
app.hosts_file.add_entry(test_entry) app.hosts_file.add_entry(test_entry)
app.selected_entry_index = 0 app.selected_entry_index = 0
@ -187,9 +194,10 @@ class TestHostsManagerApp:
mock_parser = Mock(spec=HostsParser) mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
# Mock the query_one method to return DataTable mock # Mock the query_one method to return DataTable mock
@ -221,24 +229,27 @@ class TestHostsManagerApp:
mock_parser = Mock(spec=HostsParser) mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
mock_parser.get_file_info.return_value = { mock_parser.get_file_info.return_value = {
'path': '/etc/hosts', "path": "/etc/hosts",
'exists': True, "exists": True,
'size': 100 "size": 100,
} }
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
# Add test entries # Add test entries
app.hosts_file = HostsFile() app.hosts_file = HostsFile()
app.hosts_file.add_entry(HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])) app.hosts_file.add_entry(
app.hosts_file.add_entry(HostEntry( HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
ip_address="192.168.1.1", )
hostnames=["router"], app.hosts_file.add_entry(
is_active=False HostEntry(
)) ip_address="192.168.1.1", hostnames=["router"], is_active=False
)
)
app.update_status() app.update_status()
@ -252,9 +263,10 @@ class TestHostsManagerApp:
mock_parser = Mock(spec=HostsParser) mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
# Mock set_timer and query_one to avoid event loop and UI issues # Mock set_timer and query_one to avoid event loop and UI issues
@ -264,8 +276,14 @@ class TestHostsManagerApp:
# Add test hosts_file for subtitle generation # Add test hosts_file for subtitle generation
app.hosts_file = HostsFile() app.hosts_file = HostsFile()
app.hosts_file.add_entry(HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])) app.hosts_file.add_entry(
app.hosts_file.add_entry(HostEntry(ip_address="192.168.1.1", hostnames=["router"], is_active=False)) HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
)
app.hosts_file.add_entry(
HostEntry(
ip_address="192.168.1.1", hostnames=["router"], is_active=False
)
)
app.update_status("Custom status message") app.update_status("Custom status message")
@ -283,9 +301,10 @@ class TestHostsManagerApp:
mock_parser = Mock(spec=HostsParser) mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
app.load_hosts_file = Mock() app.load_hosts_file = Mock()
app.update_status = Mock() app.update_status = Mock()
@ -300,9 +319,10 @@ class TestHostsManagerApp:
mock_parser = Mock(spec=HostsParser) mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
app.update_status = Mock() app.update_status = Mock()
@ -318,9 +338,10 @@ class TestHostsManagerApp:
mock_parser = Mock(spec=HostsParser) mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
app.push_screen = Mock() app.push_screen = Mock()
@ -336,16 +357,23 @@ class TestHostsManagerApp:
mock_parser = Mock(spec=HostsParser) mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
# Add test entries in reverse order # Add test entries in reverse order
app.hosts_file = HostsFile() app.hosts_file = HostsFile()
app.hosts_file.add_entry(HostEntry(ip_address="192.168.1.1", hostnames=["router"])) app.hosts_file.add_entry(
app.hosts_file.add_entry(HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])) HostEntry(ip_address="192.168.1.1", hostnames=["router"])
app.hosts_file.add_entry(HostEntry(ip_address="10.0.0.1", hostnames=["test"])) )
app.hosts_file.add_entry(
HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
)
app.hosts_file.add_entry(
HostEntry(ip_address="10.0.0.1", hostnames=["test"])
)
# Mock the table_handler methods to avoid UI queries # Mock the table_handler methods to avoid UI queries
app.table_handler.populate_entries_table = Mock() app.table_handler.populate_entries_table = Mock()
@ -368,16 +396,23 @@ class TestHostsManagerApp:
mock_parser = Mock(spec=HostsParser) mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
# Add test entries in reverse alphabetical order # Add test entries in reverse alphabetical order
app.hosts_file = HostsFile() app.hosts_file = HostsFile()
app.hosts_file.add_entry(HostEntry(ip_address="127.0.0.1", hostnames=["zebra"])) app.hosts_file.add_entry(
app.hosts_file.add_entry(HostEntry(ip_address="192.168.1.1", hostnames=["alpha"])) HostEntry(ip_address="127.0.0.1", hostnames=["zebra"])
app.hosts_file.add_entry(HostEntry(ip_address="10.0.0.1", hostnames=["beta"])) )
app.hosts_file.add_entry(
HostEntry(ip_address="192.168.1.1", hostnames=["alpha"])
)
app.hosts_file.add_entry(
HostEntry(ip_address="10.0.0.1", hostnames=["beta"])
)
# Mock the table_handler methods to avoid UI queries # Mock the table_handler methods to avoid UI queries
app.table_handler.populate_entries_table = Mock() app.table_handler.populate_entries_table = Mock()
@ -400,9 +435,10 @@ class TestHostsManagerApp:
mock_parser = Mock(spec=HostsParser) mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
# Mock the details_handler and table_handler methods # Mock the details_handler and table_handler methods
@ -428,9 +464,10 @@ class TestHostsManagerApp:
mock_parser = Mock(spec=HostsParser) mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
app.action_sort_by_ip = Mock() app.action_sort_by_ip = Mock()
@ -450,9 +487,10 @@ class TestHostsManagerApp:
mock_parser = Mock(spec=HostsParser) mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ with (
patch('hosts.tui.app.Config', return_value=mock_config): patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp() app = HostsManagerApp()
# Mock the query_one method to avoid UI dependencies # Mock the query_one method to avoid UI dependencies
@ -471,7 +509,10 @@ class TestHostsManagerApp:
# Find the index of entry2 # Find the index of entry2
target_index = None target_index = None
for i, entry in enumerate(app.hosts_file.entries): for i, entry in enumerate(app.hosts_file.entries):
if entry.ip_address == entry2.ip_address and entry.hostnames == entry2.hostnames: if (
entry.ip_address == entry2.ip_address
and entry.hostnames == entry2.hostnames
):
target_index = i target_index = i
break break
@ -480,7 +521,7 @@ class TestHostsManagerApp:
def test_app_bindings_defined(self): def test_app_bindings_defined(self):
"""Test that application has expected key bindings.""" """Test that application has expected key bindings."""
with patch('hosts.tui.app.HostsParser'), patch('hosts.tui.app.Config'): with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp() app = HostsManagerApp()
# Check that bindings are defined # Check that bindings are defined
@ -489,7 +530,7 @@ class TestHostsManagerApp:
# Check specific bindings exist (handle both Binding objects and tuples) # Check specific bindings exist (handle both Binding objects and tuples)
binding_keys = [] binding_keys = []
for binding in app.BINDINGS: for binding in app.BINDINGS:
if hasattr(binding, 'key'): if hasattr(binding, "key"):
# Binding object # Binding object
binding_keys.append(binding.key) binding_keys.append(binding.key)
elif isinstance(binding, tuple) and len(binding) >= 1: elif isinstance(binding, tuple) and len(binding) >= 1:
@ -506,11 +547,12 @@ class TestHostsManagerApp:
def test_main_function(self): def test_main_function(self):
"""Test main entry point function.""" """Test main entry point function."""
with patch('hosts.main.HostsManagerApp') as mock_app_class: with patch("hosts.main.HostsManagerApp") as mock_app_class:
mock_app = Mock() mock_app = Mock()
mock_app_class.return_value = mock_app mock_app_class.return_value = mock_app
from hosts.main import main from hosts.main import main
main() main()
# Should create and run app # Should create and run app

View file

@ -23,7 +23,7 @@ class TestPermissionManager:
assert not pm.has_sudo assert not pm.has_sudo
assert not pm._sudo_validated assert not pm._sudo_validated
@patch('subprocess.run') @patch("subprocess.run")
def test_request_sudo_already_available(self, mock_run): def test_request_sudo_already_available(self, mock_run):
"""Test requesting sudo when already available.""" """Test requesting sudo when already available."""
# Mock successful sudo -n true # Mock successful sudo -n true
@ -38,19 +38,16 @@ class TestPermissionManager:
assert pm._sudo_validated assert pm._sudo_validated
mock_run.assert_called_once_with( mock_run.assert_called_once_with(
['sudo', '-n', 'true'], ["sudo", "-n", "true"], capture_output=True, text=True, timeout=5
capture_output=True,
text=True,
timeout=5
) )
@patch('subprocess.run') @patch("subprocess.run")
def test_request_sudo_prompt_success(self, mock_run): def test_request_sudo_prompt_success(self, mock_run):
"""Test requesting sudo with password prompt success.""" """Test requesting sudo with password prompt success."""
# First call (sudo -n true) fails, second call (sudo -v) succeeds # First call (sudo -n true) fails, second call (sudo -v) succeeds
mock_run.side_effect = [ mock_run.side_effect = [
Mock(returncode=1), # sudo -n true fails Mock(returncode=1), # sudo -n true fails
Mock(returncode=0) # sudo -v succeeds Mock(returncode=0), # sudo -v succeeds
] ]
pm = PermissionManager() pm = PermissionManager()
@ -63,13 +60,13 @@ class TestPermissionManager:
assert mock_run.call_count == 2 assert mock_run.call_count == 2
@patch('subprocess.run') @patch("subprocess.run")
def test_request_sudo_denied(self, mock_run): def test_request_sudo_denied(self, mock_run):
"""Test requesting sudo when access is denied.""" """Test requesting sudo when access is denied."""
# Both calls fail # Both calls fail
mock_run.side_effect = [ mock_run.side_effect = [
Mock(returncode=1), # sudo -n true fails Mock(returncode=1), # sudo -n true fails
Mock(returncode=1) # sudo -v fails Mock(returncode=1), # sudo -v fails
] ]
pm = PermissionManager() pm = PermissionManager()
@ -80,10 +77,10 @@ class TestPermissionManager:
assert not pm.has_sudo assert not pm.has_sudo
assert not pm._sudo_validated assert not pm._sudo_validated
@patch('subprocess.run') @patch("subprocess.run")
def test_request_sudo_timeout(self, mock_run): def test_request_sudo_timeout(self, mock_run):
"""Test requesting sudo with timeout.""" """Test requesting sudo with timeout."""
mock_run.side_effect = subprocess.TimeoutExpired(['sudo', '-n', 'true'], 5) mock_run.side_effect = subprocess.TimeoutExpired(["sudo", "-n", "true"], 5)
pm = PermissionManager() pm = PermissionManager()
success, message = pm.request_sudo() success, message = pm.request_sudo()
@ -92,7 +89,7 @@ class TestPermissionManager:
assert "timed out" in message assert "timed out" in message
assert not pm.has_sudo assert not pm.has_sudo
@patch('subprocess.run') @patch("subprocess.run")
def test_request_sudo_exception(self, mock_run): def test_request_sudo_exception(self, mock_run):
"""Test requesting sudo with exception.""" """Test requesting sudo with exception."""
mock_run.side_effect = Exception("Test error") mock_run.side_effect = Exception("Test error")
@ -104,7 +101,7 @@ class TestPermissionManager:
assert "Test error" in message assert "Test error" in message
assert not pm.has_sudo assert not pm.has_sudo
@patch('subprocess.run') @patch("subprocess.run")
def test_validate_permissions_success(self, mock_run): def test_validate_permissions_success(self, mock_run):
"""Test validating permissions successfully.""" """Test validating permissions successfully."""
mock_run.return_value = Mock(returncode=0) mock_run.return_value = Mock(returncode=0)
@ -116,12 +113,10 @@ class TestPermissionManager:
assert result assert result
mock_run.assert_called_once_with( mock_run.assert_called_once_with(
['sudo', '-n', 'test', '-w', '/etc/hosts'], ["sudo", "-n", "test", "-w", "/etc/hosts"], capture_output=True, timeout=5
capture_output=True,
timeout=5
) )
@patch('subprocess.run') @patch("subprocess.run")
def test_validate_permissions_no_sudo(self, mock_run): def test_validate_permissions_no_sudo(self, mock_run):
"""Test validating permissions without sudo.""" """Test validating permissions without sudo."""
pm = PermissionManager() pm = PermissionManager()
@ -132,7 +127,7 @@ class TestPermissionManager:
assert not result assert not result
mock_run.assert_not_called() mock_run.assert_not_called()
@patch('subprocess.run') @patch("subprocess.run")
def test_validate_permissions_failure(self, mock_run): def test_validate_permissions_failure(self, mock_run):
"""Test validating permissions failure.""" """Test validating permissions failure."""
mock_run.return_value = Mock(returncode=1) mock_run.return_value = Mock(returncode=1)
@ -144,7 +139,7 @@ class TestPermissionManager:
assert not result assert not result
@patch('subprocess.run') @patch("subprocess.run")
def test_validate_permissions_exception(self, mock_run): def test_validate_permissions_exception(self, mock_run):
"""Test validating permissions with exception.""" """Test validating permissions with exception."""
mock_run.side_effect = Exception("Test error") mock_run.side_effect = Exception("Test error")
@ -156,7 +151,7 @@ class TestPermissionManager:
assert not result assert not result
@patch('subprocess.run') @patch("subprocess.run")
def test_release_sudo(self, mock_run): def test_release_sudo(self, mock_run):
"""Test releasing sudo permissions.""" """Test releasing sudo permissions."""
pm = PermissionManager() pm = PermissionManager()
@ -167,9 +162,9 @@ class TestPermissionManager:
assert not pm.has_sudo assert not pm.has_sudo
assert not pm._sudo_validated assert not pm._sudo_validated
mock_run.assert_called_once_with(['sudo', '-k'], capture_output=True, timeout=5) mock_run.assert_called_once_with(["sudo", "-k"], capture_output=True, timeout=5)
@patch('subprocess.run') @patch("subprocess.run")
def test_release_sudo_exception(self, mock_run): def test_release_sudo_exception(self, mock_run):
"""Test releasing sudo with exception.""" """Test releasing sudo with exception."""
mock_run.side_effect = Exception("Test error") mock_run.side_effect = Exception("Test error")
@ -196,14 +191,16 @@ class TestHostsManager:
assert manager._backup_path is None assert manager._backup_path is None
assert manager.parser.file_path == Path(temp_file.name) assert manager.parser.file_path == Path(temp_file.name)
@patch('src.hosts.core.manager.HostsManager._create_backup') @patch("src.hosts.core.manager.HostsManager._create_backup")
def test_enter_edit_mode_success(self, mock_backup): def test_enter_edit_mode_success(self, mock_backup):
"""Test entering edit mode successfully.""" """Test entering edit mode successfully."""
with tempfile.NamedTemporaryFile() as temp_file: with tempfile.NamedTemporaryFile() as temp_file:
manager = HostsManager(temp_file.name) manager = HostsManager(temp_file.name)
# Mock permission manager # Mock permission manager
manager.permission_manager.request_sudo = Mock(return_value=(True, "Success")) manager.permission_manager.request_sudo = Mock(
return_value=(True, "Success")
)
manager.permission_manager.validate_permissions = Mock(return_value=True) manager.permission_manager.validate_permissions = Mock(return_value=True)
success, message = manager.enter_edit_mode() success, message = manager.enter_edit_mode()
@ -230,7 +227,9 @@ class TestHostsManager:
manager = HostsManager(temp_file.name) manager = HostsManager(temp_file.name)
# Mock permission manager failure # Mock permission manager failure
manager.permission_manager.request_sudo = Mock(return_value=(False, "Denied")) manager.permission_manager.request_sudo = Mock(
return_value=(False, "Denied")
)
success, message = manager.enter_edit_mode() success, message = manager.enter_edit_mode()
@ -244,7 +243,9 @@ class TestHostsManager:
manager = HostsManager(temp_file.name) manager = HostsManager(temp_file.name)
# Mock permission manager # Mock permission manager
manager.permission_manager.request_sudo = Mock(return_value=(True, "Success")) manager.permission_manager.request_sudo = Mock(
return_value=(True, "Success")
)
manager.permission_manager.validate_permissions = Mock(return_value=False) manager.permission_manager.validate_permissions = Mock(return_value=False)
success, message = manager.enter_edit_mode() success, message = manager.enter_edit_mode()
@ -253,7 +254,7 @@ class TestHostsManager:
assert "Cannot write to hosts file" in message assert "Cannot write to hosts file" in message
assert not manager.edit_mode assert not manager.edit_mode
@patch('src.hosts.core.manager.HostsManager._create_backup') @patch("src.hosts.core.manager.HostsManager._create_backup")
def test_enter_edit_mode_backup_failure(self, mock_backup): def test_enter_edit_mode_backup_failure(self, mock_backup):
"""Test entering edit mode with backup failure.""" """Test entering edit mode with backup failure."""
mock_backup.side_effect = Exception("Backup failed") mock_backup.side_effect = Exception("Backup failed")
@ -262,7 +263,9 @@ class TestHostsManager:
manager = HostsManager(temp_file.name) manager = HostsManager(temp_file.name)
# Mock permission manager # Mock permission manager
manager.permission_manager.request_sudo = Mock(return_value=(True, "Success")) manager.permission_manager.request_sudo = Mock(
return_value=(True, "Success")
)
manager.permission_manager.validate_permissions = Mock(return_value=True) manager.permission_manager.validate_permissions = Mock(return_value=True)
success, message = manager.enter_edit_mode() success, message = manager.enter_edit_mode()
@ -307,7 +310,9 @@ class TestHostsManager:
manager.edit_mode = True manager.edit_mode = True
# Mock permission manager to raise exception # Mock permission manager to raise exception
manager.permission_manager.release_sudo = Mock(side_effect=Exception("Test error")) manager.permission_manager.release_sudo = Mock(
side_effect=Exception("Test error")
)
success, message = manager.exit_edit_mode() success, message = manager.exit_edit_mode()
@ -321,7 +326,9 @@ class TestHostsManager:
manager.edit_mode = True manager.edit_mode = True
hosts_file = HostsFile() hosts_file = HostsFile()
entry = HostEntry("192.168.1.1", ["router"], is_active=True) # Non-default entry entry = HostEntry(
"192.168.1.1", ["router"], is_active=True
) # Non-default entry
hosts_file.entries.append(entry) hosts_file.entries.append(entry)
success, message = manager.toggle_entry(hosts_file, 0) success, message = manager.toggle_entry(hosts_file, 0)
@ -449,7 +456,9 @@ class TestHostsManager:
manager.edit_mode = True manager.edit_mode = True
hosts_file = HostsFile() hosts_file = HostsFile()
entry = HostEntry("127.0.0.1", ["localhost"]) # Default entry - cannot be modified entry = HostEntry(
"127.0.0.1", ["localhost"]
) # Default entry - cannot be modified
hosts_file.entries.append(entry) hosts_file.entries.append(entry)
success, message = manager.update_entry( success, message = manager.update_entry(
@ -459,9 +468,9 @@ class TestHostsManager:
assert not success assert not success
assert "Cannot modify default system entries" in message assert "Cannot modify default system entries" in message
@patch('tempfile.NamedTemporaryFile') @patch("tempfile.NamedTemporaryFile")
@patch('subprocess.run') @patch("subprocess.run")
@patch('os.unlink') @patch("os.unlink")
def test_save_hosts_file_success(self, mock_unlink, mock_run, mock_temp): def test_save_hosts_file_success(self, mock_unlink, mock_run, mock_temp):
"""Test saving hosts file successfully.""" """Test saving hosts file successfully."""
# Mock temporary file # Mock temporary file
@ -517,7 +526,7 @@ class TestHostsManager:
assert not success assert not success
assert "No sudo permissions" in message assert "No sudo permissions" in message
@patch('subprocess.run') @patch("subprocess.run")
def test_restore_backup_success(self, mock_run): def test_restore_backup_success(self, mock_run):
"""Test restoring backup successfully.""" """Test restoring backup successfully."""
mock_run.return_value = Mock(returncode=0) mock_run.return_value = Mock(returncode=0)
@ -563,16 +572,16 @@ class TestHostsManager:
assert not success assert not success
assert "No backup available" in message assert "No backup available" in message
@patch('subprocess.run') @patch("subprocess.run")
@patch('tempfile.gettempdir') @patch("tempfile.gettempdir")
@patch('time.time') @patch("time.time")
def test_create_backup_success(self, mock_time, mock_tempdir, mock_run): def test_create_backup_success(self, mock_time, mock_tempdir, mock_run):
"""Test creating backup successfully.""" """Test creating backup successfully."""
mock_time.return_value = 1234567890 mock_time.return_value = 1234567890
mock_tempdir.return_value = "/tmp" mock_tempdir.return_value = "/tmp"
mock_run.side_effect = [ mock_run.side_effect = [
Mock(returncode=0), # cp command Mock(returncode=0), # cp command
Mock(returncode=0) # chmod command Mock(returncode=0), # chmod command
] ]
# Create a real temporary file for testing # Create a real temporary file for testing
@ -591,7 +600,7 @@ class TestHostsManager:
# Clean up # Clean up
Path(temp_path).unlink() Path(temp_path).unlink()
@patch('subprocess.run') @patch("subprocess.run")
def test_create_backup_failure(self, mock_run): def test_create_backup_failure(self, mock_run):
"""Test creating backup with failure.""" """Test creating backup with failure."""
mock_run.return_value = Mock(returncode=1, stderr="Permission denied") mock_run.return_value = Mock(returncode=1, stderr="Permission denied")

View file

@ -26,16 +26,14 @@ class TestHostEntry:
entry = HostEntry( entry = HostEntry(
ip_address="192.168.1.1", ip_address="192.168.1.1",
hostnames=["router", "gateway"], hostnames=["router", "gateway"],
comment="Local router" comment="Local router",
) )
assert entry.comment == "Local router" assert entry.comment == "Local router"
def test_host_entry_inactive(self): def test_host_entry_inactive(self):
"""Test inactive host entry creation.""" """Test inactive host entry creation."""
entry = HostEntry( entry = HostEntry(
ip_address="10.0.0.1", ip_address="10.0.0.1", hostnames=["test.local"], is_active=False
hostnames=["test.local"],
is_active=False
) )
assert entry.is_active is False assert entry.is_active is False
@ -62,9 +60,7 @@ class TestHostEntry:
def test_to_hosts_line_active(self): def test_to_hosts_line_active(self):
"""Test conversion to hosts file line format for active entry.""" """Test conversion to hosts file line format for active entry."""
entry = HostEntry( entry = HostEntry(
ip_address="127.0.0.1", ip_address="127.0.0.1", hostnames=["localhost", "local"], comment="Loopback"
hostnames=["localhost", "local"],
comment="Loopback"
) )
line = entry.to_hosts_line() line = entry.to_hosts_line()
assert line == "127.0.0.1\tlocalhost\tlocal\t# Loopback" assert line == "127.0.0.1\tlocalhost\tlocal\t# Loopback"
@ -72,9 +68,7 @@ class TestHostEntry:
def test_to_hosts_line_inactive(self): def test_to_hosts_line_inactive(self):
"""Test conversion to hosts file line format for inactive entry.""" """Test conversion to hosts file line format for inactive entry."""
entry = HostEntry( entry = HostEntry(
ip_address="192.168.1.1", ip_address="192.168.1.1", hostnames=["router"], is_active=False
hostnames=["router"],
is_active=False
) )
line = entry.to_hosts_line() line = entry.to_hosts_line()
assert line == "# 192.168.1.1\trouter" assert line == "# 192.168.1.1\trouter"
@ -194,9 +188,7 @@ class TestHostsFile:
hosts_file = HostsFile() hosts_file = HostsFile()
active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
inactive_entry = HostEntry( inactive_entry = HostEntry(
ip_address="192.168.1.1", ip_address="192.168.1.1", hostnames=["router"], is_active=False
hostnames=["router"],
is_active=False
) )
hosts_file.add_entry(active_entry) hosts_file.add_entry(active_entry)
@ -211,9 +203,7 @@ class TestHostsFile:
hosts_file = HostsFile() hosts_file = HostsFile()
active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
inactive_entry = HostEntry( inactive_entry = HostEntry(
ip_address="192.168.1.1", ip_address="192.168.1.1", hostnames=["router"], is_active=False
hostnames=["router"],
is_active=False
) )
hosts_file.add_entry(active_entry) hosts_file.add_entry(active_entry)
@ -227,7 +217,9 @@ class TestHostsFile:
"""Test sorting entries by IP address with default entries on top.""" """Test sorting entries by IP address with default entries on top."""
hosts_file = HostsFile() hosts_file = HostsFile()
entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["router"]) entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
entry2 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) # Default entry entry2 = HostEntry(
ip_address="127.0.0.1", hostnames=["localhost"]
) # Default entry
entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["test"]) entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["test"])
hosts_file.add_entry(entry1) hosts_file.add_entry(entry1)
@ -238,7 +230,9 @@ class TestHostsFile:
# Default entries should come first, then sorted non-default entries # Default entries should come first, then sorted non-default entries
assert hosts_file.entries[0].ip_address == "127.0.0.1" # Default entry first assert hosts_file.entries[0].ip_address == "127.0.0.1" # Default entry first
assert hosts_file.entries[1].ip_address == "10.0.0.1" # Then sorted non-defaults assert (
hosts_file.entries[1].ip_address == "10.0.0.1"
) # Then sorted non-defaults
assert hosts_file.entries[2].ip_address == "192.168.1.1" assert hosts_file.entries[2].ip_address == "192.168.1.1"
def test_sort_by_hostname(self): def test_sort_by_hostname(self):

View file

@ -33,7 +33,7 @@ class TestHostsParser:
192.168.1.1 router 192.168.1.1 router
""" """
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write(content) f.write(content)
f.flush() f.flush()
@ -70,7 +70,7 @@ class TestHostsParser:
# Footer comment # Footer comment
""" """
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write(content) f.write(content)
f.flush() f.flush()
@ -114,7 +114,7 @@ class TestHostsParser:
def test_parse_empty_file(self): def test_parse_empty_file(self):
"""Test parsing an empty hosts file.""" """Test parsing an empty hosts file."""
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write("") f.write("")
f.flush() f.flush()
@ -134,7 +134,7 @@ class TestHostsParser:
# Yet another comment # Yet another comment
""" """
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write(content) f.write(content)
f.flush() f.flush()
@ -185,15 +185,9 @@ class TestHostsParser:
hosts_file.footer_comments = ["Footer comment"] hosts_file.footer_comments = ["Footer comment"]
entry1 = HostEntry( entry1 = HostEntry(
ip_address="127.0.0.1", ip_address="127.0.0.1", hostnames=["localhost"], comment="Loopback"
hostnames=["localhost"],
comment="Loopback"
)
entry2 = HostEntry(
ip_address="10.0.0.1",
hostnames=["test"],
is_active=False
) )
entry2 = HostEntry(ip_address="10.0.0.1", hostnames=["test"], is_active=False)
hosts_file.add_entry(entry1) hosts_file.add_entry(entry1)
hosts_file.add_entry(entry2) hosts_file.add_entry(entry2)
@ -236,7 +230,7 @@ class TestHostsParser:
parser.write(hosts_file, backup=False) parser.write(hosts_file, backup=False)
# Read back and verify # Read back and verify
with open(f.name, 'r') as read_file: with open(f.name, "r") as read_file:
content = read_file.read() content = read_file.read()
expected = """# # expected = """# #
# Host Database # Host Database
@ -254,7 +248,7 @@ class TestHostsParser:
# Create initial file # Create initial file
initial_content = "192.168.1.1 router\n" initial_content = "192.168.1.1 router\n"
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write(initial_content) f.write(initial_content)
f.flush() f.flush()
@ -267,16 +261,16 @@ class TestHostsParser:
parser.write(hosts_file, backup=True) parser.write(hosts_file, backup=True)
# Check that backup was created # Check that backup was created
backup_path = Path(f.name).with_suffix('.bak') backup_path = Path(f.name).with_suffix(".bak")
assert backup_path.exists() assert backup_path.exists()
# Check backup content # Check backup content
with open(backup_path, 'r') as backup_file: with open(backup_path, "r") as backup_file:
backup_content = backup_file.read() backup_content = backup_file.read()
assert backup_content == initial_content assert backup_content == initial_content
# Check new content # Check new content
with open(f.name, 'r') as new_file: with open(f.name, "r") as new_file:
new_content = new_file.read() new_content = new_file.read()
expected = """# # expected = """# #
# Host Database # Host Database
@ -313,19 +307,19 @@ class TestHostsParser:
"""Test getting file information.""" """Test getting file information."""
content = "127.0.0.1 localhost\n" content = "127.0.0.1 localhost\n"
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write(content) f.write(content)
f.flush() f.flush()
parser = HostsParser(f.name) parser = HostsParser(f.name)
info = parser.get_file_info() info = parser.get_file_info()
assert info['path'] == f.name assert info["path"] == f.name
assert info['exists'] is True assert info["exists"] is True
assert info['readable'] is True assert info["readable"] is True
assert info['size'] == len(content) assert info["size"] == len(content)
assert info['modified'] is not None assert info["modified"] is not None
assert isinstance(info['modified'], float) assert isinstance(info["modified"], float)
os.unlink(f.name) os.unlink(f.name)
@ -334,12 +328,12 @@ class TestHostsParser:
parser = HostsParser("/nonexistent/path") parser = HostsParser("/nonexistent/path")
info = parser.get_file_info() info = parser.get_file_info()
assert info['path'] == "/nonexistent/path" assert info["path"] == "/nonexistent/path"
assert info['exists'] is False assert info["exists"] is False
assert info['readable'] is False assert info["readable"] is False
assert info['writable'] is False assert info["writable"] is False
assert info['size'] == 0 assert info["size"] == 0
assert info['modified'] is None assert info["modified"] is None
def test_round_trip_parsing(self): def test_round_trip_parsing(self):
"""Test that parsing and serializing preserves content.""" """Test that parsing and serializing preserves content."""
@ -354,7 +348,7 @@ class TestHostsParser:
# End of file # End of file
""" """
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write(original_content) f.write(original_content)
f.flush() f.flush()
@ -365,7 +359,7 @@ class TestHostsParser:
# Write back and read # Write back and read
parser.write(hosts_file, backup=False) parser.write(hosts_file, backup=False)
with open(f.name, 'r') as read_file: with open(f.name, "r") as read_file:
final_content = read_file.read() final_content = read_file.read()
# The content should be functionally equivalent # The content should be functionally equivalent