| from __future__ import annotations |
|
|
| import argparse |
| import re |
| import sys |
| import tomllib |
| from pathlib import Path |
| from typing import Any |
|
|
| ROOT_DIR = Path(__file__).resolve().parent.parent |
| DEFAULT_CONFIG_PATH = ROOT_DIR / "config.toml" |
|
|
| class ConfigUpdateError(Exception): |
| pass |
|
|
| SECTION_RE = re.compile(r"^\s*\[(.+?)\]\s*(?:#.*)?$") |
|
|
|
|
| def parse_assignment(raw: str) -> tuple[str, str]: |
| if "=" not in raw: |
| raise ConfigUpdateError("--set must be in KEY=VALUE format.") |
| key, value = raw.split("=", 1) |
| key = key.strip() |
| if not key: |
| raise ConfigUpdateError("KEY cannot be empty.") |
| return key, value |
|
|
|
|
| def split_path(path: str) -> list[str]: |
| parts = [p.strip() for p in path.split(".")] |
| if not parts or any(not p for p in parts): |
| raise ConfigUpdateError(f"Invalid path: {path}") |
| return parts |
|
|
|
|
| def parse_section_header(line: str) -> list[str] | None: |
| m = SECTION_RE.match(line) |
| if not m: |
| return None |
| return split_path(m.group(1)) |
|
|
|
|
| def get_existing_value(data: dict[str, Any], parts: list[str]) -> Any: |
| cur: Any = data |
| for part in parts: |
| if not isinstance(cur, dict) or part not in cur: |
| raise ConfigUpdateError(f"Configuration item does not exist: {'.'.join(parts)}") |
| cur = cur[part] |
| return cur |
|
|
|
|
| def coerce_value(raw: str, old_value: Any) -> Any: |
| if isinstance(old_value, bool): |
| v = raw.strip().lower() |
| if v in {"true", "1", "yes", "on"}: |
| return True |
| if v in {"false", "0", "no", "off"}: |
| return False |
| raise ConfigUpdateError("Boolean values must be one of: true, false, 1, 0, yes, no, on, or off.") |
|
|
| if isinstance(old_value, int) and not isinstance(old_value, bool): |
| try: |
| return int(raw.strip()) |
| except ValueError as e: |
| raise ConfigUpdateError(f"Expected an integer, got: {raw!r}") from e |
|
|
| if isinstance(old_value, float): |
| try: |
| return float(raw.strip()) |
| except ValueError as e: |
| raise ConfigUpdateError(f"Expected an float, got: {raw!r}") from e |
|
|
| if isinstance(old_value, str): |
| return raw |
|
|
| raise ConfigUpdateError( |
| f"Only scalar values can be modified (str, int, float, bool); current type: {type(old_value).__name__}" |
| ) |
|
|
|
|
| def toml_escape_string(value: str) -> str: |
| value = ( |
| value.replace("\\", "\\\\") |
| .replace('"', '\\"') |
| .replace("\b", "\\b") |
| .replace("\t", "\\t") |
| .replace("\n", "\\n") |
| .replace("\f", "\\f") |
| .replace("\r", "\\r") |
| ) |
| return f'"{value}"' |
|
|
|
|
| def render_toml_value(value: Any) -> str: |
| if isinstance(value, bool): |
| return "true" if value else "false" |
| if isinstance(value, int) and not isinstance(value, bool): |
| return str(value) |
| if isinstance(value, float): |
| return str(value) |
| if isinstance(value, str): |
| return toml_escape_string(value) |
| raise ConfigUpdateError(f"Unsupported value type: {type(value).__name__}") |
|
|
|
|
| def split_value_and_comment(text: str) -> tuple[str, str]: |
| quote: str | None = None |
| escape = False |
|
|
| for i, ch in enumerate(text): |
| if quote is not None: |
| if escape: |
| escape = False |
| elif ch == "\\": |
| escape = True |
| elif ch == quote: |
| quote = None |
| continue |
|
|
| if ch in {'"', "'"}: |
| quote = ch |
| elif ch == "#": |
| return text[:i].rstrip(), text[i:] |
|
|
| return text.rstrip(), "" |
|
|
|
|
| def update_text(text: str, parts: list[str], new_value: Any) -> str: |
| section_parts = parts[:-1] |
| leaf = parts[-1] |
| key_re = re.compile( |
| rf"^(?P<prefix>\s*{re.escape(leaf)}\s*=\s*)(?P<body>.*?)(?P<newline>\r?\n?)$" |
| ) |
|
|
| lines = text.splitlines(keepends=True) |
| in_target_section = len(section_parts) == 0 |
| found_section = in_target_section |
|
|
| for i, line in enumerate(lines): |
| header = parse_section_header(line) |
| if header is not None: |
| in_target_section = header == section_parts |
| if in_target_section: |
| found_section = True |
| continue |
|
|
| if not in_target_section: |
| continue |
| if line.lstrip().startswith("#") or not line.strip(): |
| continue |
|
|
| m = key_re.match(line) |
| if not m: |
| continue |
|
|
| _, comment = split_value_and_comment(m.group("body")) |
| new_line = m.group("prefix") + render_toml_value(new_value) |
| if comment: |
| new_line += " " + comment.lstrip() |
| new_line += m.group("newline") |
| lines[i] = new_line |
| return "".join(lines) |
|
|
| if not found_section: |
| raise ConfigUpdateError(f"Configuration section not found: {'.'.join(section_parts)}") |
| raise ConfigUpdateError(f"Configuration item not found: {'.'.join(parts)}") |
|
|
|
|
| def main() -> int: |
| parser = argparse.ArgumentParser(description="Update one config item in config.toml") |
| parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_PATH) |
| parser.add_argument("--set", required=True, metavar="KEY=VALUE") |
| args = parser.parse_args() |
|
|
| config_path = args.config.resolve() |
| if not config_path.exists(): |
| print(f"Configuration file does not exist: {config_path}", file=sys.stderr) |
| return 2 |
|
|
| try: |
| key_path, raw_value = parse_assignment(args.set) |
| parts = split_path(key_path) |
|
|
| with config_path.open("rb") as f: |
| data = tomllib.load(f) |
|
|
| old_value = get_existing_value(data, parts) |
| new_value = coerce_value(raw_value, old_value) |
|
|
| text = config_path.read_text(encoding="utf-8") |
| new_text = update_text(text, parts, new_value) |
| config_path.write_text(new_text, encoding="utf-8") |
|
|
| print(f"Updated: {key_path} = {new_value!r}") |
| return 0 |
|
|
| except (ConfigUpdateError, tomllib.TOMLDecodeError) as e: |
| print(f"Update failed: {e}", file=sys.stderr) |
| return 2 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |