sots-re/save-reader/ref/parsers/flat_kv.py

127 lines
4.2 KiB
Python

"""flat_kv.py -- readers for the flat 'KEY value' tuning tables and the
whitespace-positional tables under Data/, Weapons/, Badges/, Avatars/, GUI/.
Two shapes exist:
parse_kv(text) KEY value -> {KEY: value}
one constant per line; value is a bareword or a
"quoted string"; '//' comments; colors are quoted
"r g b" (use color()). Files: Data/globals.txt,
Data/species.txt, Data/Strategy/StrategyVars.txt,
Data/Combat/*.txt (most), Data/encounters.txt, ...
parse_rows(text) tok tok tok ... -> [[tok, ...], ...]
one record per line, whitespace separated, quoted
tokens may contain spaces; '//' comments. Files:
Weapons/_turrets.txt, Weapons/_defaultweapons.txt,
Data/Combat/damfx*.txt, Data/Strategy/playercolors.txt,
Badges/BadgeTable.txt, Avatars/AvatarTable.txt,
GUI/WeaponIconPlacements.txt
Quirks handled: '//' inside a quoted value is not a comment; a quoted
value may be empty (""); keys repeat in a few files (kept as list);
duplicate-key detection is exposed via parse_kv(..., on_dup=).
Stdlib only.
"""
from __future__ import annotations
import re
from typing import Any
from mars_data import coerce
__all__ = ["parse_kv", "parse_rows", "duplicates", "color", "strip_comment",
"split_tokens", "parse_kv_file", "parse_rows_file"]
_TOK_RE = re.compile(r'"([^"]*)"|(\S+)')
def strip_comment(line: str) -> str:
"""Remove a trailing // comment, ignoring // inside double quotes."""
in_q = False
i = 0
n = len(line)
while i < n:
c = line[i]
if c == '"':
in_q = not in_q
elif c == "/" and not in_q and line.startswith("//", i):
return line[:i]
i += 1
return line
def split_tokens(line: str) -> list[tuple[str, bool]]:
"""Split a line into (token, was_quoted) pairs."""
out = []
for m in _TOK_RE.finditer(line):
if m.group(1) is not None:
out.append((m.group(1), True))
else:
out.append((m.group(2), False))
return out
def parse_rows(text: str, *, typed: bool = True) -> list[list[Any]]:
rows = []
for raw in text.splitlines():
line = strip_comment(raw).strip()
if not line:
continue
toks = split_tokens(line)
rows.append([coerce(t) if (typed and not q) else t for t, q in toks])
return rows
def _pairs(text: str, typed: bool):
for lineno, raw in enumerate(text.splitlines(), 1):
line = strip_comment(raw).strip()
if not line:
continue
toks = split_tokens(line)
key = toks[0][0]
vals = [coerce(t) if (typed and not q) else t for t, q in toks[1:]]
val: Any = None if not vals else (vals[0] if len(vals) == 1 else vals)
yield lineno, key, val
def parse_kv(text: str, *, typed: bool = True, on_dup: str = "last") -> dict:
"""KEY value per line -> dict. A value made of several unquoted tokens
is kept as a list. on_dup: 'last' (later line wins), 'first', 'error'.
Use duplicates() to find repeated keys."""
d: dict = {}
for lineno, key, val in _pairs(text, typed):
if key in d:
if on_dup == "error":
raise ValueError(f"line {lineno}: duplicate key {key}")
if on_dup == "first":
continue
d[key] = val
return d
def duplicates(text: str) -> dict[str, list[int]]:
"""key -> line numbers, for keys that appear more than once."""
seen: dict[str, list[int]] = {}
for lineno, key, _ in _pairs(text, False):
seen.setdefault(key, []).append(lineno)
return {k: v for k, v in seen.items() if len(v) > 1}
def color(value: str) -> tuple:
"""'r g b' or 'r g b a' -> tuple of numbers."""
return tuple(coerce(t) for t in value.split())
def _read(path) -> str:
with open(path, "rb") as f:
return f.read().decode("cp1252")
def parse_kv_file(path, **kw) -> dict:
return parse_kv(_read(path), **kw)
def parse_rows_file(path, **kw) -> list:
return parse_rows(_read(path), **kw)