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:
parent
43fa8c871a
commit
1fddff91c8
18 changed files with 1364 additions and 1038 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
152
src/hosts/tui/password_modal.py
Normal file
152
src/hosts/tui/password_modal.py
Normal 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("")
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
@ -355,7 +383,7 @@ class TestHostsManagerApp:
|
|||
app.action_sort_by_ip()
|
||||
|
||||
# Check that entries are sorted by IP address
|
||||
assert app.hosts_file.entries[0].ip_address == "10.0.0.1" # Sorted by IP
|
||||
assert app.hosts_file.entries[0].ip_address == "10.0.0.1" # Sorted by IP
|
||||
assert app.hosts_file.entries[1].ip_address == "127.0.0.1"
|
||||
assert app.hosts_file.entries[2].ip_address == "192.168.1.1"
|
||||
|
||||
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue