Enhance hosts file serialization and entry formatting for proper tab alignment; update tests to reflect changes.
This commit is contained in:
		
							parent
							
								
									cead0c1066
								
							
						
					
					
						commit
						5a2e0d2623
					
				
					 4 changed files with 111 additions and 32 deletions
				
			
		| 
						 | 
				
			
			@ -80,28 +80,71 @@ class HostEntry:
 | 
			
		|||
            if not hostname_pattern.match(hostname):
 | 
			
		||||
                raise ValueError(f"Invalid hostname '{hostname}'")
 | 
			
		||||
 | 
			
		||||
    def to_hosts_line(self) -> str:
 | 
			
		||||
    def to_hosts_line(self, ip_width: int = 0, hostname_width: int = 0) -> str:
 | 
			
		||||
        """
 | 
			
		||||
        Convert this entry to a hosts file line.
 | 
			
		||||
        Convert this entry to a hosts file line with proper tab alignment.
 | 
			
		||||
        
 | 
			
		||||
        Args:
 | 
			
		||||
            ip_width: Width of the IP address column for alignment
 | 
			
		||||
            hostname_width: Width of the canonical hostname column for alignment
 | 
			
		||||
        
 | 
			
		||||
        Returns:
 | 
			
		||||
            String representation suitable for writing to hosts file
 | 
			
		||||
        """
 | 
			
		||||
        line_parts = []
 | 
			
		||||
        
 | 
			
		||||
        # Add comment prefix if inactive
 | 
			
		||||
        # Build the IP address part (with comment prefix if inactive)
 | 
			
		||||
        ip_part = ""
 | 
			
		||||
        if not self.is_active:
 | 
			
		||||
            line_parts.append("#")
 | 
			
		||||
            ip_part = "# "
 | 
			
		||||
        ip_part += self.ip_address
 | 
			
		||||
        
 | 
			
		||||
        # Add IP and hostnames
 | 
			
		||||
        line_parts.append(self.ip_address)
 | 
			
		||||
        line_parts.extend(self.hostnames)
 | 
			
		||||
        # Calculate tabs needed for IP column alignment
 | 
			
		||||
        ip_tabs = self._calculate_tabs_needed(len(ip_part), ip_width)
 | 
			
		||||
        
 | 
			
		||||
        # Build the canonical hostname part
 | 
			
		||||
        canonical_hostname = self.hostnames[0] if self.hostnames else ""
 | 
			
		||||
        hostname_tabs = self._calculate_tabs_needed(len(canonical_hostname), hostname_width)
 | 
			
		||||
        
 | 
			
		||||
        # Start building the line
 | 
			
		||||
        line_parts.append(ip_part)
 | 
			
		||||
        line_parts.append("\t" * max(1, ip_tabs))  # At least one tab
 | 
			
		||||
        line_parts.append(canonical_hostname)
 | 
			
		||||
        
 | 
			
		||||
        # Add additional hostnames (aliases) with single tab separation
 | 
			
		||||
        if len(self.hostnames) > 1:
 | 
			
		||||
            line_parts.append("\t" * max(1, hostname_tabs))
 | 
			
		||||
            line_parts.append("\t".join(self.hostnames[1:]))
 | 
			
		||||
        
 | 
			
		||||
        # Add comment if present
 | 
			
		||||
        if self.comment:
 | 
			
		||||
            if len(self.hostnames) <= 1:
 | 
			
		||||
                line_parts.append("\t" * max(1, hostname_tabs))
 | 
			
		||||
            else:
 | 
			
		||||
                line_parts.append("\t")
 | 
			
		||||
            line_parts.append(f"# {self.comment}")
 | 
			
		||||
        
 | 
			
		||||
        return " ".join(line_parts)
 | 
			
		||||
        return "".join(line_parts)
 | 
			
		||||
    
 | 
			
		||||
    def _calculate_tabs_needed(self, current_length: int, target_width: int) -> int:
 | 
			
		||||
        """
 | 
			
		||||
        Calculate number of tabs needed to reach target column width.
 | 
			
		||||
        
 | 
			
		||||
        Args:
 | 
			
		||||
            current_length: Current string length
 | 
			
		||||
            target_width: Target column width
 | 
			
		||||
            
 | 
			
		||||
        Returns:
 | 
			
		||||
            Number of tabs needed (minimum 1)
 | 
			
		||||
        """
 | 
			
		||||
        if target_width <= current_length:
 | 
			
		||||
            return 1
 | 
			
		||||
        
 | 
			
		||||
        # Calculate tabs needed (assuming tab width of 8)
 | 
			
		||||
        tab_width = 8
 | 
			
		||||
        remaining_space = target_width - current_length
 | 
			
		||||
        tabs_needed = (remaining_space + tab_width - 1) // tab_width  # Ceiling division
 | 
			
		||||
        return max(1, tabs_needed)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def from_hosts_line(cls, line: str) -> Optional['HostEntry']:
 | 
			
		||||
| 
						 | 
				
			
			@ -128,8 +171,10 @@ class HostEntry:
 | 
			
		|||
        if not line or line.startswith('#'):
 | 
			
		||||
            return None
 | 
			
		||||
        
 | 
			
		||||
        # Split line into parts
 | 
			
		||||
        parts = line.split()
 | 
			
		||||
        # Split line into parts, handling both spaces and tabs
 | 
			
		||||
        import re
 | 
			
		||||
        # Split on any whitespace (spaces, tabs, or combinations)
 | 
			
		||||
        parts = re.split(r'\s+', line.strip())
 | 
			
		||||
        if len(parts) < 2:
 | 
			
		||||
            return None
 | 
			
		||||
        
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -82,13 +82,13 @@ class HostsParser:
 | 
			
		|||
    
 | 
			
		||||
    def serialize(self, hosts_file: HostsFile) -> str:
 | 
			
		||||
        """
 | 
			
		||||
        Convert a HostsFile object back to hosts file format.
 | 
			
		||||
        Convert a HostsFile object back to hosts file format with proper column alignment.
 | 
			
		||||
        
 | 
			
		||||
        Args:
 | 
			
		||||
            hosts_file: HostsFile object to serialize
 | 
			
		||||
            
 | 
			
		||||
        Returns:
 | 
			
		||||
            String representation of the hosts file
 | 
			
		||||
            String representation of the hosts file with tab-aligned columns
 | 
			
		||||
        """
 | 
			
		||||
        lines = []
 | 
			
		||||
        
 | 
			
		||||
| 
						 | 
				
			
			@ -99,11 +99,13 @@ class HostsParser:
 | 
			
		|||
                    lines.append(f"# {comment}")
 | 
			
		||||
                else:
 | 
			
		||||
                    lines.append("#")
 | 
			
		||||
            lines.append("")  # Blank line after header
 | 
			
		||||
        
 | 
			
		||||
        # Add host entries
 | 
			
		||||
        # Calculate column widths for proper alignment
 | 
			
		||||
        ip_width, hostname_width = self._calculate_column_widths(hosts_file.entries)
 | 
			
		||||
        
 | 
			
		||||
        # Add host entries with proper column alignment
 | 
			
		||||
        for entry in hosts_file.entries:
 | 
			
		||||
            lines.append(entry.to_hosts_line())
 | 
			
		||||
            lines.append(entry.to_hosts_line(ip_width, hostname_width))
 | 
			
		||||
        
 | 
			
		||||
        # Add footer comments
 | 
			
		||||
        if hosts_file.footer_comments:
 | 
			
		||||
| 
						 | 
				
			
			@ -116,6 +118,39 @@ class HostsParser:
 | 
			
		|||
        
 | 
			
		||||
        return "\n".join(lines) + "\n"
 | 
			
		||||
    
 | 
			
		||||
    def _calculate_column_widths(self, entries: list) -> tuple[int, int]:
 | 
			
		||||
        """
 | 
			
		||||
        Calculate the maximum width needed for IP and hostname columns.
 | 
			
		||||
        
 | 
			
		||||
        Args:
 | 
			
		||||
            entries: List of HostEntry objects
 | 
			
		||||
            
 | 
			
		||||
        Returns:
 | 
			
		||||
            Tuple of (ip_width, hostname_width)
 | 
			
		||||
        """
 | 
			
		||||
        max_ip_width = 0
 | 
			
		||||
        max_hostname_width = 0
 | 
			
		||||
        
 | 
			
		||||
        for entry in entries:
 | 
			
		||||
            # Calculate IP column width (including comment prefix for inactive entries)
 | 
			
		||||
            ip_part = ""
 | 
			
		||||
            if not entry.is_active:
 | 
			
		||||
                ip_part = "# "
 | 
			
		||||
            ip_part += entry.ip_address
 | 
			
		||||
            max_ip_width = max(max_ip_width, len(ip_part))
 | 
			
		||||
            
 | 
			
		||||
            # Calculate canonical hostname width
 | 
			
		||||
            if entry.hostnames:
 | 
			
		||||
                canonical_hostname = entry.hostnames[0]
 | 
			
		||||
                max_hostname_width = max(max_hostname_width, len(canonical_hostname))
 | 
			
		||||
        
 | 
			
		||||
        # Round up to next tab stop (8-character boundaries) for better alignment
 | 
			
		||||
        tab_width = 8
 | 
			
		||||
        ip_width = ((max_ip_width + tab_width - 1) // tab_width) * tab_width
 | 
			
		||||
        hostname_width = ((max_hostname_width + tab_width - 1) // tab_width) * tab_width
 | 
			
		||||
        
 | 
			
		||||
        return ip_width, hostname_width
 | 
			
		||||
    
 | 
			
		||||
    def write(self, hosts_file: HostsFile, backup: bool = True) -> None:
 | 
			
		||||
        """
 | 
			
		||||
        Write a HostsFile object to the hosts file.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -67,7 +67,7 @@ class TestHostEntry:
 | 
			
		|||
            comment="Loopback"
 | 
			
		||||
        )
 | 
			
		||||
        line = entry.to_hosts_line()
 | 
			
		||||
        assert line == "127.0.0.1 localhost local # Loopback"
 | 
			
		||||
        assert line == "127.0.0.1\tlocalhost\tlocal\t# Loopback"
 | 
			
		||||
    
 | 
			
		||||
    def test_to_hosts_line_inactive(self):
 | 
			
		||||
        """Test conversion to hosts file line format for inactive entry."""
 | 
			
		||||
| 
						 | 
				
			
			@ -77,7 +77,7 @@ class TestHostEntry:
 | 
			
		|||
            is_active=False
 | 
			
		||||
        )
 | 
			
		||||
        line = entry.to_hosts_line()
 | 
			
		||||
        assert line == "# 192.168.1.1 router"
 | 
			
		||||
        assert line == "# 192.168.1.1\trouter"
 | 
			
		||||
    
 | 
			
		||||
    def test_from_hosts_line_simple(self):
 | 
			
		||||
        """Test parsing simple hosts file line."""
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -161,15 +161,15 @@ class TestHostsParser:
 | 
			
		|||
        hosts_file = HostsFile()
 | 
			
		||||
        entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
 | 
			
		||||
        entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        hosts_file.add_entry(entry1)
 | 
			
		||||
        hosts_file.add_entry(entry2)
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        parser = HostsParser()
 | 
			
		||||
        content = parser.serialize(hosts_file)
 | 
			
		||||
        
 | 
			
		||||
        expected = """127.0.0.1 localhost
 | 
			
		||||
192.168.1.1 router
 | 
			
		||||
 | 
			
		||||
        expected = """127.0.0.1\tlocalhost
 | 
			
		||||
192.168.1.1\trouter
 | 
			
		||||
"""
 | 
			
		||||
        assert content == expected
 | 
			
		||||
    
 | 
			
		||||
| 
						 | 
				
			
			@ -198,9 +198,8 @@ class TestHostsParser:
 | 
			
		|||
        
 | 
			
		||||
        expected = """# Header comment 1
 | 
			
		||||
# Header comment 2
 | 
			
		||||
 | 
			
		||||
127.0.0.1 localhost # Loopback
 | 
			
		||||
# 10.0.0.1 test
 | 
			
		||||
127.0.0.1\tlocalhost\t# Loopback
 | 
			
		||||
# 10.0.0.1\ttest
 | 
			
		||||
 | 
			
		||||
# Footer comment
 | 
			
		||||
"""
 | 
			
		||||
| 
						 | 
				
			
			@ -227,7 +226,7 @@ class TestHostsParser:
 | 
			
		|||
            # Read back and verify
 | 
			
		||||
            with open(f.name, 'r') as read_file:
 | 
			
		||||
                content = read_file.read()
 | 
			
		||||
                assert content == "127.0.0.1 localhost\n"
 | 
			
		||||
                assert content == "127.0.0.1\tlocalhost\n"
 | 
			
		||||
        
 | 
			
		||||
        os.unlink(f.name)
 | 
			
		||||
    
 | 
			
		||||
| 
						 | 
				
			
			@ -260,7 +259,7 @@ class TestHostsParser:
 | 
			
		|||
            # Check new content
 | 
			
		||||
            with open(f.name, 'r') as new_file:
 | 
			
		||||
                new_content = new_file.read()
 | 
			
		||||
                assert new_content == "127.0.0.1 localhost\n"
 | 
			
		||||
                assert new_content == "127.0.0.1\tlocalhost\n"
 | 
			
		||||
            
 | 
			
		||||
            # Cleanup
 | 
			
		||||
            os.unlink(backup_path)
 | 
			
		||||
| 
						 | 
				
			
			@ -344,10 +343,10 @@ class TestHostsParser:
 | 
			
		|||
                final_content = read_file.read()
 | 
			
		||||
            
 | 
			
		||||
            # The content should be functionally equivalent
 | 
			
		||||
            # (though formatting might differ slightly)
 | 
			
		||||
            assert "127.0.0.1 localhost loopback # Local loopback" in final_content
 | 
			
		||||
            assert "::1 localhost # IPv6 loopback" in final_content
 | 
			
		||||
            assert "192.168.1.1 router gateway # Local router" in final_content
 | 
			
		||||
            assert "# 10.0.0.1 test.local # Test entry (disabled)" in final_content
 | 
			
		||||
            # (though formatting might differ slightly with tabs)
 | 
			
		||||
            assert "127.0.0.1\tlocalhost\tloopback\t# Local loopback" in final_content
 | 
			
		||||
            assert "::1\t\tlocalhost\t# IPv6 loopback" in final_content
 | 
			
		||||
            assert "192.168.1.1\trouter\t\tgateway\t# Local router" in final_content
 | 
			
		||||
            assert "# 10.0.0.1\ttest.local\t# Test entry (disabled)" in final_content
 | 
			
		||||
        
 | 
			
		||||
        os.unlink(f.name)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue