sots-re/verify/parsers/manifest.py

121 lines
3.9 KiB
Python

"""manifest.py -- the numbered id manifests and the '#'-commented CSVs.
parse_manifest(text) -> Manifest
Weapons/_weapons.txt and Species/<Race>/sections/_shipsections.txt:
<int-id> <filename> one per line
// DELETED - <id> retired id (still reserved)
Ids are the persistent network / savegame ids. Filenames are matched
case-insensitively (the shipped manifests have 'DEWar.SHIPSECTION',
'CRAIC.Shipsection' etc. against lower-case files -- Windows FS).
parse_csv(text) -> list[list[str]]
Rows with '#' or '//' as first non-blank char are comments; blank rows
dropped; RFC-4180 quoting honoured (Strings.csv has one multi-line cell
and quoted commas). Header rows that start with '#' (aitechpri.csv,
"# species" in stock_diplomacy_messages.csv) are returned separately
via parse_csv_with_header().
Stdlib only.
"""
from __future__ import annotations
import csv
import io
import re
from dataclasses import dataclass, field
__all__ = ["Manifest", "parse_manifest", "parse_manifest_file",
"parse_csv", "parse_csv_file", "parse_csv_with_header", "read_text"]
_DELETED_RE = re.compile(r"//\s*DELETED\s*-\s*(\d+)", re.I)
_ENTRY_RE = re.compile(r"^\s*(\d+)\s+(\S+)\s*$")
@dataclass
class Manifest:
entries: list[tuple[int, str]] = field(default_factory=list) # (id, filename)
deleted: list[int] = field(default_factory=list)
problems: list[str] = field(default_factory=list)
def by_id(self) -> dict[int, str]:
return dict(self.entries)
def by_name(self) -> dict[str, int]:
"""lower-cased filename -> id"""
return {n.lower(): i for i, n in self.entries}
def parse_manifest(text: str) -> Manifest:
m = Manifest()
seen: dict[int, int] = {}
for lineno, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line:
continue
d = _DELETED_RE.search(line)
if d:
m.deleted.append(int(d.group(1)))
continue
if line.startswith("//"):
continue
e = _ENTRY_RE.match(line)
if not e:
m.problems.append(f"line {lineno}: unrecognised {line!r}")
continue
i, name = int(e.group(1)), e.group(2)
if i in seen:
m.problems.append(f"line {lineno}: duplicate id {i} (first at line {seen[i]})")
seen[i] = lineno
m.entries.append((i, name))
for i in m.deleted:
if i in seen:
m.problems.append(f"id {i} is both DELETED and assigned")
return m
def read_text(path) -> str:
with open(path, "rb") as f:
return f.read().decode("cp1252")
def parse_manifest_file(path) -> Manifest:
return parse_manifest(read_text(path))
def _is_comment(row: list[str]) -> bool:
if not row:
return True
first = row[0].lstrip()
if first.startswith("#") or first.startswith("//"):
return True
return all(c.strip() == "" for c in row)
def parse_csv(text: str, *, strip: bool = True) -> list[list[str]]:
rows = []
for row in csv.reader(io.StringIO(text, newline="")):
if _is_comment(row):
continue
rows.append([c.strip() for c in row] if strip else row)
return rows
def parse_csv_with_header(text: str) -> tuple[list[str] | None, list[list[str]]]:
"""Return (header, rows). Header = the first '#'-prefixed row that
contains a comma (e.g. '# <tech>,<human-pri>,...'), with the '#' and
any '<>' stripped; None when there is no such row."""
header = None
for row in csv.reader(io.StringIO(text, newline="")):
if not row:
continue
first = row[0].lstrip()
if first.startswith("#") and len(row) > 1:
header = [c.strip().lstrip("#").strip().strip("<>") for c in row]
break
if not _is_comment(row):
break
return header, parse_csv(text)
def parse_csv_file(path, **kw):
return parse_csv(read_text(path), **kw)