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.
This commit is contained in:
Philip Henning 2025-08-14 17:32:02 +02:00
parent 43fa8c871a
commit 1fddff91c8
18 changed files with 1364 additions and 1038 deletions

View file

@ -34,14 +34,14 @@ class Config:
"window_settings": {
"last_sort_column": "",
"last_sort_ascending": True,
}
},
}
def load(self) -> None:
"""Load configuration from file."""
try:
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)
# Merge with defaults to ensure all keys exist
self._settings.update(loaded_settings)
@ -55,7 +55,7 @@ class Config:
# Ensure config directory exists
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)
except IOError:
# Silently fail if we can't save config

View file

@ -26,20 +26,20 @@ class PermissionManager:
self.has_sudo = 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.
Args:
password: Optional password for sudo authentication
Returns:
Tuple of (success, message)
"""
try:
# Test sudo access with a simple command
result = subprocess.run(
['sudo', '-n', 'true'],
capture_output=True,
text=True,
timeout=5
["sudo", "-n", "true"], capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
@ -48,12 +48,17 @@ class PermissionManager:
self._sudo_validated = True
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(
['sudo', '-v'],
["sudo", "-S", "-v"],
input=password + "\n",
capture_output=True,
text=True,
timeout=30
timeout=10,
)
if result.returncode == 0:
@ -61,7 +66,14 @@ class PermissionManager:
self._sudo_validated = True
return True, "Sudo access granted"
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:
return False, "Sudo request timed out"
@ -84,9 +96,7 @@ class PermissionManager:
try:
# Test write access with sudo
result = subprocess.run(
['sudo', '-n', 'test', '-w', file_path],
capture_output=True,
timeout=5
["sudo", "-n", "test", "-w", file_path], capture_output=True, timeout=5
)
return result.returncode == 0
except Exception:
@ -95,7 +105,7 @@ class PermissionManager:
def release_sudo(self) -> None:
"""Release sudo permissions."""
try:
subprocess.run(['sudo', '-k'], capture_output=True, timeout=5)
subprocess.run(["sudo", "-k"], capture_output=True, timeout=5)
except Exception:
pass
finally:
@ -117,10 +127,13 @@ class HostsManager:
self.edit_mode = False
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.
Args:
password: Optional password for sudo authentication
Returns:
Tuple of (success, message)
"""
@ -128,9 +141,9 @@ class HostsManager:
return True, "Already in edit mode"
# Request sudo permissions
success, message = self.permission_manager.request_sudo()
success, message = self.permission_manager.request_sudo(password)
if not success:
return False, f"Cannot enter edit mode: {message}"
return False, message
# Validate write permissions
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"
# Swap with previous entry
hosts_file.entries[index], hosts_file.entries[index - 1] = \
hosts_file.entries[index - 1], hosts_file.entries[index]
hosts_file.entries[index], hosts_file.entries[index - 1] = (
hosts_file.entries[index - 1],
hosts_file.entries[index],
)
return True, "Entry moved up"
except Exception as e:
return False, f"Error moving entry: {e}"
@ -252,15 +267,22 @@ class HostsManager:
return False, "Cannot move default system entries"
# Swap with next entry
hosts_file.entries[index], hosts_file.entries[index + 1] = \
hosts_file.entries[index + 1], hosts_file.entries[index]
hosts_file.entries[index], hosts_file.entries[index + 1] = (
hosts_file.entries[index + 1],
hosts_file.entries[index],
)
return True, "Entry moved down"
except Exception as e:
return False, f"Error moving entry: {e}"
def update_entry(self, hosts_file: HostsFile, index: int,
ip_address: str, hostnames: list[str],
comment: Optional[str] = None) -> Tuple[bool, str]:
def update_entry(
self,
hosts_file: HostsFile,
index: int,
ip_address: str,
hostnames: list[str],
comment: Optional[str] = None,
) -> Tuple[bool, str]:
"""
Update an existing entry.
@ -293,7 +315,7 @@ class HostsManager:
hostnames=hostnames,
comment=comment,
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
@ -326,17 +348,19 @@ class HostsManager:
content = self.parser.serialize(hosts_file)
# 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_path = temp_file.name
try:
# Use sudo to copy the temp file to the hosts file
result = subprocess.run(
['sudo', 'cp', temp_path, str(self.parser.file_path)],
["sudo", "cp", temp_path, str(self.parser.file_path)],
capture_output=True,
text=True,
timeout=10
timeout=10,
)
if result.returncode == 0:
@ -369,10 +393,10 @@ class HostsManager:
try:
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,
text=True,
timeout=10
timeout=10,
)
if result.returncode == 0:
@ -393,33 +417,39 @@ class HostsManager:
backup_dir.mkdir(exist_ok=True)
import time
timestamp = int(time.time())
self._backup_path = backup_dir / f"hosts.backup.{timestamp}"
# Copy current hosts file to backup
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,
timeout=10
timeout=10,
)
if result.returncode != 0:
raise Exception(f"Failed to create backup: {result.stderr}")
# 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):
"""Base exception for edit mode errors."""
pass
class PermissionError(EditModeError):
"""Raised when there are permission issues."""
pass
class ValidationError(EditModeError):
"""Raised when validation fails."""
pass

View file

@ -23,6 +23,7 @@ class HostEntry:
is_active: Whether this entry is active (not commented out)
dns_name: Optional DNS name for CNAME-like functionality
"""
ip_address: str
hostnames: List[str]
comment: Optional[str] = None
@ -51,7 +52,10 @@ class HostEntry:
]
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 False
@ -73,7 +77,7 @@ class HostEntry:
raise ValueError("At least one hostname is required")
hostname_pattern = re.compile(
r'^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$'
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:
@ -104,7 +108,9 @@ class HostEntry:
# Build the canonical hostname part
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
line_parts.append(ip_part)
@ -147,7 +153,7 @@ class HostEntry:
return max(1, tabs_needed)
@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.
@ -163,18 +169,19 @@ class HostEntry:
# Check if line is commented out (inactive)
is_active = True
if original_line.startswith('#'):
if original_line.startswith("#"):
is_active = False
line = original_line[1:].strip()
# Handle comment-only lines
if not line or line.startswith('#'):
if not line or line.startswith("#"):
return None
# Split line into parts, handling both spaces and tabs
import re
# 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:
return None
@ -184,9 +191,9 @@ class HostEntry:
# Parse hostnames and comments
for i, part in enumerate(parts[1:], 1):
if part.startswith('#'):
if part.startswith("#"):
# Everything from here is a comment
comment = ' '.join(parts[i:]).lstrip('# ')
comment = " ".join(parts[i:]).lstrip("# ")
break
else:
hostnames.append(part)
@ -199,7 +206,7 @@ class HostEntry:
ip_address=ip_address,
hostnames=hostnames,
comment=comment,
is_active=is_active
is_active=is_active,
)
except ValueError:
# Skip invalid entries
@ -216,6 +223,7 @@ class HostsFile:
header_comments: Comments at the beginning of the file
footer_comments: Comments at the end of the file
"""
entries: List[HostEntry] = field(default_factory=list)
header_comments: List[str] = field(default_factory=list)
footer_comments: List[str] = field(default_factory=list)
@ -252,11 +260,13 @@ class HostsFile:
"""
# Separate default and non-default entries
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):
try:
ip_str = entry.ip_address.lstrip('# ')
ip_str = entry.ip_address.lstrip("# ")
ip_obj = ipaddress.ip_address(ip_str)
# Create a tuple for sorting: (version, ip_int)
return (ip_obj.version, int(ip_obj))
@ -275,8 +285,11 @@ class HostsFile:
# Sort default entries according to their fixed order
def default_sort_key(entry):
for i, default in enumerate(default_order):
if (entry.ip_address == default["ip"] and
entry.hostnames and entry.hostnames[0] == default["hostname"]):
if (
entry.ip_address == default["ip"]
and entry.hostnames
and entry.hostnames[0] == default["hostname"]
):
return i
return 999 # fallback for any unexpected default entries
@ -297,7 +310,9 @@ class HostsFile:
"""
# Separate default and non-default entries
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):
hostname = (entry.hostnames[0] if entry.hostnames else "").lower()
@ -314,8 +329,11 @@ class HostsFile:
# Sort default entries according to their fixed order
def default_sort_key(entry):
for i, default in enumerate(default_order):
if (entry.ip_address == default["ip"] and
entry.hostnames and entry.hostnames[0] == default["hostname"]):
if (
entry.ip_address == default["ip"]
and entry.hostnames
and entry.hostnames[0] == default["hostname"]
):
return i
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}")
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()
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()
entries_started = False
@ -62,7 +64,7 @@ class HostsParser:
entries_started = True
elif stripped_line and not entries_started:
# This is a comment before any entries (header)
if stripped_line.startswith('#'):
if stripped_line.startswith("#"):
comment_text = stripped_line[1:].strip()
hosts_file.header_comments.append(comment_text)
else:
@ -70,7 +72,7 @@ class HostsParser:
hosts_file.header_comments.append(stripped_line)
elif stripped_line and entries_started:
# This is a comment after entries have started
if stripped_line.startswith('#'):
if stripped_line.startswith("#"):
comment_text = stripped_line[1:].strip()
hosts_file.footer_comments.append(comment_text)
else:
@ -140,20 +142,16 @@ class HostsParser:
# If no header exists, create default header
if not header_comments:
return [
"#",
"Host Database",
"",
management_line,
"#"
]
return ["#", "Host Database", "", management_line, "#"]
# Check for enclosing comment patterns
enclosing_pattern = self._detect_enclosing_pattern(header_comments)
if 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:
# No enclosing pattern, append management line
result = header_comments.copy()
@ -192,33 +190,39 @@ class HostsParser:
# Check for ### pattern
if first_line == "###" and last_line == "###":
return {
'type': 'triple_hash',
'start_index': 0,
'end_index': last_pattern_index,
'pattern': '###'
"type": "triple_hash",
"start_index": 0,
"end_index": last_pattern_index,
"pattern": "###",
}
# Check for # # pattern
if first_line == "#" and last_line == "#":
return {
'type': 'single_hash',
'start_index': 0,
'end_index': last_pattern_index,
'pattern': '#'
"type": "single_hash",
"start_index": 0,
"end_index": last_pattern_index,
"pattern": "#",
}
# 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 {
'type': 'repeating_hash',
'start_index': 0,
'end_index': last_pattern_index,
'pattern': first_line
"type": "repeating_hash",
"start_index": 0,
"end_index": last_pattern_index,
"pattern": first_line,
}
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.
@ -233,7 +237,7 @@ class HostsParser:
result = header_comments.copy()
# 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
# Otherwise, insert right before the closing pattern
@ -294,9 +298,10 @@ class HostsParser:
"""
# Create backup if requested
if backup and self.file_path.exists():
backup_path = self.file_path.with_suffix('.bak')
backup_path = self.file_path.with_suffix(".bak")
try:
import shutil
shutil.copy2(self.file_path, backup_path)
except Exception as e:
raise OSError(f"Failed to create backup: {e}")
@ -305,9 +310,9 @@ class HostsParser:
content = self.serialize(hosts_file)
# Write atomically using a temporary file
temp_path = self.file_path.with_suffix('.tmp')
temp_path = self.file_path.with_suffix(".tmp")
try:
with open(temp_path, 'w', encoding='utf-8') as f:
with open(temp_path, "w", encoding="utf-8") as f:
f.write(content)
# Atomic move
@ -344,21 +349,21 @@ class HostsParser:
Dictionary with file information
"""
info = {
'path': str(self.file_path),
'exists': self.file_path.exists(),
'readable': False,
'writable': False,
'size': 0,
'modified': None
"path": str(self.file_path),
"exists": self.file_path.exists(),
"readable": False,
"writable": False,
"size": 0,
"modified": None,
}
if info['exists']:
if info["exists"]:
try:
info['readable'] = os.access(self.file_path, os.R_OK)
info['writable'] = os.access(self.file_path, os.W_OK)
info["readable"] = os.access(self.file_path, os.R_OK)
info["writable"] = os.access(self.file_path, os.W_OK)
stat = self.file_path.stat()
info['size'] = stat.st_size
info['modified'] = stat.st_mtime
info["size"] = stat.st_size
info["modified"] = stat.st_mtime
except Exception:
pass
@ -367,19 +372,23 @@ class HostsParser:
class HostsParserError(Exception):
"""Base exception for hosts parser errors."""
pass
class HostsFileNotFoundError(HostsParserError):
"""Raised when the hosts file is not found."""
pass
class HostsPermissionError(HostsParserError):
"""Raised when there are permission issues with the hosts file."""
pass
class HostsValidationError(HostsParserError):
"""Raised when hosts file content is invalid."""
pass

View file

@ -15,6 +15,7 @@ from ..core.models import HostsFile
from ..core.config import Config
from ..core.manager import HostsManager
from .config_modal import ConfigModal
from .password_modal import PasswordModal
from .styles import HOSTS_MANAGER_CSS
from .keybindings import HOSTS_MANAGER_BINDINGS
from .table_handler import TableHandler
@ -75,7 +76,12 @@ class HostsManagerApp(App):
# Right pane - entry details or edit form
with Vertical(classes="right-pane") as right_pane:
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)
with Vertical(id="entry-edit-form", classes="hidden"):
@ -84,7 +90,9 @@ class HostsManagerApp(App):
yield Label("Hostnames (comma-separated):")
yield Input(placeholder="Enter hostnames", id="hostname-input")
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")
# Status bar for error/temporary messages (overlay, doesn't affect layout)
@ -99,9 +107,8 @@ class HostsManagerApp(App):
try:
# Remember the currently selected entry before reload
previous_entry = None
if (
if self.hosts_file.entries and self.selected_entry_index < len(
self.hosts_file.entries
and self.selected_entry_index < len(self.hosts_file.entries)
):
previous_entry = self.hosts_file.entries[self.selected_entry_index]
@ -128,7 +135,7 @@ class HostsManagerApp(App):
else:
# Auto-clear regular message after 3 seconds
self.set_timer(3.0, lambda: self._clear_status_message())
except:
except Exception:
# Fallback if status bar not found (during initialization)
pass
@ -146,7 +153,7 @@ class HostsManagerApp(App):
status_bar = self.query_one("#status-bar", Static)
status_bar.update("")
status_bar.add_class("hidden")
except:
except Exception:
pass
# Event handlers
@ -154,8 +161,8 @@ class HostsManagerApp(App):
"""Handle row highlighting (cursor movement) in the DataTable."""
if event.data_table.id == "entries-table":
# Convert display index to actual index
self.selected_entry_index = self.table_handler.display_index_to_actual_index(
event.cursor_row
self.selected_entry_index = (
self.table_handler.display_index_to_actual_index(event.cursor_row)
)
self.details_handler.update_entry_details()
@ -163,8 +170,8 @@ class HostsManagerApp(App):
"""Handle row selection in the DataTable."""
if event.data_table.id == "entries-table":
# Convert display index to actual index
self.selected_entry_index = self.table_handler.display_index_to_actual_index(
event.cursor_row
self.selected_entry_index = (
self.table_handler.display_index_to_actual_index(event.cursor_row)
)
self.details_handler.update_entry_details()
@ -213,6 +220,7 @@ class HostsManagerApp(App):
def action_config(self) -> None:
"""Show configuration modal."""
def handle_config_result(config_changed: bool) -> None:
if config_changed:
# Reload the table to apply new filtering
@ -245,15 +253,42 @@ class HostsManagerApp(App):
else:
self.update_status(f"Error exiting edit mode: {message}")
else:
# Enter edit mode
# Enter edit mode - first try without password
success, message = self.manager.enter_edit_mode()
if success:
self.edit_mode = True
self.sub_title = "Edit mode"
self.update_status(message)
elif "Password required" in message:
# Show password modal
self._request_sudo_password()
else:
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:
"""Enter edit mode for the selected entry."""
if not self.edit_mode:

View file

@ -79,12 +79,19 @@ class ConfigModal(ModalScreen):
"Show default system entries (localhost, broadcasthost)",
value=self.config.should_show_default_entries(),
id="show-defaults-checkbox",
classes="config-option"
classes="config-option",
)
with Horizontal(classes="button-row"):
yield Button("Save", variant="primary", id="save-button", classes="config-button")
yield Button("Cancel", variant="default", id="cancel-button", classes="config-button")
yield 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:
"""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.
"""
from textual.widgets import Static, Input, Checkbox, DataTable
from textual.widgets import Input, Checkbox, DataTable
class DetailsHandler:
@ -82,7 +82,9 @@ class DetailsHandler:
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("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)
if entry.dns_name:

View file

@ -35,11 +35,18 @@ class NavigationHandler:
)
if success:
# 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:
self.app.table_handler.populate_entries_table()
# 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.update_status(f"{message} - Changes saved automatically")
else:
@ -64,7 +71,9 @@ class NavigationHandler:
)
if success:
# 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:
# Update the selection index to follow the moved entry
if self.app.selected_entry_index > 0:
@ -101,7 +110,9 @@ class NavigationHandler:
)
if success:
# 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:
# Update the selection index to follow the moved entry
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
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:
# Move cursor to the selected row
table.move_cursor(row=display_index)
@ -180,13 +182,14 @@ class TableHandler:
# Remember the currently selected entry
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]
# Sort the entries
self.app.hosts_file.entries.sort(
key=lambda entry: entry.ip_address,
reverse=not self.app.sort_ascending
key=lambda entry: entry.ip_address, reverse=not self.app.sort_ascending
)
# Refresh the table and restore cursor position
@ -205,13 +208,15 @@ class TableHandler:
# Remember the currently selected entry
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]
# Sort the entries
self.app.hosts_file.entries.sort(
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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