mirror of
https://github.com/FireRedTeam/FireRed-OpenStoryline.git
synced 2026-09-01 14:57:19 +08:00
203 lines
5.9 KiB
Python
203 lines
5.9 KiB
Python
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()) |