121 lines
3.9 KiB
Python
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)
|