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