Compare commits
No commits in common. "1fddff91c86e8a664565f135570bae893fe50e7d" and "220818c8d159a3ffc79b472f21bb9fbed2ece12d" have entirely different histories.
1fddff91c8
...
220818c8d1
19 changed files with 1042 additions and 1463 deletions
87
CLAUDE.md
87
CLAUDE.md
|
@ -1,87 +0,0 @@
|
||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
This is a Python TUI (Terminal User Interface) application for managing `/etc/hosts` files. The application provides a two-pane interface built with Textual, allowing users to view, edit, activate/deactivate, and reorder hostname entries in their system's hosts file with proper sudo permission handling.
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
### Package Management (uv)
|
|
||||||
- `uv sync` - Update project environment and install dependencies
|
|
||||||
- `uv add <package>` - Add new dependency to project
|
|
||||||
- `uv run <command>` - Run commands in the project environment
|
|
||||||
|
|
||||||
### Running the Application
|
|
||||||
- `uv run hosts` - Launch the TUI application (main entry point)
|
|
||||||
- `uv run python -m hosts.main` - Alternative way to run the application
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- `uv run pytest` - Run all tests
|
|
||||||
- `uv run pytest tests/test_<module>.py` - Run specific test module
|
|
||||||
- `uv run pytest -k <test_name>` - Run tests matching pattern
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
- `uv run ruff check` - Run linter checks
|
|
||||||
- `uv run ruff check --fix` - Auto-fix linting issues
|
|
||||||
- `uv run ruff format` - Format code
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Core Structure
|
|
||||||
The application follows a modular architecture with clear separation of concerns:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/hosts/
|
|
||||||
├── core/ # Core business logic
|
|
||||||
│ ├── config.py # Configuration management
|
|
||||||
│ ├── manager.py # HostsManager with sudo permission handling
|
|
||||||
│ ├── models.py # Data models (HostEntry, HostsFile)
|
|
||||||
│ └── parser.py # Hosts file parsing/serialization
|
|
||||||
├── tui/ # Textual UI components
|
|
||||||
│ ├── app.py # Main application class (HostsManagerApp)
|
|
||||||
│ └── ... # Various handlers and modals
|
|
||||||
└── main.py # Entry point
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Components
|
|
||||||
|
|
||||||
**HostsManager** (`src/hosts/core/manager.py:106`): Central component that handles all edit operations with proper sudo permission management. Key features:
|
|
||||||
- Permission management through PermissionManager class
|
|
||||||
- Edit mode with backup creation and restoration
|
|
||||||
- Safe file operations with validation
|
|
||||||
- Entry manipulation (toggle, move, update)
|
|
||||||
|
|
||||||
**HostsParser** (`src/hosts/core/parser.py`): Handles reading/writing hosts files and maintains original formatting.
|
|
||||||
|
|
||||||
**HostsManagerApp** (`src/hosts/tui/app.py:26`): Main Textual application providing the two-pane interface with reactive state management.
|
|
||||||
|
|
||||||
### Permission Model
|
|
||||||
The application operates in two modes:
|
|
||||||
- **Read-only mode** (default): Safe browsing of hosts file entries
|
|
||||||
- **Edit mode**: Requires sudo permissions for modifications, includes automatic backup creation
|
|
||||||
|
|
||||||
### Memory Bank Integration
|
|
||||||
This project uses Cline's Memory Bank system (see `.clinerules`) for maintaining project context across sessions. Key files are in `memory-bank/` directory.
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
|
|
||||||
### Testing Approach
|
|
||||||
- Tests are located in `tests/` directory
|
|
||||||
- Uses pytest framework
|
|
||||||
- Test individual modules with `uv run pytest tests/test_<module>.py`
|
|
||||||
|
|
||||||
### Code Style
|
|
||||||
- Uses ruff for both linting and formatting
|
|
||||||
- Configuration is embedded in `pyproject.toml`
|
|
||||||
- Run `uv run ruff check --fix && uv run ruff format` before committing
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
- **Textual**: TUI framework for the interface
|
|
||||||
- **pytest**: Testing framework
|
|
||||||
- **ruff**: Linting and code formatting
|
|
||||||
- Managed via uv with dependencies declared in `pyproject.toml`
|
|
||||||
|
|
||||||
### File Permissions
|
|
||||||
When working on permission-related code, be aware that the application needs to handle sudo operations safely. The PermissionManager class in `src/hosts/core/manager.py:17` manages this complexity.
|
|
|
@ -34,14 +34,14 @@ class Config:
|
||||||
"window_settings": {
|
"window_settings": {
|
||||||
"last_sort_column": "",
|
"last_sort_column": "",
|
||||||
"last_sort_ascending": True,
|
"last_sort_ascending": True,
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
"""Load configuration from file."""
|
"""Load configuration from file."""
|
||||||
try:
|
try:
|
||||||
if self.config_file.exists():
|
if self.config_file.exists():
|
||||||
with open(self.config_file, "r") as f:
|
with open(self.config_file, 'r') as f:
|
||||||
loaded_settings = json.load(f)
|
loaded_settings = json.load(f)
|
||||||
# Merge with defaults to ensure all keys exist
|
# Merge with defaults to ensure all keys exist
|
||||||
self._settings.update(loaded_settings)
|
self._settings.update(loaded_settings)
|
||||||
|
@ -55,7 +55,7 @@ class Config:
|
||||||
# Ensure config directory exists
|
# Ensure config directory exists
|
||||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
with open(self.config_file, "w") as f:
|
with open(self.config_file, 'w') as f:
|
||||||
json.dump(self._settings, f, indent=2)
|
json.dump(self._settings, f, indent=2)
|
||||||
except IOError:
|
except IOError:
|
||||||
# Silently fail if we can't save config
|
# Silently fail if we can't save config
|
||||||
|
|
|
@ -26,20 +26,20 @@ class PermissionManager:
|
||||||
self.has_sudo = False
|
self.has_sudo = False
|
||||||
self._sudo_validated = False
|
self._sudo_validated = False
|
||||||
|
|
||||||
def request_sudo(self, password: str = None) -> Tuple[bool, str]:
|
def request_sudo(self) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Request sudo permissions for hosts file editing.
|
Request sudo permissions for hosts file editing.
|
||||||
|
|
||||||
Args:
|
|
||||||
password: Optional password for sudo authentication
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Test sudo access with a simple command
|
# Test sudo access with a simple command
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["sudo", "-n", "true"], capture_output=True, text=True, timeout=5
|
['sudo', '-n', 'true'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
|
@ -48,17 +48,12 @@ class PermissionManager:
|
||||||
self._sudo_validated = True
|
self._sudo_validated = True
|
||||||
return True, "Sudo access already available"
|
return True, "Sudo access already available"
|
||||||
|
|
||||||
# If no password provided, indicate we need password input
|
# Need to prompt for password
|
||||||
if password is None:
|
|
||||||
return False, "Password required for sudo access"
|
|
||||||
|
|
||||||
# Use password for sudo authentication
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["sudo", "-S", "-v"],
|
['sudo', '-v'],
|
||||||
input=password + "\n",
|
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=30
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
|
@ -66,14 +61,7 @@ class PermissionManager:
|
||||||
self._sudo_validated = True
|
self._sudo_validated = True
|
||||||
return True, "Sudo access granted"
|
return True, "Sudo access granted"
|
||||||
else:
|
else:
|
||||||
# Check if it's a password error
|
return False, "Sudo access denied"
|
||||||
if (
|
|
||||||
"incorrect password" in result.stderr.lower()
|
|
||||||
or "authentication failure" in result.stderr.lower()
|
|
||||||
):
|
|
||||||
return False, "Incorrect password"
|
|
||||||
else:
|
|
||||||
return False, f"Sudo access denied: {result.stderr}"
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return False, "Sudo request timed out"
|
return False, "Sudo request timed out"
|
||||||
|
@ -96,7 +84,9 @@ class PermissionManager:
|
||||||
try:
|
try:
|
||||||
# Test write access with sudo
|
# Test write access with sudo
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["sudo", "-n", "test", "-w", file_path], capture_output=True, timeout=5
|
['sudo', '-n', 'test', '-w', file_path],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5
|
||||||
)
|
)
|
||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
except Exception:
|
except Exception:
|
||||||
|
@ -105,7 +95,7 @@ class PermissionManager:
|
||||||
def release_sudo(self) -> None:
|
def release_sudo(self) -> None:
|
||||||
"""Release sudo permissions."""
|
"""Release sudo permissions."""
|
||||||
try:
|
try:
|
||||||
subprocess.run(["sudo", "-k"], capture_output=True, timeout=5)
|
subprocess.run(['sudo', '-k'], capture_output=True, timeout=5)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
@ -127,13 +117,10 @@ class HostsManager:
|
||||||
self.edit_mode = False
|
self.edit_mode = False
|
||||||
self._backup_path: Optional[Path] = None
|
self._backup_path: Optional[Path] = None
|
||||||
|
|
||||||
def enter_edit_mode(self, password: str = None) -> Tuple[bool, str]:
|
def enter_edit_mode(self) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Enter edit mode with proper permission management.
|
Enter edit mode with proper permission management.
|
||||||
|
|
||||||
Args:
|
|
||||||
password: Optional password for sudo authentication
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
"""
|
"""
|
||||||
|
@ -141,9 +128,9 @@ class HostsManager:
|
||||||
return True, "Already in edit mode"
|
return True, "Already in edit mode"
|
||||||
|
|
||||||
# Request sudo permissions
|
# Request sudo permissions
|
||||||
success, message = self.permission_manager.request_sudo(password)
|
success, message = self.permission_manager.request_sudo()
|
||||||
if not success:
|
if not success:
|
||||||
return False, message
|
return False, f"Cannot enter edit mode: {message}"
|
||||||
|
|
||||||
# Validate write permissions
|
# Validate write permissions
|
||||||
if not self.permission_manager.validate_permissions(str(self.parser.file_path)):
|
if not self.permission_manager.validate_permissions(str(self.parser.file_path)):
|
||||||
|
@ -233,10 +220,8 @@ class HostsManager:
|
||||||
return False, "Cannot move default system entries"
|
return False, "Cannot move default system entries"
|
||||||
|
|
||||||
# Swap with previous entry
|
# Swap with previous entry
|
||||||
hosts_file.entries[index], hosts_file.entries[index - 1] = (
|
hosts_file.entries[index], hosts_file.entries[index - 1] = \
|
||||||
hosts_file.entries[index - 1],
|
hosts_file.entries[index - 1], hosts_file.entries[index]
|
||||||
hosts_file.entries[index],
|
|
||||||
)
|
|
||||||
return True, "Entry moved up"
|
return True, "Entry moved up"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"Error moving entry: {e}"
|
return False, f"Error moving entry: {e}"
|
||||||
|
@ -267,22 +252,15 @@ class HostsManager:
|
||||||
return False, "Cannot move default system entries"
|
return False, "Cannot move default system entries"
|
||||||
|
|
||||||
# Swap with next entry
|
# Swap with next entry
|
||||||
hosts_file.entries[index], hosts_file.entries[index + 1] = (
|
hosts_file.entries[index], hosts_file.entries[index + 1] = \
|
||||||
hosts_file.entries[index + 1],
|
hosts_file.entries[index + 1], hosts_file.entries[index]
|
||||||
hosts_file.entries[index],
|
|
||||||
)
|
|
||||||
return True, "Entry moved down"
|
return True, "Entry moved down"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"Error moving entry: {e}"
|
return False, f"Error moving entry: {e}"
|
||||||
|
|
||||||
def update_entry(
|
def update_entry(self, hosts_file: HostsFile, index: int,
|
||||||
self,
|
ip_address: str, hostnames: list[str],
|
||||||
hosts_file: HostsFile,
|
comment: Optional[str] = None) -> Tuple[bool, str]:
|
||||||
index: int,
|
|
||||||
ip_address: str,
|
|
||||||
hostnames: list[str],
|
|
||||||
comment: Optional[str] = None,
|
|
||||||
) -> Tuple[bool, str]:
|
|
||||||
"""
|
"""
|
||||||
Update an existing entry.
|
Update an existing entry.
|
||||||
|
|
||||||
|
@ -315,7 +293,7 @@ class HostsManager:
|
||||||
hostnames=hostnames,
|
hostnames=hostnames,
|
||||||
comment=comment,
|
comment=comment,
|
||||||
is_active=hosts_file.entries[index].is_active,
|
is_active=hosts_file.entries[index].is_active,
|
||||||
dns_name=hosts_file.entries[index].dns_name,
|
dns_name=hosts_file.entries[index].dns_name
|
||||||
)
|
)
|
||||||
|
|
||||||
# Replace the entry
|
# Replace the entry
|
||||||
|
@ -348,19 +326,17 @@ class HostsManager:
|
||||||
content = self.parser.serialize(hosts_file)
|
content = self.parser.serialize(hosts_file)
|
||||||
|
|
||||||
# Write to temporary file first
|
# Write to temporary file first
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.hosts') as temp_file:
|
||||||
mode="w", delete=False, suffix=".hosts"
|
|
||||||
) as temp_file:
|
|
||||||
temp_file.write(content)
|
temp_file.write(content)
|
||||||
temp_path = temp_file.name
|
temp_path = temp_file.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use sudo to copy the temp file to the hosts file
|
# Use sudo to copy the temp file to the hosts file
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["sudo", "cp", temp_path, str(self.parser.file_path)],
|
['sudo', 'cp', temp_path, str(self.parser.file_path)],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
|
@ -393,10 +369,10 @@ class HostsManager:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["sudo", "cp", str(self._backup_path), str(self.parser.file_path)],
|
['sudo', 'cp', str(self._backup_path), str(self.parser.file_path)],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
|
@ -417,39 +393,33 @@ class HostsManager:
|
||||||
backup_dir.mkdir(exist_ok=True)
|
backup_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
timestamp = int(time.time())
|
timestamp = int(time.time())
|
||||||
self._backup_path = backup_dir / f"hosts.backup.{timestamp}"
|
self._backup_path = backup_dir / f"hosts.backup.{timestamp}"
|
||||||
|
|
||||||
# Copy current hosts file to backup
|
# Copy current hosts file to backup
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["sudo", "cp", str(self.parser.file_path), str(self._backup_path)],
|
['sudo', 'cp', str(self.parser.file_path), str(self._backup_path)],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=10,
|
timeout=10
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise Exception(f"Failed to create backup: {result.stderr}")
|
raise Exception(f"Failed to create backup: {result.stderr}")
|
||||||
|
|
||||||
# Make backup readable by user
|
# Make backup readable by user
|
||||||
subprocess.run(
|
subprocess.run(['sudo', 'chmod', '644', str(self._backup_path)], capture_output=True)
|
||||||
["sudo", "chmod", "644", str(self._backup_path)], capture_output=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EditModeError(Exception):
|
class EditModeError(Exception):
|
||||||
"""Base exception for edit mode errors."""
|
"""Base exception for edit mode errors."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PermissionError(EditModeError):
|
class PermissionError(EditModeError):
|
||||||
"""Raised when there are permission issues."""
|
"""Raised when there are permission issues."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(EditModeError):
|
class ValidationError(EditModeError):
|
||||||
"""Raised when validation fails."""
|
"""Raised when validation fails."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -23,7 +23,6 @@ class HostEntry:
|
||||||
is_active: Whether this entry is active (not commented out)
|
is_active: Whether this entry is active (not commented out)
|
||||||
dns_name: Optional DNS name for CNAME-like functionality
|
dns_name: Optional DNS name for CNAME-like functionality
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ip_address: str
|
ip_address: str
|
||||||
hostnames: List[str]
|
hostnames: List[str]
|
||||||
comment: Optional[str] = None
|
comment: Optional[str] = None
|
||||||
|
@ -52,10 +51,7 @@ class HostEntry:
|
||||||
]
|
]
|
||||||
|
|
||||||
for entry in default_entries:
|
for entry in default_entries:
|
||||||
if (
|
if entry["ip"] == self.ip_address and entry["hostname"] == canonical_hostname:
|
||||||
entry["ip"] == self.ip_address
|
|
||||||
and entry["hostname"] == canonical_hostname
|
|
||||||
):
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -77,7 +73,7 @@ class HostEntry:
|
||||||
raise ValueError("At least one hostname is required")
|
raise ValueError("At least one hostname is required")
|
||||||
|
|
||||||
hostname_pattern = re.compile(
|
hostname_pattern = re.compile(
|
||||||
r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$"
|
r'^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$'
|
||||||
)
|
)
|
||||||
|
|
||||||
for hostname in self.hostnames:
|
for hostname in self.hostnames:
|
||||||
|
@ -108,9 +104,7 @@ class HostEntry:
|
||||||
|
|
||||||
# Build the canonical hostname part
|
# Build the canonical hostname part
|
||||||
canonical_hostname = self.hostnames[0] if self.hostnames else ""
|
canonical_hostname = self.hostnames[0] if self.hostnames else ""
|
||||||
hostname_tabs = self._calculate_tabs_needed(
|
hostname_tabs = self._calculate_tabs_needed(len(canonical_hostname), hostname_width)
|
||||||
len(canonical_hostname), hostname_width
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start building the line
|
# Start building the line
|
||||||
line_parts.append(ip_part)
|
line_parts.append(ip_part)
|
||||||
|
@ -153,7 +147,7 @@ class HostEntry:
|
||||||
return max(1, tabs_needed)
|
return max(1, tabs_needed)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_hosts_line(cls, line: str) -> Optional["HostEntry"]:
|
def from_hosts_line(cls, line: str) -> Optional['HostEntry']:
|
||||||
"""
|
"""
|
||||||
Parse a hosts file line into a HostEntry.
|
Parse a hosts file line into a HostEntry.
|
||||||
|
|
||||||
|
@ -169,19 +163,18 @@ class HostEntry:
|
||||||
|
|
||||||
# Check if line is commented out (inactive)
|
# Check if line is commented out (inactive)
|
||||||
is_active = True
|
is_active = True
|
||||||
if original_line.startswith("#"):
|
if original_line.startswith('#'):
|
||||||
is_active = False
|
is_active = False
|
||||||
line = original_line[1:].strip()
|
line = original_line[1:].strip()
|
||||||
|
|
||||||
# Handle comment-only lines
|
# Handle comment-only lines
|
||||||
if not line or line.startswith("#"):
|
if not line or line.startswith('#'):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Split line into parts, handling both spaces and tabs
|
# Split line into parts, handling both spaces and tabs
|
||||||
import re
|
import re
|
||||||
|
|
||||||
# Split on any whitespace (spaces, tabs, or combinations)
|
# Split on any whitespace (spaces, tabs, or combinations)
|
||||||
parts = re.split(r"\s+", line.strip())
|
parts = re.split(r'\s+', line.strip())
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -191,9 +184,9 @@ class HostEntry:
|
||||||
|
|
||||||
# Parse hostnames and comments
|
# Parse hostnames and comments
|
||||||
for i, part in enumerate(parts[1:], 1):
|
for i, part in enumerate(parts[1:], 1):
|
||||||
if part.startswith("#"):
|
if part.startswith('#'):
|
||||||
# Everything from here is a comment
|
# Everything from here is a comment
|
||||||
comment = " ".join(parts[i:]).lstrip("# ")
|
comment = ' '.join(parts[i:]).lstrip('# ')
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
hostnames.append(part)
|
hostnames.append(part)
|
||||||
|
@ -206,7 +199,7 @@ class HostEntry:
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
hostnames=hostnames,
|
hostnames=hostnames,
|
||||||
comment=comment,
|
comment=comment,
|
||||||
is_active=is_active,
|
is_active=is_active
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Skip invalid entries
|
# Skip invalid entries
|
||||||
|
@ -223,7 +216,6 @@ class HostsFile:
|
||||||
header_comments: Comments at the beginning of the file
|
header_comments: Comments at the beginning of the file
|
||||||
footer_comments: Comments at the end of the file
|
footer_comments: Comments at the end of the file
|
||||||
"""
|
"""
|
||||||
|
|
||||||
entries: List[HostEntry] = field(default_factory=list)
|
entries: List[HostEntry] = field(default_factory=list)
|
||||||
header_comments: List[str] = field(default_factory=list)
|
header_comments: List[str] = field(default_factory=list)
|
||||||
footer_comments: List[str] = field(default_factory=list)
|
footer_comments: List[str] = field(default_factory=list)
|
||||||
|
@ -260,13 +252,11 @@ class HostsFile:
|
||||||
"""
|
"""
|
||||||
# Separate default and non-default entries
|
# Separate default and non-default entries
|
||||||
default_entries = [entry for entry in self.entries if entry.is_default_entry()]
|
default_entries = [entry for entry in self.entries if entry.is_default_entry()]
|
||||||
non_default_entries = [
|
non_default_entries = [entry for entry in self.entries if not entry.is_default_entry()]
|
||||||
entry for entry in self.entries if not entry.is_default_entry()
|
|
||||||
]
|
|
||||||
|
|
||||||
def ip_sort_key(entry):
|
def ip_sort_key(entry):
|
||||||
try:
|
try:
|
||||||
ip_str = entry.ip_address.lstrip("# ")
|
ip_str = entry.ip_address.lstrip('# ')
|
||||||
ip_obj = ipaddress.ip_address(ip_str)
|
ip_obj = ipaddress.ip_address(ip_str)
|
||||||
# Create a tuple for sorting: (version, ip_int)
|
# Create a tuple for sorting: (version, ip_int)
|
||||||
return (ip_obj.version, int(ip_obj))
|
return (ip_obj.version, int(ip_obj))
|
||||||
|
@ -285,11 +275,8 @@ class HostsFile:
|
||||||
# Sort default entries according to their fixed order
|
# Sort default entries according to their fixed order
|
||||||
def default_sort_key(entry):
|
def default_sort_key(entry):
|
||||||
for i, default in enumerate(default_order):
|
for i, default in enumerate(default_order):
|
||||||
if (
|
if (entry.ip_address == default["ip"] and
|
||||||
entry.ip_address == default["ip"]
|
entry.hostnames and entry.hostnames[0] == default["hostname"]):
|
||||||
and entry.hostnames
|
|
||||||
and entry.hostnames[0] == default["hostname"]
|
|
||||||
):
|
|
||||||
return i
|
return i
|
||||||
return 999 # fallback for any unexpected default entries
|
return 999 # fallback for any unexpected default entries
|
||||||
|
|
||||||
|
@ -310,9 +297,7 @@ class HostsFile:
|
||||||
"""
|
"""
|
||||||
# Separate default and non-default entries
|
# Separate default and non-default entries
|
||||||
default_entries = [entry for entry in self.entries if entry.is_default_entry()]
|
default_entries = [entry for entry in self.entries if entry.is_default_entry()]
|
||||||
non_default_entries = [
|
non_default_entries = [entry for entry in self.entries if not entry.is_default_entry()]
|
||||||
entry for entry in self.entries if not entry.is_default_entry()
|
|
||||||
]
|
|
||||||
|
|
||||||
def hostname_sort_key(entry):
|
def hostname_sort_key(entry):
|
||||||
hostname = (entry.hostnames[0] if entry.hostnames else "").lower()
|
hostname = (entry.hostnames[0] if entry.hostnames else "").lower()
|
||||||
|
@ -329,11 +314,8 @@ class HostsFile:
|
||||||
# Sort default entries according to their fixed order
|
# Sort default entries according to their fixed order
|
||||||
def default_sort_key(entry):
|
def default_sort_key(entry):
|
||||||
for i, default in enumerate(default_order):
|
for i, default in enumerate(default_order):
|
||||||
if (
|
if (entry.ip_address == default["ip"] and
|
||||||
entry.ip_address == default["ip"]
|
entry.hostnames and entry.hostnames[0] == default["hostname"]):
|
||||||
and entry.hostnames
|
|
||||||
and entry.hostnames[0] == default["hostname"]
|
|
||||||
):
|
|
||||||
return i
|
return i
|
||||||
return 999 # fallback for any unexpected default entries
|
return 999 # fallback for any unexpected default entries
|
||||||
|
|
||||||
|
|
|
@ -42,12 +42,10 @@ class HostsParser:
|
||||||
raise FileNotFoundError(f"Hosts file not found: {self.file_path}")
|
raise FileNotFoundError(f"Hosts file not found: {self.file_path}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
with open(self.file_path, 'r', encoding='utf-8') as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
raise PermissionError(
|
raise PermissionError(f"Permission denied reading hosts file: {self.file_path}")
|
||||||
f"Permission denied reading hosts file: {self.file_path}"
|
|
||||||
)
|
|
||||||
|
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
entries_started = False
|
entries_started = False
|
||||||
|
@ -64,7 +62,7 @@ class HostsParser:
|
||||||
entries_started = True
|
entries_started = True
|
||||||
elif stripped_line and not entries_started:
|
elif stripped_line and not entries_started:
|
||||||
# This is a comment before any entries (header)
|
# This is a comment before any entries (header)
|
||||||
if stripped_line.startswith("#"):
|
if stripped_line.startswith('#'):
|
||||||
comment_text = stripped_line[1:].strip()
|
comment_text = stripped_line[1:].strip()
|
||||||
hosts_file.header_comments.append(comment_text)
|
hosts_file.header_comments.append(comment_text)
|
||||||
else:
|
else:
|
||||||
|
@ -72,7 +70,7 @@ class HostsParser:
|
||||||
hosts_file.header_comments.append(stripped_line)
|
hosts_file.header_comments.append(stripped_line)
|
||||||
elif stripped_line and entries_started:
|
elif stripped_line and entries_started:
|
||||||
# This is a comment after entries have started
|
# This is a comment after entries have started
|
||||||
if stripped_line.startswith("#"):
|
if stripped_line.startswith('#'):
|
||||||
comment_text = stripped_line[1:].strip()
|
comment_text = stripped_line[1:].strip()
|
||||||
hosts_file.footer_comments.append(comment_text)
|
hosts_file.footer_comments.append(comment_text)
|
||||||
else:
|
else:
|
||||||
|
@ -142,16 +140,20 @@ class HostsParser:
|
||||||
|
|
||||||
# If no header exists, create default header
|
# If no header exists, create default header
|
||||||
if not header_comments:
|
if not header_comments:
|
||||||
return ["#", "Host Database", "", management_line, "#"]
|
return [
|
||||||
|
"#",
|
||||||
|
"Host Database",
|
||||||
|
"",
|
||||||
|
management_line,
|
||||||
|
"#"
|
||||||
|
]
|
||||||
|
|
||||||
# Check for enclosing comment patterns
|
# Check for enclosing comment patterns
|
||||||
enclosing_pattern = self._detect_enclosing_pattern(header_comments)
|
enclosing_pattern = self._detect_enclosing_pattern(header_comments)
|
||||||
|
|
||||||
if enclosing_pattern:
|
if enclosing_pattern:
|
||||||
# Insert management line within the enclosing pattern
|
# Insert management line within the enclosing pattern
|
||||||
return self._insert_in_enclosing_pattern(
|
return self._insert_in_enclosing_pattern(header_comments, management_line, enclosing_pattern)
|
||||||
header_comments, management_line, enclosing_pattern
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# No enclosing pattern, append management line
|
# No enclosing pattern, append management line
|
||||||
result = header_comments.copy()
|
result = header_comments.copy()
|
||||||
|
@ -190,39 +192,33 @@ class HostsParser:
|
||||||
# Check for ### pattern
|
# Check for ### pattern
|
||||||
if first_line == "###" and last_line == "###":
|
if first_line == "###" and last_line == "###":
|
||||||
return {
|
return {
|
||||||
"type": "triple_hash",
|
'type': 'triple_hash',
|
||||||
"start_index": 0,
|
'start_index': 0,
|
||||||
"end_index": last_pattern_index,
|
'end_index': last_pattern_index,
|
||||||
"pattern": "###",
|
'pattern': '###'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check for # # pattern
|
# Check for # # pattern
|
||||||
if first_line == "#" and last_line == "#":
|
if first_line == "#" and last_line == "#":
|
||||||
return {
|
return {
|
||||||
"type": "single_hash",
|
'type': 'single_hash',
|
||||||
"start_index": 0,
|
'start_index': 0,
|
||||||
"end_index": last_pattern_index,
|
'end_index': last_pattern_index,
|
||||||
"pattern": "#",
|
'pattern': '#'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check for other repeating patterns (like ####, #####, etc.)
|
# Check for other repeating patterns (like ####, #####, etc.)
|
||||||
if (
|
if len(first_line) > 1 and first_line == last_line and all(c == '#' for c in first_line):
|
||||||
len(first_line) > 1
|
|
||||||
and first_line == last_line
|
|
||||||
and all(c == "#" for c in first_line)
|
|
||||||
):
|
|
||||||
return {
|
return {
|
||||||
"type": "repeating_hash",
|
'type': 'repeating_hash',
|
||||||
"start_index": 0,
|
'start_index': 0,
|
||||||
"end_index": last_pattern_index,
|
'end_index': last_pattern_index,
|
||||||
"pattern": first_line,
|
'pattern': first_line
|
||||||
}
|
}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _insert_in_enclosing_pattern(
|
def _insert_in_enclosing_pattern(self, header_comments: list, management_line: str, pattern_info: dict) -> list:
|
||||||
self, header_comments: list, management_line: str, pattern_info: dict
|
|
||||||
) -> list:
|
|
||||||
"""
|
"""
|
||||||
Insert management line within an enclosing comment pattern.
|
Insert management line within an enclosing comment pattern.
|
||||||
|
|
||||||
|
@ -237,7 +233,7 @@ class HostsParser:
|
||||||
result = header_comments.copy()
|
result = header_comments.copy()
|
||||||
|
|
||||||
# Find the best insertion point (before the closing pattern)
|
# Find the best insertion point (before the closing pattern)
|
||||||
insert_index = pattern_info["end_index"]
|
insert_index = pattern_info['end_index']
|
||||||
|
|
||||||
# Look for an empty line before the closing pattern to insert after it
|
# Look for an empty line before the closing pattern to insert after it
|
||||||
# Otherwise, insert right before the closing pattern
|
# Otherwise, insert right before the closing pattern
|
||||||
|
@ -298,10 +294,9 @@ class HostsParser:
|
||||||
"""
|
"""
|
||||||
# Create backup if requested
|
# Create backup if requested
|
||||||
if backup and self.file_path.exists():
|
if backup and self.file_path.exists():
|
||||||
backup_path = self.file_path.with_suffix(".bak")
|
backup_path = self.file_path.with_suffix('.bak')
|
||||||
try:
|
try:
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
shutil.copy2(self.file_path, backup_path)
|
shutil.copy2(self.file_path, backup_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise OSError(f"Failed to create backup: {e}")
|
raise OSError(f"Failed to create backup: {e}")
|
||||||
|
@ -310,9 +305,9 @@ class HostsParser:
|
||||||
content = self.serialize(hosts_file)
|
content = self.serialize(hosts_file)
|
||||||
|
|
||||||
# Write atomically using a temporary file
|
# Write atomically using a temporary file
|
||||||
temp_path = self.file_path.with_suffix(".tmp")
|
temp_path = self.file_path.with_suffix('.tmp')
|
||||||
try:
|
try:
|
||||||
with open(temp_path, "w", encoding="utf-8") as f:
|
with open(temp_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
# Atomic move
|
# Atomic move
|
||||||
|
@ -349,21 +344,21 @@ class HostsParser:
|
||||||
Dictionary with file information
|
Dictionary with file information
|
||||||
"""
|
"""
|
||||||
info = {
|
info = {
|
||||||
"path": str(self.file_path),
|
'path': str(self.file_path),
|
||||||
"exists": self.file_path.exists(),
|
'exists': self.file_path.exists(),
|
||||||
"readable": False,
|
'readable': False,
|
||||||
"writable": False,
|
'writable': False,
|
||||||
"size": 0,
|
'size': 0,
|
||||||
"modified": None,
|
'modified': None
|
||||||
}
|
}
|
||||||
|
|
||||||
if info["exists"]:
|
if info['exists']:
|
||||||
try:
|
try:
|
||||||
info["readable"] = os.access(self.file_path, os.R_OK)
|
info['readable'] = os.access(self.file_path, os.R_OK)
|
||||||
info["writable"] = os.access(self.file_path, os.W_OK)
|
info['writable'] = os.access(self.file_path, os.W_OK)
|
||||||
stat = self.file_path.stat()
|
stat = self.file_path.stat()
|
||||||
info["size"] = stat.st_size
|
info['size'] = stat.st_size
|
||||||
info["modified"] = stat.st_mtime
|
info['modified'] = stat.st_mtime
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -372,23 +367,19 @@ class HostsParser:
|
||||||
|
|
||||||
class HostsParserError(Exception):
|
class HostsParserError(Exception):
|
||||||
"""Base exception for hosts parser errors."""
|
"""Base exception for hosts parser errors."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class HostsFileNotFoundError(HostsParserError):
|
class HostsFileNotFoundError(HostsParserError):
|
||||||
"""Raised when the hosts file is not found."""
|
"""Raised when the hosts file is not found."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class HostsPermissionError(HostsParserError):
|
class HostsPermissionError(HostsParserError):
|
||||||
"""Raised when there are permission issues with the hosts file."""
|
"""Raised when there are permission issues with the hosts file."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class HostsValidationError(HostsParserError):
|
class HostsValidationError(HostsParserError):
|
||||||
"""Raised when hosts file content is invalid."""
|
"""Raised when hosts file content is invalid."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -15,7 +15,6 @@ from ..core.models import HostsFile
|
||||||
from ..core.config import Config
|
from ..core.config import Config
|
||||||
from ..core.manager import HostsManager
|
from ..core.manager import HostsManager
|
||||||
from .config_modal import ConfigModal
|
from .config_modal import ConfigModal
|
||||||
from .password_modal import PasswordModal
|
|
||||||
from .styles import HOSTS_MANAGER_CSS
|
from .styles import HOSTS_MANAGER_CSS
|
||||||
from .keybindings import HOSTS_MANAGER_BINDINGS
|
from .keybindings import HOSTS_MANAGER_BINDINGS
|
||||||
from .table_handler import TableHandler
|
from .table_handler import TableHandler
|
||||||
|
@ -69,19 +68,14 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
with Horizontal(classes="hosts-container"):
|
with Horizontal(classes="hosts-container"):
|
||||||
# Left pane - entries table
|
# Left pane - entries table
|
||||||
with Vertical(classes="left-pane") as left_pane:
|
with Vertical(classes="left-pane"):
|
||||||
left_pane.border_title = "Host Entries"
|
yield Static("Host Entries", id="entries-title")
|
||||||
yield DataTable(id="entries-table")
|
yield DataTable(id="entries-table")
|
||||||
|
|
||||||
# Right pane - entry details or edit form
|
# Right pane - entry details or edit form
|
||||||
with Vertical(classes="right-pane") as right_pane:
|
with Vertical(classes="right-pane"):
|
||||||
right_pane.border_title = "Entry Details"
|
yield Static("Entry Details", id="details-title")
|
||||||
yield DataTable(
|
yield DataTable(id="entry-details-table", show_header=False, show_cursor=False, disabled=True)
|
||||||
id="entry-details-table",
|
|
||||||
show_header=False,
|
|
||||||
show_cursor=False,
|
|
||||||
disabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Edit form (initially hidden)
|
# Edit form (initially hidden)
|
||||||
with Vertical(id="entry-edit-form", classes="hidden"):
|
with Vertical(id="entry-edit-form", classes="hidden"):
|
||||||
|
@ -90,9 +84,7 @@ class HostsManagerApp(App):
|
||||||
yield Label("Hostnames (comma-separated):")
|
yield Label("Hostnames (comma-separated):")
|
||||||
yield Input(placeholder="Enter hostnames", id="hostname-input")
|
yield Input(placeholder="Enter hostnames", id="hostname-input")
|
||||||
yield Label("Comment:")
|
yield Label("Comment:")
|
||||||
yield Input(
|
yield Input(placeholder="Enter comment (optional)", id="comment-input")
|
||||||
placeholder="Enter comment (optional)", id="comment-input"
|
|
||||||
)
|
|
||||||
yield Checkbox("Active", id="active-checkbox")
|
yield Checkbox("Active", id="active-checkbox")
|
||||||
|
|
||||||
# Status bar for error/temporary messages (overlay, doesn't affect layout)
|
# Status bar for error/temporary messages (overlay, doesn't affect layout)
|
||||||
|
@ -107,8 +99,9 @@ class HostsManagerApp(App):
|
||||||
try:
|
try:
|
||||||
# Remember the currently selected entry before reload
|
# Remember the currently selected entry before reload
|
||||||
previous_entry = None
|
previous_entry = None
|
||||||
if self.hosts_file.entries and self.selected_entry_index < len(
|
if (
|
||||||
self.hosts_file.entries
|
self.hosts_file.entries
|
||||||
|
and self.selected_entry_index < len(self.hosts_file.entries)
|
||||||
):
|
):
|
||||||
previous_entry = self.hosts_file.entries[self.selected_entry_index]
|
previous_entry = self.hosts_file.entries[self.selected_entry_index]
|
||||||
|
|
||||||
|
@ -135,7 +128,7 @@ class HostsManagerApp(App):
|
||||||
else:
|
else:
|
||||||
# Auto-clear regular message after 3 seconds
|
# Auto-clear regular message after 3 seconds
|
||||||
self.set_timer(3.0, lambda: self._clear_status_message())
|
self.set_timer(3.0, lambda: self._clear_status_message())
|
||||||
except Exception:
|
except:
|
||||||
# Fallback if status bar not found (during initialization)
|
# Fallback if status bar not found (during initialization)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -153,7 +146,7 @@ class HostsManagerApp(App):
|
||||||
status_bar = self.query_one("#status-bar", Static)
|
status_bar = self.query_one("#status-bar", Static)
|
||||||
status_bar.update("")
|
status_bar.update("")
|
||||||
status_bar.add_class("hidden")
|
status_bar.add_class("hidden")
|
||||||
except Exception:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Event handlers
|
# Event handlers
|
||||||
|
@ -161,8 +154,8 @@ class HostsManagerApp(App):
|
||||||
"""Handle row highlighting (cursor movement) in the DataTable."""
|
"""Handle row highlighting (cursor movement) in the DataTable."""
|
||||||
if event.data_table.id == "entries-table":
|
if event.data_table.id == "entries-table":
|
||||||
# Convert display index to actual index
|
# Convert display index to actual index
|
||||||
self.selected_entry_index = (
|
self.selected_entry_index = self.table_handler.display_index_to_actual_index(
|
||||||
self.table_handler.display_index_to_actual_index(event.cursor_row)
|
event.cursor_row
|
||||||
)
|
)
|
||||||
self.details_handler.update_entry_details()
|
self.details_handler.update_entry_details()
|
||||||
|
|
||||||
|
@ -170,8 +163,8 @@ class HostsManagerApp(App):
|
||||||
"""Handle row selection in the DataTable."""
|
"""Handle row selection in the DataTable."""
|
||||||
if event.data_table.id == "entries-table":
|
if event.data_table.id == "entries-table":
|
||||||
# Convert display index to actual index
|
# Convert display index to actual index
|
||||||
self.selected_entry_index = (
|
self.selected_entry_index = self.table_handler.display_index_to_actual_index(
|
||||||
self.table_handler.display_index_to_actual_index(event.cursor_row)
|
event.cursor_row
|
||||||
)
|
)
|
||||||
self.details_handler.update_entry_details()
|
self.details_handler.update_entry_details()
|
||||||
|
|
||||||
|
@ -220,7 +213,6 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
def action_config(self) -> None:
|
def action_config(self) -> None:
|
||||||
"""Show configuration modal."""
|
"""Show configuration modal."""
|
||||||
|
|
||||||
def handle_config_result(config_changed: bool) -> None:
|
def handle_config_result(config_changed: bool) -> None:
|
||||||
if config_changed:
|
if config_changed:
|
||||||
# Reload the table to apply new filtering
|
# Reload the table to apply new filtering
|
||||||
|
@ -253,42 +245,15 @@ class HostsManagerApp(App):
|
||||||
else:
|
else:
|
||||||
self.update_status(f"Error exiting edit mode: {message}")
|
self.update_status(f"Error exiting edit mode: {message}")
|
||||||
else:
|
else:
|
||||||
# Enter edit mode - first try without password
|
# Enter edit mode
|
||||||
success, message = self.manager.enter_edit_mode()
|
success, message = self.manager.enter_edit_mode()
|
||||||
if success:
|
if success:
|
||||||
self.edit_mode = True
|
self.edit_mode = True
|
||||||
self.sub_title = "Edit mode"
|
self.sub_title = "Edit mode"
|
||||||
self.update_status(message)
|
self.update_status(message)
|
||||||
elif "Password required" in message:
|
|
||||||
# Show password modal
|
|
||||||
self._request_sudo_password()
|
|
||||||
else:
|
else:
|
||||||
self.update_status(f"Error entering edit mode: {message}")
|
self.update_status(f"Error entering edit mode: {message}")
|
||||||
|
|
||||||
def _request_sudo_password(self) -> None:
|
|
||||||
"""Show password modal and attempt sudo authentication."""
|
|
||||||
|
|
||||||
def handle_password(password: str) -> None:
|
|
||||||
if password is None:
|
|
||||||
# User cancelled
|
|
||||||
self.update_status("Edit mode cancelled")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Try to enter edit mode with password
|
|
||||||
success, message = self.manager.enter_edit_mode(password)
|
|
||||||
if success:
|
|
||||||
self.edit_mode = True
|
|
||||||
self.sub_title = "Edit mode"
|
|
||||||
self.update_status(message)
|
|
||||||
elif "Incorrect password" in message:
|
|
||||||
# Show error and try again
|
|
||||||
self.update_status("❌ Incorrect password. Please try again.")
|
|
||||||
self.set_timer(2.0, lambda: self._request_sudo_password())
|
|
||||||
else:
|
|
||||||
self.update_status(f"❌ Error entering edit mode: {message}")
|
|
||||||
|
|
||||||
self.push_screen(PasswordModal(), handle_password)
|
|
||||||
|
|
||||||
def action_edit_entry(self) -> None:
|
def action_edit_entry(self) -> None:
|
||||||
"""Enter edit mode for the selected entry."""
|
"""Enter edit mode for the selected entry."""
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
|
@ -399,11 +364,3 @@ class HostsManagerApp(App):
|
||||||
def update_edit_form(self) -> None:
|
def update_edit_form(self) -> None:
|
||||||
"""Update the edit form with current entry values."""
|
"""Update the edit form with current entry values."""
|
||||||
self.details_handler.update_edit_form()
|
self.details_handler.update_edit_form()
|
||||||
|
|
||||||
def watch_entry_edit_mode(self, entry_edit_mode: bool) -> None:
|
|
||||||
"""Update the right pane border title when entry edit mode changes."""
|
|
||||||
right_pane = self.query_one(".right-pane")
|
|
||||||
if entry_edit_mode:
|
|
||||||
right_pane.border_title = "Edit Entry"
|
|
||||||
else:
|
|
||||||
right_pane.border_title = "Entry Details"
|
|
||||||
|
|
|
@ -79,19 +79,12 @@ class ConfigModal(ModalScreen):
|
||||||
"Show default system entries (localhost, broadcasthost)",
|
"Show default system entries (localhost, broadcasthost)",
|
||||||
value=self.config.should_show_default_entries(),
|
value=self.config.should_show_default_entries(),
|
||||||
id="show-defaults-checkbox",
|
id="show-defaults-checkbox",
|
||||||
classes="config-option",
|
classes="config-option"
|
||||||
)
|
)
|
||||||
|
|
||||||
with Horizontal(classes="button-row"):
|
with Horizontal(classes="button-row"):
|
||||||
yield Button(
|
yield Button("Save", variant="primary", id="save-button", classes="config-button")
|
||||||
"Save", variant="primary", id="save-button", classes="config-button"
|
yield Button("Cancel", variant="default", id="cancel-button", classes="config-button")
|
||||||
)
|
|
||||||
yield Button(
|
|
||||||
"Cancel",
|
|
||||||
variant="default",
|
|
||||||
id="cancel-button",
|
|
||||||
classes="config-button",
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
"""Handle button presses."""
|
"""Handle button presses."""
|
||||||
|
|
|
@ -5,7 +5,7 @@ This module handles the display and updating of entry details
|
||||||
and edit forms in the right pane.
|
and edit forms in the right pane.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from textual.widgets import Input, Checkbox, DataTable
|
from textual.widgets import Static, Input, Checkbox, DataTable
|
||||||
|
|
||||||
|
|
||||||
class DetailsHandler:
|
class DetailsHandler:
|
||||||
|
@ -82,9 +82,7 @@ class DetailsHandler:
|
||||||
details_table.add_row("IP Address", entry.ip_address, key="ip")
|
details_table.add_row("IP Address", entry.ip_address, key="ip")
|
||||||
details_table.add_row("Hostnames", ", ".join(entry.hostnames), key="hostnames")
|
details_table.add_row("Hostnames", ", ".join(entry.hostnames), key="hostnames")
|
||||||
details_table.add_row("Comment", entry.comment or "", key="comment")
|
details_table.add_row("Comment", entry.comment or "", key="comment")
|
||||||
details_table.add_row(
|
details_table.add_row("Active", "Yes" if entry.is_active else "No", key="active")
|
||||||
"Active", "Yes" if entry.is_active else "No", key="active"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add DNS name if present (not in edit form but good to show)
|
# Add DNS name if present (not in edit form but good to show)
|
||||||
if entry.dns_name:
|
if entry.dns_name:
|
||||||
|
|
|
@ -35,18 +35,11 @@ class NavigationHandler:
|
||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
# Auto-save the changes immediately
|
# Auto-save the changes immediately
|
||||||
save_success, save_message = self.app.manager.save_hosts_file(
|
save_success, save_message = self.app.manager.save_hosts_file(self.app.hosts_file)
|
||||||
self.app.hosts_file
|
|
||||||
)
|
|
||||||
if save_success:
|
if save_success:
|
||||||
self.app.table_handler.populate_entries_table()
|
self.app.table_handler.populate_entries_table()
|
||||||
# Restore cursor position to the same entry
|
# Restore cursor position to the same entry
|
||||||
self.app.set_timer(
|
self.app.set_timer(0.1, lambda: self.app.table_handler.restore_cursor_position(current_entry))
|
||||||
0.1,
|
|
||||||
lambda: self.app.table_handler.restore_cursor_position(
|
|
||||||
current_entry
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self.app.details_handler.update_entry_details()
|
self.app.details_handler.update_entry_details()
|
||||||
self.app.update_status(f"{message} - Changes saved automatically")
|
self.app.update_status(f"{message} - Changes saved automatically")
|
||||||
else:
|
else:
|
||||||
|
@ -71,9 +64,7 @@ class NavigationHandler:
|
||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
# Auto-save the changes immediately
|
# Auto-save the changes immediately
|
||||||
save_success, save_message = self.app.manager.save_hosts_file(
|
save_success, save_message = self.app.manager.save_hosts_file(self.app.hosts_file)
|
||||||
self.app.hosts_file
|
|
||||||
)
|
|
||||||
if save_success:
|
if save_success:
|
||||||
# Update the selection index to follow the moved entry
|
# Update the selection index to follow the moved entry
|
||||||
if self.app.selected_entry_index > 0:
|
if self.app.selected_entry_index > 0:
|
||||||
|
@ -110,9 +101,7 @@ class NavigationHandler:
|
||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
# Auto-save the changes immediately
|
# Auto-save the changes immediately
|
||||||
save_success, save_message = self.app.manager.save_hosts_file(
|
save_success, save_message = self.app.manager.save_hosts_file(self.app.hosts_file)
|
||||||
self.app.hosts_file
|
|
||||||
)
|
|
||||||
if save_success:
|
if save_success:
|
||||||
# Update the selection index to follow the moved entry
|
# Update the selection index to follow the moved entry
|
||||||
if self.app.selected_entry_index < len(self.app.hosts_file.entries) - 1:
|
if self.app.selected_entry_index < len(self.app.hosts_file.entries) - 1:
|
||||||
|
|
|
@ -1,152 +0,0 @@
|
||||||
"""
|
|
||||||
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,9 +160,7 @@ class TableHandler:
|
||||||
|
|
||||||
# Update the DataTable cursor position using display index
|
# Update the DataTable cursor position using display index
|
||||||
table = self.app.query_one("#entries-table", DataTable)
|
table = self.app.query_one("#entries-table", DataTable)
|
||||||
display_index = self.actual_index_to_display_index(
|
display_index = self.actual_index_to_display_index(self.app.selected_entry_index)
|
||||||
self.app.selected_entry_index
|
|
||||||
)
|
|
||||||
if table.row_count > 0 and display_index < table.row_count:
|
if table.row_count > 0 and display_index < table.row_count:
|
||||||
# Move cursor to the selected row
|
# Move cursor to the selected row
|
||||||
table.move_cursor(row=display_index)
|
table.move_cursor(row=display_index)
|
||||||
|
@ -182,14 +180,13 @@ class TableHandler:
|
||||||
|
|
||||||
# Remember the currently selected entry
|
# Remember the currently selected entry
|
||||||
current_entry = None
|
current_entry = None
|
||||||
if self.app.hosts_file.entries and self.app.selected_entry_index < len(
|
if self.app.hosts_file.entries and self.app.selected_entry_index < len(self.app.hosts_file.entries):
|
||||||
self.app.hosts_file.entries
|
|
||||||
):
|
|
||||||
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
||||||
|
|
||||||
# Sort the entries
|
# Sort the entries
|
||||||
self.app.hosts_file.entries.sort(
|
self.app.hosts_file.entries.sort(
|
||||||
key=lambda entry: entry.ip_address, 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
|
# Refresh the table and restore cursor position
|
||||||
|
@ -208,15 +205,13 @@ class TableHandler:
|
||||||
|
|
||||||
# Remember the currently selected entry
|
# Remember the currently selected entry
|
||||||
current_entry = None
|
current_entry = None
|
||||||
if self.app.hosts_file.entries and self.app.selected_entry_index < len(
|
if self.app.hosts_file.entries and self.app.selected_entry_index < len(self.app.hosts_file.entries):
|
||||||
self.app.hosts_file.entries
|
|
||||||
):
|
|
||||||
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
||||||
|
|
||||||
# Sort the entries
|
# Sort the entries
|
||||||
self.app.hosts_file.entries.sort(
|
self.app.hosts_file.entries.sort(
|
||||||
key=lambda entry: entry.hostnames[0].lower() if entry.hostnames else "",
|
key=lambda entry: entry.hostnames[0].lower() if entry.hostnames else "",
|
||||||
reverse=not self.app.sort_ascending,
|
reverse=not self.app.sort_ascending
|
||||||
)
|
)
|
||||||
|
|
||||||
# Refresh the table and restore cursor position
|
# Refresh the table and restore cursor position
|
||||||
|
|
|
@ -18,7 +18,7 @@ class TestConfig:
|
||||||
|
|
||||||
def test_config_initialization(self):
|
def test_config_initialization(self):
|
||||||
"""Test basic config initialization with defaults."""
|
"""Test basic config initialization with defaults."""
|
||||||
with patch.object(Config, "load"):
|
with patch.object(Config, 'load'):
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
# Check default settings
|
# Check default settings
|
||||||
|
@ -29,28 +29,24 @@ class TestConfig:
|
||||||
|
|
||||||
def test_default_settings_structure(self):
|
def test_default_settings_structure(self):
|
||||||
"""Test that default settings have the expected structure."""
|
"""Test that default settings have the expected structure."""
|
||||||
with patch.object(Config, "load"):
|
with patch.object(Config, 'load'):
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
default_entries = config.get("default_entries", [])
|
default_entries = config.get("default_entries", [])
|
||||||
assert len(default_entries) == 3
|
assert len(default_entries) == 3
|
||||||
|
|
||||||
# Check localhost entries
|
# Check localhost entries
|
||||||
localhost_entries = [
|
localhost_entries = [e for e in default_entries if e["hostname"] == "localhost"]
|
||||||
e for e in default_entries if e["hostname"] == "localhost"
|
|
||||||
]
|
|
||||||
assert len(localhost_entries) == 2 # IPv4 and IPv6
|
assert len(localhost_entries) == 2 # IPv4 and IPv6
|
||||||
|
|
||||||
# Check broadcasthost entry
|
# Check broadcasthost entry
|
||||||
broadcast_entries = [
|
broadcast_entries = [e for e in default_entries if e["hostname"] == "broadcasthost"]
|
||||||
e for e in default_entries if e["hostname"] == "broadcasthost"
|
|
||||||
]
|
|
||||||
assert len(broadcast_entries) == 1
|
assert len(broadcast_entries) == 1
|
||||||
assert broadcast_entries[0]["ip"] == "255.255.255.255"
|
assert broadcast_entries[0]["ip"] == "255.255.255.255"
|
||||||
|
|
||||||
def test_config_paths(self):
|
def test_config_paths(self):
|
||||||
"""Test that config paths are set correctly."""
|
"""Test that config paths are set correctly."""
|
||||||
with patch.object(Config, "load"):
|
with patch.object(Config, 'load'):
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
expected_dir = Path.home() / ".config" / "hosts-manager"
|
expected_dir = Path.home() / ".config" / "hosts-manager"
|
||||||
|
@ -61,7 +57,7 @@ class TestConfig:
|
||||||
|
|
||||||
def test_get_existing_key(self):
|
def test_get_existing_key(self):
|
||||||
"""Test getting an existing configuration key."""
|
"""Test getting an existing configuration key."""
|
||||||
with patch.object(Config, "load"):
|
with patch.object(Config, 'load'):
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
result = config.get("show_default_entries")
|
result = config.get("show_default_entries")
|
||||||
|
@ -69,7 +65,7 @@ class TestConfig:
|
||||||
|
|
||||||
def test_get_nonexistent_key_with_default(self):
|
def test_get_nonexistent_key_with_default(self):
|
||||||
"""Test getting a nonexistent key with default value."""
|
"""Test getting a nonexistent key with default value."""
|
||||||
with patch.object(Config, "load"):
|
with patch.object(Config, 'load'):
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
result = config.get("nonexistent_key", "default_value")
|
result = config.get("nonexistent_key", "default_value")
|
||||||
|
@ -77,7 +73,7 @@ class TestConfig:
|
||||||
|
|
||||||
def test_get_nonexistent_key_without_default(self):
|
def test_get_nonexistent_key_without_default(self):
|
||||||
"""Test getting a nonexistent key without default value."""
|
"""Test getting a nonexistent key without default value."""
|
||||||
with patch.object(Config, "load"):
|
with patch.object(Config, 'load'):
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
result = config.get("nonexistent_key")
|
result = config.get("nonexistent_key")
|
||||||
|
@ -85,7 +81,7 @@ class TestConfig:
|
||||||
|
|
||||||
def test_set_configuration_value(self):
|
def test_set_configuration_value(self):
|
||||||
"""Test setting a configuration value."""
|
"""Test setting a configuration value."""
|
||||||
with patch.object(Config, "load"):
|
with patch.object(Config, 'load'):
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
config.set("test_key", "test_value")
|
config.set("test_key", "test_value")
|
||||||
|
@ -93,7 +89,7 @@ class TestConfig:
|
||||||
|
|
||||||
def test_set_overwrites_existing_value(self):
|
def test_set_overwrites_existing_value(self):
|
||||||
"""Test that setting overwrites existing values."""
|
"""Test that setting overwrites existing values."""
|
||||||
with patch.object(Config, "load"):
|
with patch.object(Config, 'load'):
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
# Set initial value
|
# Set initial value
|
||||||
|
@ -106,7 +102,7 @@ class TestConfig:
|
||||||
|
|
||||||
def test_is_default_entry_true(self):
|
def test_is_default_entry_true(self):
|
||||||
"""Test identifying default entries correctly."""
|
"""Test identifying default entries correctly."""
|
||||||
with patch.object(Config, "load"):
|
with patch.object(Config, 'load'):
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
# Test localhost IPv4
|
# Test localhost IPv4
|
||||||
|
@ -120,7 +116,7 @@ class TestConfig:
|
||||||
|
|
||||||
def test_is_default_entry_false(self):
|
def test_is_default_entry_false(self):
|
||||||
"""Test that non-default entries are not identified as default."""
|
"""Test that non-default entries are not identified as default."""
|
||||||
with patch.object(Config, "load"):
|
with patch.object(Config, 'load'):
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
# Test custom entries
|
# Test custom entries
|
||||||
|
@ -130,14 +126,14 @@ class TestConfig:
|
||||||
|
|
||||||
def test_should_show_default_entries_default(self):
|
def test_should_show_default_entries_default(self):
|
||||||
"""Test default value for show_default_entries."""
|
"""Test default value for show_default_entries."""
|
||||||
with patch.object(Config, "load"):
|
with patch.object(Config, 'load'):
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
assert config.should_show_default_entries() is False
|
assert config.should_show_default_entries() is False
|
||||||
|
|
||||||
def test_should_show_default_entries_configured(self):
|
def test_should_show_default_entries_configured(self):
|
||||||
"""Test configured value for show_default_entries."""
|
"""Test configured value for show_default_entries."""
|
||||||
with patch.object(Config, "load"):
|
with patch.object(Config, 'load'):
|
||||||
config = Config()
|
config = Config()
|
||||||
config.set("show_default_entries", True)
|
config.set("show_default_entries", True)
|
||||||
|
|
||||||
|
@ -145,7 +141,7 @@ class TestConfig:
|
||||||
|
|
||||||
def test_toggle_show_default_entries(self):
|
def test_toggle_show_default_entries(self):
|
||||||
"""Test toggling the show_default_entries setting."""
|
"""Test toggling the show_default_entries setting."""
|
||||||
with patch.object(Config, "load"), patch.object(Config, "save") as mock_save:
|
with patch.object(Config, 'load'), patch.object(Config, 'save') as mock_save:
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
# Initial state should be False
|
# Initial state should be False
|
||||||
|
@ -164,7 +160,7 @@ class TestConfig:
|
||||||
|
|
||||||
def test_load_nonexistent_file(self):
|
def test_load_nonexistent_file(self):
|
||||||
"""Test loading config when file doesn't exist."""
|
"""Test loading config when file doesn't exist."""
|
||||||
with patch("pathlib.Path.exists", return_value=False):
|
with patch('pathlib.Path.exists', return_value=False):
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
# Should use defaults when file doesn't exist
|
# Should use defaults when file doesn't exist
|
||||||
|
@ -172,12 +168,13 @@ class TestConfig:
|
||||||
|
|
||||||
def test_load_existing_file(self):
|
def test_load_existing_file(self):
|
||||||
"""Test loading config from existing file."""
|
"""Test loading config from existing file."""
|
||||||
test_config = {"show_default_entries": True, "custom_setting": "custom_value"}
|
test_config = {
|
||||||
|
"show_default_entries": True,
|
||||||
|
"custom_setting": "custom_value"
|
||||||
|
}
|
||||||
|
|
||||||
with (
|
with patch('pathlib.Path.exists', return_value=True), \
|
||||||
patch("pathlib.Path.exists", return_value=True),
|
patch('builtins.open', mock_open(read_data=json.dumps(test_config))):
|
||||||
patch("builtins.open", mock_open(read_data=json.dumps(test_config))),
|
|
||||||
):
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
# Should load values from file
|
# Should load values from file
|
||||||
|
@ -189,10 +186,8 @@ class TestConfig:
|
||||||
|
|
||||||
def test_load_invalid_json(self):
|
def test_load_invalid_json(self):
|
||||||
"""Test loading config with invalid JSON falls back to defaults."""
|
"""Test loading config with invalid JSON falls back to defaults."""
|
||||||
with (
|
with patch('pathlib.Path.exists', return_value=True), \
|
||||||
patch("pathlib.Path.exists", return_value=True),
|
patch('builtins.open', mock_open(read_data="invalid json")):
|
||||||
patch("builtins.open", mock_open(read_data="invalid json")),
|
|
||||||
):
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
# Should use defaults when JSON is invalid
|
# Should use defaults when JSON is invalid
|
||||||
|
@ -200,10 +195,8 @@ class TestConfig:
|
||||||
|
|
||||||
def test_load_file_io_error(self):
|
def test_load_file_io_error(self):
|
||||||
"""Test loading config with file I/O error falls back to defaults."""
|
"""Test loading config with file I/O error falls back to defaults."""
|
||||||
with (
|
with patch('pathlib.Path.exists', return_value=True), \
|
||||||
patch("pathlib.Path.exists", return_value=True),
|
patch('builtins.open', side_effect=IOError("File error")):
|
||||||
patch("builtins.open", side_effect=IOError("File error")),
|
|
||||||
):
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
# Should use defaults when file can't be read
|
# Should use defaults when file can't be read
|
||||||
|
@ -211,11 +204,9 @@ class TestConfig:
|
||||||
|
|
||||||
def test_save_creates_directory(self):
|
def test_save_creates_directory(self):
|
||||||
"""Test that save creates config directory if it doesn't exist."""
|
"""Test that save creates config directory if it doesn't exist."""
|
||||||
with (
|
with patch.object(Config, 'load'), \
|
||||||
patch.object(Config, "load"),
|
patch('pathlib.Path.mkdir') as mock_mkdir, \
|
||||||
patch("pathlib.Path.mkdir") as mock_mkdir,
|
patch('builtins.open', mock_open()) as mock_file:
|
||||||
patch("builtins.open", mock_open()) as mock_file,
|
|
||||||
):
|
|
||||||
config = Config()
|
config = Config()
|
||||||
config.save()
|
config.save()
|
||||||
|
|
||||||
|
@ -225,21 +216,19 @@ class TestConfig:
|
||||||
|
|
||||||
def test_save_writes_json(self):
|
def test_save_writes_json(self):
|
||||||
"""Test that save writes configuration as JSON."""
|
"""Test that save writes configuration as JSON."""
|
||||||
with (
|
with patch.object(Config, 'load'), \
|
||||||
patch.object(Config, "load"),
|
patch('pathlib.Path.mkdir'), \
|
||||||
patch("pathlib.Path.mkdir"),
|
patch('builtins.open', mock_open()) as mock_file:
|
||||||
patch("builtins.open", mock_open()) as mock_file,
|
|
||||||
):
|
|
||||||
config = Config()
|
config = Config()
|
||||||
config.set("test_key", "test_value")
|
config.set("test_key", "test_value")
|
||||||
config.save()
|
config.save()
|
||||||
|
|
||||||
# Check that file was opened for writing
|
# Check that file was opened for writing
|
||||||
mock_file.assert_called_once_with(config.config_file, "w")
|
mock_file.assert_called_once_with(config.config_file, 'w')
|
||||||
|
|
||||||
# Check that JSON was written
|
# Check that JSON was written
|
||||||
handle = mock_file()
|
handle = mock_file()
|
||||||
written_data = "".join(call.args[0] for call in handle.write.call_args_list)
|
written_data = ''.join(call.args[0] for call in handle.write.call_args_list)
|
||||||
|
|
||||||
# Should be valid JSON containing our test data
|
# Should be valid JSON containing our test data
|
||||||
parsed_data = json.loads(written_data)
|
parsed_data = json.loads(written_data)
|
||||||
|
@ -247,11 +236,9 @@ class TestConfig:
|
||||||
|
|
||||||
def test_save_io_error_silent_fail(self):
|
def test_save_io_error_silent_fail(self):
|
||||||
"""Test that save silently fails on I/O error."""
|
"""Test that save silently fails on I/O error."""
|
||||||
with (
|
with patch.object(Config, 'load'), \
|
||||||
patch.object(Config, "load"),
|
patch('pathlib.Path.mkdir'), \
|
||||||
patch("pathlib.Path.mkdir"),
|
patch('builtins.open', side_effect=IOError("Write error")):
|
||||||
patch("builtins.open", side_effect=IOError("Write error")),
|
|
||||||
):
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
# Should not raise exception
|
# Should not raise exception
|
||||||
|
@ -259,10 +246,8 @@ class TestConfig:
|
||||||
|
|
||||||
def test_save_directory_creation_error_silent_fail(self):
|
def test_save_directory_creation_error_silent_fail(self):
|
||||||
"""Test that save silently fails on directory creation error."""
|
"""Test that save silently fails on directory creation error."""
|
||||||
with (
|
with patch.object(Config, 'load'), \
|
||||||
patch.object(Config, "load"),
|
patch('pathlib.Path.mkdir', side_effect=OSError("Permission denied")):
|
||||||
patch("pathlib.Path.mkdir", side_effect=OSError("Permission denied")),
|
|
||||||
):
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
# Should not raise exception
|
# Should not raise exception
|
||||||
|
@ -274,7 +259,7 @@ class TestConfig:
|
||||||
config_dir = Path(temp_dir) / "hosts-manager"
|
config_dir = Path(temp_dir) / "hosts-manager"
|
||||||
config_file = config_dir / "config.json"
|
config_file = config_dir / "config.json"
|
||||||
|
|
||||||
with patch.object(Config, "__init__", lambda self: None):
|
with patch.object(Config, '__init__', lambda self: None):
|
||||||
config = Config()
|
config = Config()
|
||||||
config.config_dir = config_dir
|
config.config_dir = config_dir
|
||||||
config.config_file = config_file
|
config.config_file = config_file
|
||||||
|
|
|
@ -32,7 +32,7 @@ class TestConfigModal:
|
||||||
modal = ConfigModal(mock_config)
|
modal = ConfigModal(mock_config)
|
||||||
|
|
||||||
# Test that compose method exists and is callable
|
# Test that compose method exists and is callable
|
||||||
assert hasattr(modal, "compose")
|
assert hasattr(modal, 'compose')
|
||||||
assert callable(modal.compose)
|
assert callable(modal.compose)
|
||||||
|
|
||||||
def test_action_save_updates_config(self):
|
def test_action_save_updates_config(self):
|
||||||
|
@ -171,7 +171,7 @@ class TestConfigModal:
|
||||||
modal = ConfigModal(mock_config)
|
modal = ConfigModal(mock_config)
|
||||||
|
|
||||||
# Check that CSS is defined
|
# Check that CSS is defined
|
||||||
assert hasattr(modal, "CSS")
|
assert hasattr(modal, 'CSS')
|
||||||
assert isinstance(modal.CSS, str)
|
assert isinstance(modal.CSS, str)
|
||||||
assert len(modal.CSS) > 0
|
assert len(modal.CSS) > 0
|
||||||
|
|
||||||
|
@ -207,14 +207,12 @@ class TestConfigModal:
|
||||||
|
|
||||||
# Test that compose method exists and has correct signature
|
# Test that compose method exists and has correct signature
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
sig = inspect.signature(modal.compose)
|
sig = inspect.signature(modal.compose)
|
||||||
assert len(sig.parameters) == 0 # No parameters except self
|
assert len(sig.parameters) == 0 # No parameters except self
|
||||||
|
|
||||||
# Test return type annotation if present
|
# Test return type annotation if present
|
||||||
if sig.return_annotation != inspect.Signature.empty:
|
if sig.return_annotation != inspect.Signature.empty:
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
|
|
||||||
assert sig.return_annotation == ComposeResult
|
assert sig.return_annotation == ComposeResult
|
||||||
|
|
||||||
def test_modal_inheritance(self):
|
def test_modal_inheritance(self):
|
||||||
|
@ -223,9 +221,8 @@ class TestConfigModal:
|
||||||
modal = ConfigModal(mock_config)
|
modal = ConfigModal(mock_config)
|
||||||
|
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
|
|
||||||
assert isinstance(modal, ModalScreen)
|
assert isinstance(modal, ModalScreen)
|
||||||
|
|
||||||
# Should have the config attribute
|
# Should have the config attribute
|
||||||
assert hasattr(modal, "config")
|
assert hasattr(modal, 'config')
|
||||||
assert modal.config == mock_config
|
assert modal.config == mock_config
|
||||||
|
|
|
@ -19,7 +19,7 @@ class TestHostsManagerApp:
|
||||||
|
|
||||||
def test_app_initialization(self):
|
def test_app_initialization(self):
|
||||||
"""Test application initialization."""
|
"""Test application initialization."""
|
||||||
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
with patch('hosts.tui.app.HostsParser'), patch('hosts.tui.app.Config'):
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
|
|
||||||
assert app.title == "/etc/hosts Manager"
|
assert app.title == "/etc/hosts Manager"
|
||||||
|
@ -31,11 +31,11 @@ class TestHostsManagerApp:
|
||||||
|
|
||||||
def test_app_compose_method_exists(self):
|
def test_app_compose_method_exists(self):
|
||||||
"""Test that app has compose method."""
|
"""Test that app has compose method."""
|
||||||
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
with patch('hosts.tui.app.HostsParser'), patch('hosts.tui.app.Config'):
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
|
|
||||||
# Test that compose method exists and is callable
|
# Test that compose method exists and is callable
|
||||||
assert hasattr(app, "compose")
|
assert hasattr(app, 'compose')
|
||||||
assert callable(app.compose)
|
assert callable(app.compose)
|
||||||
|
|
||||||
def test_load_hosts_file_success(self):
|
def test_load_hosts_file_success(self):
|
||||||
|
@ -50,15 +50,14 @@ class TestHostsManagerApp:
|
||||||
|
|
||||||
mock_parser.parse.return_value = test_hosts
|
mock_parser.parse.return_value = test_hosts
|
||||||
mock_parser.get_file_info.return_value = {
|
mock_parser.get_file_info.return_value = {
|
||||||
"path": "/etc/hosts",
|
'path': '/etc/hosts',
|
||||||
"exists": True,
|
'exists': True,
|
||||||
"size": 100,
|
'size': 100
|
||||||
}
|
}
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
app.populate_entries_table = Mock()
|
app.populate_entries_table = Mock()
|
||||||
app.update_entry_details = Mock()
|
app.update_entry_details = Mock()
|
||||||
|
@ -77,19 +76,16 @@ class TestHostsManagerApp:
|
||||||
mock_config = Mock(spec=Config)
|
mock_config = Mock(spec=Config)
|
||||||
mock_parser.parse.side_effect = FileNotFoundError("Hosts file not found")
|
mock_parser.parse.side_effect = FileNotFoundError("Hosts file not found")
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
app.update_status = Mock()
|
app.update_status = Mock()
|
||||||
|
|
||||||
app.load_hosts_file()
|
app.load_hosts_file()
|
||||||
|
|
||||||
# Should handle error gracefully
|
# Should handle error gracefully
|
||||||
app.update_status.assert_called_with(
|
app.update_status.assert_called_with("❌ Error loading hosts file: Hosts file not found")
|
||||||
"❌ Error loading hosts file: Hosts file not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_load_hosts_file_permission_error(self):
|
def test_load_hosts_file_permission_error(self):
|
||||||
"""Test handling of permission denied error."""
|
"""Test handling of permission denied error."""
|
||||||
|
@ -97,19 +93,16 @@ class TestHostsManagerApp:
|
||||||
mock_config = Mock(spec=Config)
|
mock_config = Mock(spec=Config)
|
||||||
mock_parser.parse.side_effect = PermissionError("Permission denied")
|
mock_parser.parse.side_effect = PermissionError("Permission denied")
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
app.update_status = Mock()
|
app.update_status = Mock()
|
||||||
|
|
||||||
app.load_hosts_file()
|
app.load_hosts_file()
|
||||||
|
|
||||||
# Should handle error gracefully
|
# Should handle error gracefully
|
||||||
app.update_status.assert_called_with(
|
app.update_status.assert_called_with("❌ Error loading hosts file: Permission denied")
|
||||||
"❌ Error loading hosts file: Permission denied"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_populate_entries_table_logic(self):
|
def test_populate_entries_table_logic(self):
|
||||||
"""Test populating DataTable logic without UI dependencies."""
|
"""Test populating DataTable logic without UI dependencies."""
|
||||||
|
@ -118,10 +111,9 @@ class TestHostsManagerApp:
|
||||||
mock_config.should_show_default_entries.return_value = True
|
mock_config.should_show_default_entries.return_value = True
|
||||||
mock_config.is_default_entry.return_value = False
|
mock_config.is_default_entry.return_value = False
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
|
|
||||||
# Mock the query_one method to return a mock table
|
# Mock the query_one method to return a mock table
|
||||||
|
@ -132,7 +124,9 @@ class TestHostsManagerApp:
|
||||||
app.hosts_file = HostsFile()
|
app.hosts_file = HostsFile()
|
||||||
active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
inactive_entry = HostEntry(
|
inactive_entry = HostEntry(
|
||||||
ip_address="192.168.1.1", 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(active_entry)
|
||||||
app.hosts_file.add_entry(inactive_entry)
|
app.hosts_file.add_entry(inactive_entry)
|
||||||
|
@ -150,10 +144,9 @@ class TestHostsManagerApp:
|
||||||
mock_config = Mock(spec=Config)
|
mock_config = Mock(spec=Config)
|
||||||
mock_config.should_show_default_entries.return_value = True
|
mock_config.should_show_default_entries.return_value = True
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
|
|
||||||
# Mock the query_one method to return DataTable mock
|
# Mock the query_one method to return DataTable mock
|
||||||
|
@ -175,7 +168,7 @@ class TestHostsManagerApp:
|
||||||
test_entry = HostEntry(
|
test_entry = HostEntry(
|
||||||
ip_address="127.0.0.1",
|
ip_address="127.0.0.1",
|
||||||
hostnames=["localhost", "local"],
|
hostnames=["localhost", "local"],
|
||||||
comment="Test comment",
|
comment="Test comment"
|
||||||
)
|
)
|
||||||
app.hosts_file.add_entry(test_entry)
|
app.hosts_file.add_entry(test_entry)
|
||||||
app.selected_entry_index = 0
|
app.selected_entry_index = 0
|
||||||
|
@ -194,10 +187,9 @@ class TestHostsManagerApp:
|
||||||
mock_parser = Mock(spec=HostsParser)
|
mock_parser = Mock(spec=HostsParser)
|
||||||
mock_config = Mock(spec=Config)
|
mock_config = Mock(spec=Config)
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
|
|
||||||
# Mock the query_one method to return DataTable mock
|
# Mock the query_one method to return DataTable mock
|
||||||
|
@ -229,27 +221,24 @@ class TestHostsManagerApp:
|
||||||
mock_parser = Mock(spec=HostsParser)
|
mock_parser = Mock(spec=HostsParser)
|
||||||
mock_config = Mock(spec=Config)
|
mock_config = Mock(spec=Config)
|
||||||
mock_parser.get_file_info.return_value = {
|
mock_parser.get_file_info.return_value = {
|
||||||
"path": "/etc/hosts",
|
'path': '/etc/hosts',
|
||||||
"exists": True,
|
'exists': True,
|
||||||
"size": 100,
|
'size': 100
|
||||||
}
|
}
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
|
|
||||||
# Add test entries
|
# Add test entries
|
||||||
app.hosts_file = HostsFile()
|
app.hosts_file = HostsFile()
|
||||||
app.hosts_file.add_entry(
|
app.hosts_file.add_entry(HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]))
|
||||||
HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
app.hosts_file.add_entry(HostEntry(
|
||||||
)
|
ip_address="192.168.1.1",
|
||||||
app.hosts_file.add_entry(
|
hostnames=["router"],
|
||||||
HostEntry(
|
is_active=False
|
||||||
ip_address="192.168.1.1", hostnames=["router"], is_active=False
|
))
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
app.update_status()
|
app.update_status()
|
||||||
|
|
||||||
|
@ -263,10 +252,9 @@ class TestHostsManagerApp:
|
||||||
mock_parser = Mock(spec=HostsParser)
|
mock_parser = Mock(spec=HostsParser)
|
||||||
mock_config = Mock(spec=Config)
|
mock_config = Mock(spec=Config)
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
|
|
||||||
# Mock set_timer and query_one to avoid event loop and UI issues
|
# Mock set_timer and query_one to avoid event loop and UI issues
|
||||||
|
@ -276,14 +264,8 @@ class TestHostsManagerApp:
|
||||||
|
|
||||||
# Add test hosts_file for subtitle generation
|
# Add test hosts_file for subtitle generation
|
||||||
app.hosts_file = HostsFile()
|
app.hosts_file = HostsFile()
|
||||||
app.hosts_file.add_entry(
|
app.hosts_file.add_entry(HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]))
|
||||||
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="192.168.1.1", hostnames=["router"], is_active=False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
app.update_status("Custom status message")
|
app.update_status("Custom status message")
|
||||||
|
|
||||||
|
@ -301,10 +283,9 @@ class TestHostsManagerApp:
|
||||||
mock_parser = Mock(spec=HostsParser)
|
mock_parser = Mock(spec=HostsParser)
|
||||||
mock_config = Mock(spec=Config)
|
mock_config = Mock(spec=Config)
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
app.load_hosts_file = Mock()
|
app.load_hosts_file = Mock()
|
||||||
app.update_status = Mock()
|
app.update_status = Mock()
|
||||||
|
@ -319,10 +300,9 @@ class TestHostsManagerApp:
|
||||||
mock_parser = Mock(spec=HostsParser)
|
mock_parser = Mock(spec=HostsParser)
|
||||||
mock_config = Mock(spec=Config)
|
mock_config = Mock(spec=Config)
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
app.update_status = Mock()
|
app.update_status = Mock()
|
||||||
|
|
||||||
|
@ -338,10 +318,9 @@ class TestHostsManagerApp:
|
||||||
mock_parser = Mock(spec=HostsParser)
|
mock_parser = Mock(spec=HostsParser)
|
||||||
mock_config = Mock(spec=Config)
|
mock_config = Mock(spec=Config)
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
app.push_screen = Mock()
|
app.push_screen = Mock()
|
||||||
|
|
||||||
|
@ -357,23 +336,16 @@ class TestHostsManagerApp:
|
||||||
mock_parser = Mock(spec=HostsParser)
|
mock_parser = Mock(spec=HostsParser)
|
||||||
mock_config = Mock(spec=Config)
|
mock_config = Mock(spec=Config)
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
|
|
||||||
# Add test entries in reverse order
|
# Add test entries in reverse order
|
||||||
app.hosts_file = HostsFile()
|
app.hosts_file = HostsFile()
|
||||||
app.hosts_file.add_entry(
|
app.hosts_file.add_entry(HostEntry(ip_address="192.168.1.1", hostnames=["router"]))
|
||||||
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="127.0.0.1", hostnames=["localhost"])
|
|
||||||
)
|
|
||||||
app.hosts_file.add_entry(
|
|
||||||
HostEntry(ip_address="10.0.0.1", hostnames=["test"])
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock the table_handler methods to avoid UI queries
|
# Mock the table_handler methods to avoid UI queries
|
||||||
app.table_handler.populate_entries_table = Mock()
|
app.table_handler.populate_entries_table = Mock()
|
||||||
|
@ -383,7 +355,7 @@ class TestHostsManagerApp:
|
||||||
app.action_sort_by_ip()
|
app.action_sort_by_ip()
|
||||||
|
|
||||||
# Check that entries are sorted by IP address
|
# 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[1].ip_address == "127.0.0.1"
|
||||||
assert app.hosts_file.entries[2].ip_address == "192.168.1.1"
|
assert app.hosts_file.entries[2].ip_address == "192.168.1.1"
|
||||||
|
|
||||||
|
@ -396,23 +368,16 @@ class TestHostsManagerApp:
|
||||||
mock_parser = Mock(spec=HostsParser)
|
mock_parser = Mock(spec=HostsParser)
|
||||||
mock_config = Mock(spec=Config)
|
mock_config = Mock(spec=Config)
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
|
|
||||||
# Add test entries in reverse alphabetical order
|
# Add test entries in reverse alphabetical order
|
||||||
app.hosts_file = HostsFile()
|
app.hosts_file = HostsFile()
|
||||||
app.hosts_file.add_entry(
|
app.hosts_file.add_entry(HostEntry(ip_address="127.0.0.1", hostnames=["zebra"]))
|
||||||
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="192.168.1.1", hostnames=["alpha"])
|
|
||||||
)
|
|
||||||
app.hosts_file.add_entry(
|
|
||||||
HostEntry(ip_address="10.0.0.1", hostnames=["beta"])
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock the table_handler methods to avoid UI queries
|
# Mock the table_handler methods to avoid UI queries
|
||||||
app.table_handler.populate_entries_table = Mock()
|
app.table_handler.populate_entries_table = Mock()
|
||||||
|
@ -435,10 +400,9 @@ class TestHostsManagerApp:
|
||||||
mock_parser = Mock(spec=HostsParser)
|
mock_parser = Mock(spec=HostsParser)
|
||||||
mock_config = Mock(spec=Config)
|
mock_config = Mock(spec=Config)
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
|
|
||||||
# Mock the details_handler and table_handler methods
|
# Mock the details_handler and table_handler methods
|
||||||
|
@ -464,10 +428,9 @@ class TestHostsManagerApp:
|
||||||
mock_parser = Mock(spec=HostsParser)
|
mock_parser = Mock(spec=HostsParser)
|
||||||
mock_config = Mock(spec=Config)
|
mock_config = Mock(spec=Config)
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
app.action_sort_by_ip = Mock()
|
app.action_sort_by_ip = Mock()
|
||||||
|
|
||||||
|
@ -487,10 +450,9 @@ class TestHostsManagerApp:
|
||||||
mock_parser = Mock(spec=HostsParser)
|
mock_parser = Mock(spec=HostsParser)
|
||||||
mock_config = Mock(spec=Config)
|
mock_config = Mock(spec=Config)
|
||||||
|
|
||||||
with (
|
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||||
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
|
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
|
||||||
):
|
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
|
|
||||||
# Mock the query_one method to avoid UI dependencies
|
# Mock the query_one method to avoid UI dependencies
|
||||||
|
@ -509,10 +471,7 @@ class TestHostsManagerApp:
|
||||||
# Find the index of entry2
|
# Find the index of entry2
|
||||||
target_index = None
|
target_index = None
|
||||||
for i, entry in enumerate(app.hosts_file.entries):
|
for i, entry in enumerate(app.hosts_file.entries):
|
||||||
if (
|
if entry.ip_address == entry2.ip_address and entry.hostnames == entry2.hostnames:
|
||||||
entry.ip_address == entry2.ip_address
|
|
||||||
and entry.hostnames == entry2.hostnames
|
|
||||||
):
|
|
||||||
target_index = i
|
target_index = i
|
||||||
break
|
break
|
||||||
|
|
||||||
|
@ -521,7 +480,7 @@ class TestHostsManagerApp:
|
||||||
|
|
||||||
def test_app_bindings_defined(self):
|
def test_app_bindings_defined(self):
|
||||||
"""Test that application has expected key bindings."""
|
"""Test that application has expected key bindings."""
|
||||||
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
with patch('hosts.tui.app.HostsParser'), patch('hosts.tui.app.Config'):
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
|
|
||||||
# Check that bindings are defined
|
# Check that bindings are defined
|
||||||
|
@ -530,7 +489,7 @@ class TestHostsManagerApp:
|
||||||
# Check specific bindings exist (handle both Binding objects and tuples)
|
# Check specific bindings exist (handle both Binding objects and tuples)
|
||||||
binding_keys = []
|
binding_keys = []
|
||||||
for binding in app.BINDINGS:
|
for binding in app.BINDINGS:
|
||||||
if hasattr(binding, "key"):
|
if hasattr(binding, 'key'):
|
||||||
# Binding object
|
# Binding object
|
||||||
binding_keys.append(binding.key)
|
binding_keys.append(binding.key)
|
||||||
elif isinstance(binding, tuple) and len(binding) >= 1:
|
elif isinstance(binding, tuple) and len(binding) >= 1:
|
||||||
|
@ -547,12 +506,11 @@ class TestHostsManagerApp:
|
||||||
|
|
||||||
def test_main_function(self):
|
def test_main_function(self):
|
||||||
"""Test main entry point function."""
|
"""Test main entry point function."""
|
||||||
with patch("hosts.main.HostsManagerApp") as mock_app_class:
|
with patch('hosts.main.HostsManagerApp') as mock_app_class:
|
||||||
mock_app = Mock()
|
mock_app = Mock()
|
||||||
mock_app_class.return_value = mock_app
|
mock_app_class.return_value = mock_app
|
||||||
|
|
||||||
from hosts.main import main
|
from hosts.main import main
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|
||||||
# Should create and run app
|
# Should create and run app
|
||||||
|
|
|
@ -23,7 +23,7 @@ class TestPermissionManager:
|
||||||
assert not pm.has_sudo
|
assert not pm.has_sudo
|
||||||
assert not pm._sudo_validated
|
assert not pm._sudo_validated
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch('subprocess.run')
|
||||||
def test_request_sudo_already_available(self, mock_run):
|
def test_request_sudo_already_available(self, mock_run):
|
||||||
"""Test requesting sudo when already available."""
|
"""Test requesting sudo when already available."""
|
||||||
# Mock successful sudo -n true
|
# Mock successful sudo -n true
|
||||||
|
@ -38,16 +38,19 @@ class TestPermissionManager:
|
||||||
assert pm._sudo_validated
|
assert pm._sudo_validated
|
||||||
|
|
||||||
mock_run.assert_called_once_with(
|
mock_run.assert_called_once_with(
|
||||||
["sudo", "-n", "true"], 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):
|
def test_request_sudo_prompt_success(self, mock_run):
|
||||||
"""Test requesting sudo with password prompt success."""
|
"""Test requesting sudo with password prompt success."""
|
||||||
# First call (sudo -n true) fails, second call (sudo -v) succeeds
|
# First call (sudo -n true) fails, second call (sudo -v) succeeds
|
||||||
mock_run.side_effect = [
|
mock_run.side_effect = [
|
||||||
Mock(returncode=1), # sudo -n true fails
|
Mock(returncode=1), # sudo -n true fails
|
||||||
Mock(returncode=0), # sudo -v succeeds
|
Mock(returncode=0) # sudo -v succeeds
|
||||||
]
|
]
|
||||||
|
|
||||||
pm = PermissionManager()
|
pm = PermissionManager()
|
||||||
|
@ -60,13 +63,13 @@ class TestPermissionManager:
|
||||||
|
|
||||||
assert mock_run.call_count == 2
|
assert mock_run.call_count == 2
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch('subprocess.run')
|
||||||
def test_request_sudo_denied(self, mock_run):
|
def test_request_sudo_denied(self, mock_run):
|
||||||
"""Test requesting sudo when access is denied."""
|
"""Test requesting sudo when access is denied."""
|
||||||
# Both calls fail
|
# Both calls fail
|
||||||
mock_run.side_effect = [
|
mock_run.side_effect = [
|
||||||
Mock(returncode=1), # sudo -n true fails
|
Mock(returncode=1), # sudo -n true fails
|
||||||
Mock(returncode=1), # sudo -v fails
|
Mock(returncode=1) # sudo -v fails
|
||||||
]
|
]
|
||||||
|
|
||||||
pm = PermissionManager()
|
pm = PermissionManager()
|
||||||
|
@ -77,10 +80,10 @@ class TestPermissionManager:
|
||||||
assert not pm.has_sudo
|
assert not pm.has_sudo
|
||||||
assert not pm._sudo_validated
|
assert not pm._sudo_validated
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch('subprocess.run')
|
||||||
def test_request_sudo_timeout(self, mock_run):
|
def test_request_sudo_timeout(self, mock_run):
|
||||||
"""Test requesting sudo with timeout."""
|
"""Test requesting sudo with timeout."""
|
||||||
mock_run.side_effect = subprocess.TimeoutExpired(["sudo", "-n", "true"], 5)
|
mock_run.side_effect = subprocess.TimeoutExpired(['sudo', '-n', 'true'], 5)
|
||||||
|
|
||||||
pm = PermissionManager()
|
pm = PermissionManager()
|
||||||
success, message = pm.request_sudo()
|
success, message = pm.request_sudo()
|
||||||
|
@ -89,7 +92,7 @@ class TestPermissionManager:
|
||||||
assert "timed out" in message
|
assert "timed out" in message
|
||||||
assert not pm.has_sudo
|
assert not pm.has_sudo
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch('subprocess.run')
|
||||||
def test_request_sudo_exception(self, mock_run):
|
def test_request_sudo_exception(self, mock_run):
|
||||||
"""Test requesting sudo with exception."""
|
"""Test requesting sudo with exception."""
|
||||||
mock_run.side_effect = Exception("Test error")
|
mock_run.side_effect = Exception("Test error")
|
||||||
|
@ -101,7 +104,7 @@ class TestPermissionManager:
|
||||||
assert "Test error" in message
|
assert "Test error" in message
|
||||||
assert not pm.has_sudo
|
assert not pm.has_sudo
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch('subprocess.run')
|
||||||
def test_validate_permissions_success(self, mock_run):
|
def test_validate_permissions_success(self, mock_run):
|
||||||
"""Test validating permissions successfully."""
|
"""Test validating permissions successfully."""
|
||||||
mock_run.return_value = Mock(returncode=0)
|
mock_run.return_value = Mock(returncode=0)
|
||||||
|
@ -113,10 +116,12 @@ class TestPermissionManager:
|
||||||
|
|
||||||
assert result
|
assert result
|
||||||
mock_run.assert_called_once_with(
|
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):
|
def test_validate_permissions_no_sudo(self, mock_run):
|
||||||
"""Test validating permissions without sudo."""
|
"""Test validating permissions without sudo."""
|
||||||
pm = PermissionManager()
|
pm = PermissionManager()
|
||||||
|
@ -127,7 +132,7 @@ class TestPermissionManager:
|
||||||
assert not result
|
assert not result
|
||||||
mock_run.assert_not_called()
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch('subprocess.run')
|
||||||
def test_validate_permissions_failure(self, mock_run):
|
def test_validate_permissions_failure(self, mock_run):
|
||||||
"""Test validating permissions failure."""
|
"""Test validating permissions failure."""
|
||||||
mock_run.return_value = Mock(returncode=1)
|
mock_run.return_value = Mock(returncode=1)
|
||||||
|
@ -139,7 +144,7 @@ class TestPermissionManager:
|
||||||
|
|
||||||
assert not result
|
assert not result
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch('subprocess.run')
|
||||||
def test_validate_permissions_exception(self, mock_run):
|
def test_validate_permissions_exception(self, mock_run):
|
||||||
"""Test validating permissions with exception."""
|
"""Test validating permissions with exception."""
|
||||||
mock_run.side_effect = Exception("Test error")
|
mock_run.side_effect = Exception("Test error")
|
||||||
|
@ -151,7 +156,7 @@ class TestPermissionManager:
|
||||||
|
|
||||||
assert not result
|
assert not result
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch('subprocess.run')
|
||||||
def test_release_sudo(self, mock_run):
|
def test_release_sudo(self, mock_run):
|
||||||
"""Test releasing sudo permissions."""
|
"""Test releasing sudo permissions."""
|
||||||
pm = PermissionManager()
|
pm = PermissionManager()
|
||||||
|
@ -162,9 +167,9 @@ class TestPermissionManager:
|
||||||
|
|
||||||
assert not pm.has_sudo
|
assert not pm.has_sudo
|
||||||
assert not pm._sudo_validated
|
assert not pm._sudo_validated
|
||||||
mock_run.assert_called_once_with(["sudo", "-k"], capture_output=True, timeout=5)
|
mock_run.assert_called_once_with(['sudo', '-k'], capture_output=True, timeout=5)
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch('subprocess.run')
|
||||||
def test_release_sudo_exception(self, mock_run):
|
def test_release_sudo_exception(self, mock_run):
|
||||||
"""Test releasing sudo with exception."""
|
"""Test releasing sudo with exception."""
|
||||||
mock_run.side_effect = Exception("Test error")
|
mock_run.side_effect = Exception("Test error")
|
||||||
|
@ -191,16 +196,14 @@ class TestHostsManager:
|
||||||
assert manager._backup_path is None
|
assert manager._backup_path is None
|
||||||
assert manager.parser.file_path == Path(temp_file.name)
|
assert manager.parser.file_path == Path(temp_file.name)
|
||||||
|
|
||||||
@patch("src.hosts.core.manager.HostsManager._create_backup")
|
@patch('src.hosts.core.manager.HostsManager._create_backup')
|
||||||
def test_enter_edit_mode_success(self, mock_backup):
|
def test_enter_edit_mode_success(self, mock_backup):
|
||||||
"""Test entering edit mode successfully."""
|
"""Test entering edit mode successfully."""
|
||||||
with tempfile.NamedTemporaryFile() as temp_file:
|
with tempfile.NamedTemporaryFile() as temp_file:
|
||||||
manager = HostsManager(temp_file.name)
|
manager = HostsManager(temp_file.name)
|
||||||
|
|
||||||
# Mock permission manager
|
# Mock permission manager
|
||||||
manager.permission_manager.request_sudo = Mock(
|
manager.permission_manager.request_sudo = Mock(return_value=(True, "Success"))
|
||||||
return_value=(True, "Success")
|
|
||||||
)
|
|
||||||
manager.permission_manager.validate_permissions = Mock(return_value=True)
|
manager.permission_manager.validate_permissions = Mock(return_value=True)
|
||||||
|
|
||||||
success, message = manager.enter_edit_mode()
|
success, message = manager.enter_edit_mode()
|
||||||
|
@ -227,9 +230,7 @@ class TestHostsManager:
|
||||||
manager = HostsManager(temp_file.name)
|
manager = HostsManager(temp_file.name)
|
||||||
|
|
||||||
# Mock permission manager failure
|
# Mock permission manager failure
|
||||||
manager.permission_manager.request_sudo = Mock(
|
manager.permission_manager.request_sudo = Mock(return_value=(False, "Denied"))
|
||||||
return_value=(False, "Denied")
|
|
||||||
)
|
|
||||||
|
|
||||||
success, message = manager.enter_edit_mode()
|
success, message = manager.enter_edit_mode()
|
||||||
|
|
||||||
|
@ -243,9 +244,7 @@ class TestHostsManager:
|
||||||
manager = HostsManager(temp_file.name)
|
manager = HostsManager(temp_file.name)
|
||||||
|
|
||||||
# Mock permission manager
|
# Mock permission manager
|
||||||
manager.permission_manager.request_sudo = Mock(
|
manager.permission_manager.request_sudo = Mock(return_value=(True, "Success"))
|
||||||
return_value=(True, "Success")
|
|
||||||
)
|
|
||||||
manager.permission_manager.validate_permissions = Mock(return_value=False)
|
manager.permission_manager.validate_permissions = Mock(return_value=False)
|
||||||
|
|
||||||
success, message = manager.enter_edit_mode()
|
success, message = manager.enter_edit_mode()
|
||||||
|
@ -254,7 +253,7 @@ class TestHostsManager:
|
||||||
assert "Cannot write to hosts file" in message
|
assert "Cannot write to hosts file" in message
|
||||||
assert not manager.edit_mode
|
assert not manager.edit_mode
|
||||||
|
|
||||||
@patch("src.hosts.core.manager.HostsManager._create_backup")
|
@patch('src.hosts.core.manager.HostsManager._create_backup')
|
||||||
def test_enter_edit_mode_backup_failure(self, mock_backup):
|
def test_enter_edit_mode_backup_failure(self, mock_backup):
|
||||||
"""Test entering edit mode with backup failure."""
|
"""Test entering edit mode with backup failure."""
|
||||||
mock_backup.side_effect = Exception("Backup failed")
|
mock_backup.side_effect = Exception("Backup failed")
|
||||||
|
@ -263,9 +262,7 @@ class TestHostsManager:
|
||||||
manager = HostsManager(temp_file.name)
|
manager = HostsManager(temp_file.name)
|
||||||
|
|
||||||
# Mock permission manager
|
# Mock permission manager
|
||||||
manager.permission_manager.request_sudo = Mock(
|
manager.permission_manager.request_sudo = Mock(return_value=(True, "Success"))
|
||||||
return_value=(True, "Success")
|
|
||||||
)
|
|
||||||
manager.permission_manager.validate_permissions = Mock(return_value=True)
|
manager.permission_manager.validate_permissions = Mock(return_value=True)
|
||||||
|
|
||||||
success, message = manager.enter_edit_mode()
|
success, message = manager.enter_edit_mode()
|
||||||
|
@ -310,9 +307,7 @@ class TestHostsManager:
|
||||||
manager.edit_mode = True
|
manager.edit_mode = True
|
||||||
|
|
||||||
# Mock permission manager to raise exception
|
# Mock permission manager to raise exception
|
||||||
manager.permission_manager.release_sudo = Mock(
|
manager.permission_manager.release_sudo = Mock(side_effect=Exception("Test error"))
|
||||||
side_effect=Exception("Test error")
|
|
||||||
)
|
|
||||||
|
|
||||||
success, message = manager.exit_edit_mode()
|
success, message = manager.exit_edit_mode()
|
||||||
|
|
||||||
|
@ -326,9 +321,7 @@ class TestHostsManager:
|
||||||
manager.edit_mode = True
|
manager.edit_mode = True
|
||||||
|
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
entry = HostEntry(
|
entry = HostEntry("192.168.1.1", ["router"], is_active=True) # Non-default entry
|
||||||
"192.168.1.1", ["router"], is_active=True
|
|
||||||
) # Non-default entry
|
|
||||||
hosts_file.entries.append(entry)
|
hosts_file.entries.append(entry)
|
||||||
|
|
||||||
success, message = manager.toggle_entry(hosts_file, 0)
|
success, message = manager.toggle_entry(hosts_file, 0)
|
||||||
|
@ -456,9 +449,7 @@ class TestHostsManager:
|
||||||
manager.edit_mode = True
|
manager.edit_mode = True
|
||||||
|
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
entry = HostEntry(
|
entry = HostEntry("127.0.0.1", ["localhost"]) # Default entry - cannot be modified
|
||||||
"127.0.0.1", ["localhost"]
|
|
||||||
) # Default entry - cannot be modified
|
|
||||||
hosts_file.entries.append(entry)
|
hosts_file.entries.append(entry)
|
||||||
|
|
||||||
success, message = manager.update_entry(
|
success, message = manager.update_entry(
|
||||||
|
@ -468,9 +459,9 @@ class TestHostsManager:
|
||||||
assert not success
|
assert not success
|
||||||
assert "Cannot modify default system entries" in message
|
assert "Cannot modify default system entries" in message
|
||||||
|
|
||||||
@patch("tempfile.NamedTemporaryFile")
|
@patch('tempfile.NamedTemporaryFile')
|
||||||
@patch("subprocess.run")
|
@patch('subprocess.run')
|
||||||
@patch("os.unlink")
|
@patch('os.unlink')
|
||||||
def test_save_hosts_file_success(self, mock_unlink, mock_run, mock_temp):
|
def test_save_hosts_file_success(self, mock_unlink, mock_run, mock_temp):
|
||||||
"""Test saving hosts file successfully."""
|
"""Test saving hosts file successfully."""
|
||||||
# Mock temporary file
|
# Mock temporary file
|
||||||
|
@ -526,7 +517,7 @@ class TestHostsManager:
|
||||||
assert not success
|
assert not success
|
||||||
assert "No sudo permissions" in message
|
assert "No sudo permissions" in message
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch('subprocess.run')
|
||||||
def test_restore_backup_success(self, mock_run):
|
def test_restore_backup_success(self, mock_run):
|
||||||
"""Test restoring backup successfully."""
|
"""Test restoring backup successfully."""
|
||||||
mock_run.return_value = Mock(returncode=0)
|
mock_run.return_value = Mock(returncode=0)
|
||||||
|
@ -572,16 +563,16 @@ class TestHostsManager:
|
||||||
assert not success
|
assert not success
|
||||||
assert "No backup available" in message
|
assert "No backup available" in message
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch('subprocess.run')
|
||||||
@patch("tempfile.gettempdir")
|
@patch('tempfile.gettempdir')
|
||||||
@patch("time.time")
|
@patch('time.time')
|
||||||
def test_create_backup_success(self, mock_time, mock_tempdir, mock_run):
|
def test_create_backup_success(self, mock_time, mock_tempdir, mock_run):
|
||||||
"""Test creating backup successfully."""
|
"""Test creating backup successfully."""
|
||||||
mock_time.return_value = 1234567890
|
mock_time.return_value = 1234567890
|
||||||
mock_tempdir.return_value = "/tmp"
|
mock_tempdir.return_value = "/tmp"
|
||||||
mock_run.side_effect = [
|
mock_run.side_effect = [
|
||||||
Mock(returncode=0), # cp command
|
Mock(returncode=0), # cp command
|
||||||
Mock(returncode=0), # chmod command
|
Mock(returncode=0) # chmod command
|
||||||
]
|
]
|
||||||
|
|
||||||
# Create a real temporary file for testing
|
# Create a real temporary file for testing
|
||||||
|
@ -600,7 +591,7 @@ class TestHostsManager:
|
||||||
# Clean up
|
# Clean up
|
||||||
Path(temp_path).unlink()
|
Path(temp_path).unlink()
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch('subprocess.run')
|
||||||
def test_create_backup_failure(self, mock_run):
|
def test_create_backup_failure(self, mock_run):
|
||||||
"""Test creating backup with failure."""
|
"""Test creating backup with failure."""
|
||||||
mock_run.return_value = Mock(returncode=1, stderr="Permission denied")
|
mock_run.return_value = Mock(returncode=1, stderr="Permission denied")
|
||||||
|
|
|
@ -26,14 +26,16 @@ class TestHostEntry:
|
||||||
entry = HostEntry(
|
entry = HostEntry(
|
||||||
ip_address="192.168.1.1",
|
ip_address="192.168.1.1",
|
||||||
hostnames=["router", "gateway"],
|
hostnames=["router", "gateway"],
|
||||||
comment="Local router",
|
comment="Local router"
|
||||||
)
|
)
|
||||||
assert entry.comment == "Local router"
|
assert entry.comment == "Local router"
|
||||||
|
|
||||||
def test_host_entry_inactive(self):
|
def test_host_entry_inactive(self):
|
||||||
"""Test inactive host entry creation."""
|
"""Test inactive host entry creation."""
|
||||||
entry = HostEntry(
|
entry = HostEntry(
|
||||||
ip_address="10.0.0.1", hostnames=["test.local"], is_active=False
|
ip_address="10.0.0.1",
|
||||||
|
hostnames=["test.local"],
|
||||||
|
is_active=False
|
||||||
)
|
)
|
||||||
assert entry.is_active is False
|
assert entry.is_active is False
|
||||||
|
|
||||||
|
@ -60,7 +62,9 @@ class TestHostEntry:
|
||||||
def test_to_hosts_line_active(self):
|
def test_to_hosts_line_active(self):
|
||||||
"""Test conversion to hosts file line format for active entry."""
|
"""Test conversion to hosts file line format for active entry."""
|
||||||
entry = HostEntry(
|
entry = HostEntry(
|
||||||
ip_address="127.0.0.1", hostnames=["localhost", "local"], comment="Loopback"
|
ip_address="127.0.0.1",
|
||||||
|
hostnames=["localhost", "local"],
|
||||||
|
comment="Loopback"
|
||||||
)
|
)
|
||||||
line = entry.to_hosts_line()
|
line = entry.to_hosts_line()
|
||||||
assert line == "127.0.0.1\tlocalhost\tlocal\t# Loopback"
|
assert line == "127.0.0.1\tlocalhost\tlocal\t# Loopback"
|
||||||
|
@ -68,7 +72,9 @@ class TestHostEntry:
|
||||||
def test_to_hosts_line_inactive(self):
|
def test_to_hosts_line_inactive(self):
|
||||||
"""Test conversion to hosts file line format for inactive entry."""
|
"""Test conversion to hosts file line format for inactive entry."""
|
||||||
entry = HostEntry(
|
entry = HostEntry(
|
||||||
ip_address="192.168.1.1", hostnames=["router"], is_active=False
|
ip_address="192.168.1.1",
|
||||||
|
hostnames=["router"],
|
||||||
|
is_active=False
|
||||||
)
|
)
|
||||||
line = entry.to_hosts_line()
|
line = entry.to_hosts_line()
|
||||||
assert line == "# 192.168.1.1\trouter"
|
assert line == "# 192.168.1.1\trouter"
|
||||||
|
@ -188,7 +194,9 @@ class TestHostsFile:
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
inactive_entry = HostEntry(
|
inactive_entry = HostEntry(
|
||||||
ip_address="192.168.1.1", hostnames=["router"], is_active=False
|
ip_address="192.168.1.1",
|
||||||
|
hostnames=["router"],
|
||||||
|
is_active=False
|
||||||
)
|
)
|
||||||
|
|
||||||
hosts_file.add_entry(active_entry)
|
hosts_file.add_entry(active_entry)
|
||||||
|
@ -203,7 +211,9 @@ class TestHostsFile:
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
inactive_entry = HostEntry(
|
inactive_entry = HostEntry(
|
||||||
ip_address="192.168.1.1", hostnames=["router"], is_active=False
|
ip_address="192.168.1.1",
|
||||||
|
hostnames=["router"],
|
||||||
|
is_active=False
|
||||||
)
|
)
|
||||||
|
|
||||||
hosts_file.add_entry(active_entry)
|
hosts_file.add_entry(active_entry)
|
||||||
|
@ -217,9 +227,7 @@ class TestHostsFile:
|
||||||
"""Test sorting entries by IP address with default entries on top."""
|
"""Test sorting entries by IP address with default entries on top."""
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
|
entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
|
||||||
entry2 = HostEntry(
|
entry2 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) # Default entry
|
||||||
ip_address="127.0.0.1", hostnames=["localhost"]
|
|
||||||
) # Default entry
|
|
||||||
entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["test"])
|
entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["test"])
|
||||||
|
|
||||||
hosts_file.add_entry(entry1)
|
hosts_file.add_entry(entry1)
|
||||||
|
@ -230,9 +238,7 @@ class TestHostsFile:
|
||||||
|
|
||||||
# Default entries should come first, then sorted non-default entries
|
# Default entries should come first, then sorted non-default entries
|
||||||
assert hosts_file.entries[0].ip_address == "127.0.0.1" # Default entry first
|
assert hosts_file.entries[0].ip_address == "127.0.0.1" # Default entry first
|
||||||
assert (
|
assert hosts_file.entries[1].ip_address == "10.0.0.1" # Then sorted non-defaults
|
||||||
hosts_file.entries[1].ip_address == "10.0.0.1"
|
|
||||||
) # Then sorted non-defaults
|
|
||||||
assert hosts_file.entries[2].ip_address == "192.168.1.1"
|
assert hosts_file.entries[2].ip_address == "192.168.1.1"
|
||||||
|
|
||||||
def test_sort_by_hostname(self):
|
def test_sort_by_hostname(self):
|
||||||
|
|
|
@ -33,7 +33,7 @@ class TestHostsParser:
|
||||||
192.168.1.1 router
|
192.168.1.1 router
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|
||||||
|
@ -70,7 +70,7 @@ class TestHostsParser:
|
||||||
# Footer comment
|
# Footer comment
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|
||||||
|
@ -114,7 +114,7 @@ class TestHostsParser:
|
||||||
|
|
||||||
def test_parse_empty_file(self):
|
def test_parse_empty_file(self):
|
||||||
"""Test parsing an empty hosts file."""
|
"""Test parsing an empty hosts file."""
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
f.write("")
|
f.write("")
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|
||||||
|
@ -134,7 +134,7 @@ class TestHostsParser:
|
||||||
# Yet another comment
|
# Yet another comment
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|
||||||
|
@ -185,9 +185,15 @@ class TestHostsParser:
|
||||||
hosts_file.footer_comments = ["Footer comment"]
|
hosts_file.footer_comments = ["Footer comment"]
|
||||||
|
|
||||||
entry1 = HostEntry(
|
entry1 = HostEntry(
|
||||||
ip_address="127.0.0.1", hostnames=["localhost"], comment="Loopback"
|
ip_address="127.0.0.1",
|
||||||
|
hostnames=["localhost"],
|
||||||
|
comment="Loopback"
|
||||||
|
)
|
||||||
|
entry2 = HostEntry(
|
||||||
|
ip_address="10.0.0.1",
|
||||||
|
hostnames=["test"],
|
||||||
|
is_active=False
|
||||||
)
|
)
|
||||||
entry2 = HostEntry(ip_address="10.0.0.1", hostnames=["test"], is_active=False)
|
|
||||||
|
|
||||||
hosts_file.add_entry(entry1)
|
hosts_file.add_entry(entry1)
|
||||||
hosts_file.add_entry(entry2)
|
hosts_file.add_entry(entry2)
|
||||||
|
@ -230,7 +236,7 @@ class TestHostsParser:
|
||||||
parser.write(hosts_file, backup=False)
|
parser.write(hosts_file, backup=False)
|
||||||
|
|
||||||
# Read back and verify
|
# Read back and verify
|
||||||
with open(f.name, "r") as read_file:
|
with open(f.name, 'r') as read_file:
|
||||||
content = read_file.read()
|
content = read_file.read()
|
||||||
expected = """# #
|
expected = """# #
|
||||||
# Host Database
|
# Host Database
|
||||||
|
@ -248,7 +254,7 @@ class TestHostsParser:
|
||||||
# Create initial file
|
# Create initial file
|
||||||
initial_content = "192.168.1.1 router\n"
|
initial_content = "192.168.1.1 router\n"
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
f.write(initial_content)
|
f.write(initial_content)
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|
||||||
|
@ -261,16 +267,16 @@ class TestHostsParser:
|
||||||
parser.write(hosts_file, backup=True)
|
parser.write(hosts_file, backup=True)
|
||||||
|
|
||||||
# Check that backup was created
|
# Check that backup was created
|
||||||
backup_path = Path(f.name).with_suffix(".bak")
|
backup_path = Path(f.name).with_suffix('.bak')
|
||||||
assert backup_path.exists()
|
assert backup_path.exists()
|
||||||
|
|
||||||
# Check backup content
|
# Check backup content
|
||||||
with open(backup_path, "r") as backup_file:
|
with open(backup_path, 'r') as backup_file:
|
||||||
backup_content = backup_file.read()
|
backup_content = backup_file.read()
|
||||||
assert backup_content == initial_content
|
assert backup_content == initial_content
|
||||||
|
|
||||||
# Check new content
|
# Check new content
|
||||||
with open(f.name, "r") as new_file:
|
with open(f.name, 'r') as new_file:
|
||||||
new_content = new_file.read()
|
new_content = new_file.read()
|
||||||
expected = """# #
|
expected = """# #
|
||||||
# Host Database
|
# Host Database
|
||||||
|
@ -307,19 +313,19 @@ class TestHostsParser:
|
||||||
"""Test getting file information."""
|
"""Test getting file information."""
|
||||||
content = "127.0.0.1 localhost\n"
|
content = "127.0.0.1 localhost\n"
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|
||||||
parser = HostsParser(f.name)
|
parser = HostsParser(f.name)
|
||||||
info = parser.get_file_info()
|
info = parser.get_file_info()
|
||||||
|
|
||||||
assert info["path"] == f.name
|
assert info['path'] == f.name
|
||||||
assert info["exists"] is True
|
assert info['exists'] is True
|
||||||
assert info["readable"] is True
|
assert info['readable'] is True
|
||||||
assert info["size"] == len(content)
|
assert info['size'] == len(content)
|
||||||
assert info["modified"] is not None
|
assert info['modified'] is not None
|
||||||
assert isinstance(info["modified"], float)
|
assert isinstance(info['modified'], float)
|
||||||
|
|
||||||
os.unlink(f.name)
|
os.unlink(f.name)
|
||||||
|
|
||||||
|
@ -328,12 +334,12 @@ class TestHostsParser:
|
||||||
parser = HostsParser("/nonexistent/path")
|
parser = HostsParser("/nonexistent/path")
|
||||||
info = parser.get_file_info()
|
info = parser.get_file_info()
|
||||||
|
|
||||||
assert info["path"] == "/nonexistent/path"
|
assert info['path'] == "/nonexistent/path"
|
||||||
assert info["exists"] is False
|
assert info['exists'] is False
|
||||||
assert info["readable"] is False
|
assert info['readable'] is False
|
||||||
assert info["writable"] is False
|
assert info['writable'] is False
|
||||||
assert info["size"] == 0
|
assert info['size'] == 0
|
||||||
assert info["modified"] is None
|
assert info['modified'] is None
|
||||||
|
|
||||||
def test_round_trip_parsing(self):
|
def test_round_trip_parsing(self):
|
||||||
"""Test that parsing and serializing preserves content."""
|
"""Test that parsing and serializing preserves content."""
|
||||||
|
@ -348,7 +354,7 @@ class TestHostsParser:
|
||||||
# End of file
|
# End of file
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
f.write(original_content)
|
f.write(original_content)
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|
||||||
|
@ -359,7 +365,7 @@ class TestHostsParser:
|
||||||
# Write back and read
|
# Write back and read
|
||||||
parser.write(hosts_file, backup=False)
|
parser.write(hosts_file, backup=False)
|
||||||
|
|
||||||
with open(f.name, "r") as read_file:
|
with open(f.name, 'r') as read_file:
|
||||||
final_content = read_file.read()
|
final_content = read_file.read()
|
||||||
|
|
||||||
# The content should be functionally equivalent
|
# The content should be functionally equivalent
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue