"""manifest.py -- the numbered id manifests and the '#'-commented CSVs. parse_manifest(text) -> Manifest Weapons/_weapons.txt and Species//sections/_shipsections.txt: one per line // DELETED - 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. '# ,,...'), 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)