113 lines
3.5 KiB
Python
113 lines
3.5 KiB
Python
"""Exact, verified INI value inspection and replacement."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
from typing import Any
|
|
|
|
from .filesystem import sha256_file
|
|
|
|
|
|
class IniError(ValueError):
|
|
"""Raised when an INI override cannot be applied exactly."""
|
|
|
|
|
|
def inspect_ini_value(path: Path, section: str, key: str) -> dict[str, Any]:
|
|
if path.is_symlink() or not path.is_file():
|
|
raise IniError(f"INI path is missing or unsafe: {path}")
|
|
text = _read_ini_text(path)
|
|
_, value = _find_ini_value(text, section, key)
|
|
state = path.stat()
|
|
return {
|
|
"path": str(path),
|
|
"section": section,
|
|
"key": key,
|
|
"value": value,
|
|
"sha256": sha256_file(path),
|
|
"size": state.st_size,
|
|
"mtime_ns": state.st_mtime_ns,
|
|
}
|
|
|
|
|
|
def override_ini_value(
|
|
path: Path,
|
|
section: str,
|
|
key: str,
|
|
expected: str,
|
|
value: str,
|
|
) -> dict[str, Any]:
|
|
before = inspect_ini_value(path, section, key)
|
|
if before["value"] != expected:
|
|
raise IniError(
|
|
f"INI value mismatch for [{section}] {key}: "
|
|
f"expected {expected!r}, found {before['value']!r}"
|
|
)
|
|
text = _read_ini_text(path)
|
|
line_index, _ = _find_ini_value(text, section, key)
|
|
lines = text.splitlines(keepends=True)
|
|
body = lines[line_index].rstrip("\r\n")
|
|
newline = lines[line_index][len(body) :]
|
|
left, separator, right = body.partition("=")
|
|
if not separator:
|
|
raise IniError(f"INI key has no equals separator: [{section}] {key}")
|
|
leading = right[: len(right) - len(right.lstrip())]
|
|
trailing = right[len(right.rstrip()) :]
|
|
lines[line_index] = f"{left}={leading}{value}{trailing}{newline}"
|
|
updated = "".join(lines)
|
|
if updated == text:
|
|
raise IniError(f"INI override made no change: [{section}] {key}")
|
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
if temporary.exists() or temporary.is_symlink():
|
|
raise IniError(f"INI temporary path already exists: {temporary}")
|
|
try:
|
|
with temporary.open("w", encoding="utf-8", newline="") as stream:
|
|
stream.write(updated)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
shutil.copystat(path, temporary, follow_symlinks=False)
|
|
temporary.replace(path)
|
|
finally:
|
|
if temporary.exists() or temporary.is_symlink():
|
|
temporary.unlink()
|
|
after = inspect_ini_value(path, section, key)
|
|
if after["value"] != value:
|
|
raise IniError(f"INI override verification failed: [{section}] {key}")
|
|
if after["sha256"] == before["sha256"]:
|
|
raise IniError(f"INI hash did not change: [{section}] {key}")
|
|
return {
|
|
"ok": True,
|
|
"before": before,
|
|
"after": after,
|
|
"expected": expected,
|
|
"value": value,
|
|
}
|
|
|
|
|
|
def _read_ini_text(path: Path) -> str:
|
|
with path.open("r", encoding="utf-8", newline="") as stream:
|
|
return stream.read()
|
|
|
|
|
|
def _find_ini_value(text: str, section: str, key: str) -> tuple[int, str]:
|
|
current_section: str | None = None
|
|
matches: list[tuple[int, str]] = []
|
|
for index, line in enumerate(text.splitlines(keepends=True)):
|
|
stripped = line.strip()
|
|
if stripped.startswith("[") and stripped.endswith("]"):
|
|
current_section = stripped[1:-1].strip()
|
|
continue
|
|
if current_section is None or current_section.casefold() != section.casefold():
|
|
continue
|
|
if not stripped or stripped.startswith((";", "#")):
|
|
continue
|
|
left, separator, right = line.rstrip("\r\n").partition("=")
|
|
if separator and left.strip().casefold() == key.casefold():
|
|
matches.append((index, right.strip()))
|
|
if not matches:
|
|
raise IniError(f"INI key not found: [{section}] {key}")
|
|
if len(matches) != 1:
|
|
raise IniError(f"INI key is duplicated: [{section}] {key}")
|
|
return matches[0]
|