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