Enhance hosts file serialization and entry formatting for proper tab alignment; update tests to reflect changes.

This commit is contained in:
Philip Henning 2025-07-29 23:31:30 +02:00
parent cead0c1066
commit 5a2e0d2623
4 changed files with 111 additions and 32 deletions

View file

@ -80,29 +80,72 @@ class HostEntry:
if not hostname_pattern.match(hostname): if not hostname_pattern.match(hostname):
raise ValueError(f"Invalid hostname '{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: Returns:
String representation suitable for writing to hosts file String representation suitable for writing to hosts file
""" """
line_parts = [] line_parts = []
# Add comment prefix if inactive # Build the IP address part (with comment prefix if inactive)
ip_part = ""
if not self.is_active: if not self.is_active:
line_parts.append("#") ip_part = "# "
ip_part += self.ip_address
# Add IP and hostnames # Calculate tabs needed for IP column alignment
line_parts.append(self.ip_address) ip_tabs = self._calculate_tabs_needed(len(ip_part), ip_width)
line_parts.extend(self.hostnames)
# 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 # Add comment if present
if self.comment: 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}") 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 @classmethod
def from_hosts_line(cls, line: str) -> Optional['HostEntry']: def from_hosts_line(cls, line: str) -> Optional['HostEntry']:
""" """
@ -128,8 +171,10 @@ class HostEntry:
if not line or line.startswith('#'): if not line or line.startswith('#'):
return None return None
# Split line into parts # Split line into parts, handling both spaces and tabs
parts = line.split() import re
# Split on any whitespace (spaces, tabs, or combinations)
parts = re.split(r'\s+', line.strip())
if len(parts) < 2: if len(parts) < 2:
return None return None

View file

@ -82,13 +82,13 @@ class HostsParser:
def serialize(self, hosts_file: HostsFile) -> str: 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: Args:
hosts_file: HostsFile object to serialize hosts_file: HostsFile object to serialize
Returns: Returns:
String representation of the hosts file String representation of the hosts file with tab-aligned columns
""" """
lines = [] lines = []
@ -99,11 +99,13 @@ class HostsParser:
lines.append(f"# {comment}") lines.append(f"# {comment}")
else: else:
lines.append("#") 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: 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 # Add footer comments
if hosts_file.footer_comments: if hosts_file.footer_comments:
@ -116,6 +118,39 @@ class HostsParser:
return "\n".join(lines) + "\n" 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: def write(self, hosts_file: HostsFile, backup: bool = True) -> None:
""" """
Write a HostsFile object to the hosts file. Write a HostsFile object to the hosts file.

View file

@ -67,7 +67,7 @@ class TestHostEntry:
comment="Loopback" comment="Loopback"
) )
line = entry.to_hosts_line() 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): 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."""
@ -77,7 +77,7 @@ class TestHostEntry:
is_active=False is_active=False
) )
line = entry.to_hosts_line() 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): def test_from_hosts_line_simple(self):
"""Test parsing simple hosts file line.""" """Test parsing simple hosts file line."""

View file

@ -168,8 +168,8 @@ class TestHostsParser:
parser = HostsParser() parser = HostsParser()
content = parser.serialize(hosts_file) content = parser.serialize(hosts_file)
expected = """127.0.0.1 localhost expected = """127.0.0.1\tlocalhost
192.168.1.1 router 192.168.1.1\trouter
""" """
assert content == expected assert content == expected
@ -198,9 +198,8 @@ class TestHostsParser:
expected = """# Header comment 1 expected = """# Header comment 1
# Header comment 2 # Header comment 2
127.0.0.1\tlocalhost\t# Loopback
127.0.0.1 localhost # Loopback # 10.0.0.1\ttest
# 10.0.0.1 test
# Footer comment # Footer comment
""" """
@ -227,7 +226,7 @@ class TestHostsParser:
# 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()
assert content == "127.0.0.1 localhost\n" assert content == "127.0.0.1\tlocalhost\n"
os.unlink(f.name) os.unlink(f.name)
@ -260,7 +259,7 @@ class TestHostsParser:
# 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()
assert new_content == "127.0.0.1 localhost\n" assert new_content == "127.0.0.1\tlocalhost\n"
# Cleanup # Cleanup
os.unlink(backup_path) os.unlink(backup_path)
@ -344,10 +343,10 @@ class TestHostsParser:
final_content = read_file.read() final_content = read_file.read()
# The content should be functionally equivalent # The content should be functionally equivalent
# (though formatting might differ slightly) # (though formatting might differ slightly with tabs)
assert "127.0.0.1 localhost loopback # Local loopback" in final_content assert "127.0.0.1\tlocalhost\tloopback\t# Local loopback" in final_content
assert "::1 localhost # IPv6 loopback" in final_content assert "::1\t\tlocalhost\t# IPv6 loopback" in final_content
assert "192.168.1.1 router gateway # Local router" in final_content assert "192.168.1.1\trouter\t\tgateway\t# Local router" in final_content
assert "# 10.0.0.1 test.local # Test entry (disabled)" in final_content assert "# 10.0.0.1\ttest.local\t# Test entry (disabled)" in final_content
os.unlink(f.name) os.unlink(f.name)