first commit
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""Parser for clrmamepro (.dat) files.
|
||||
|
||||
Handles the No-Intro / Redump text DAT format::
|
||||
|
||||
clrmamepro (
|
||||
name "Sony - PlayStation Portable"
|
||||
version "2021.10.24"
|
||||
)
|
||||
game (
|
||||
name "WipEout Pure (Europe)"
|
||||
region "Europe"
|
||||
serial "ULES-00011"
|
||||
rom ( name "WipEout Pure (Europe).iso" size 123 crc ABCD1234 md5 ... sha1 ... )
|
||||
)
|
||||
|
||||
``parse_dat_text`` returns ``(header, entries)``. Each entry is a dict with
|
||||
scalar keys (``name``, ``description``, ``region``, ``serial``, ...) and
|
||||
repeated nested blocks exposed as lists under their block key (``rom``).
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_TOKEN_RE = re.compile(
|
||||
r"""
|
||||
(?P<ws>\s+)
|
||||
| (?P<lparen>\()
|
||||
| (?P<rparen>\))
|
||||
| (?P<string>"(?:\\.|[^"\\])*")
|
||||
| (?P<ident>[^"\s()]+)
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
class _Token:
|
||||
__slots__ = ("kind", "value")
|
||||
|
||||
def __init__(self, kind, value):
|
||||
self.kind = kind
|
||||
self.value = value
|
||||
|
||||
|
||||
def _tokenize(text):
|
||||
tokens = []
|
||||
for match in _TOKEN_RE.finditer(text):
|
||||
kind = match.lastgroup
|
||||
value = match.group()
|
||||
if kind == "ws":
|
||||
continue
|
||||
if kind == "string":
|
||||
value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
|
||||
kind = "string"
|
||||
elif kind == "lparen":
|
||||
kind = "lparen"
|
||||
value = "("
|
||||
elif kind == "rparen":
|
||||
kind = "rparen"
|
||||
value = ")"
|
||||
else:
|
||||
kind = "ident"
|
||||
tokens.append(_Token(kind, value))
|
||||
return tokens
|
||||
|
||||
|
||||
class _Parser:
|
||||
def __init__(self, tokens):
|
||||
self._tokens = tokens
|
||||
self._pos = 0
|
||||
|
||||
def _peek(self):
|
||||
return self._tokens[self._pos] if self._pos < len(self._tokens) else None
|
||||
|
||||
def _next(self):
|
||||
token = self._peek()
|
||||
self._pos += 1
|
||||
return token
|
||||
|
||||
def parse_block(self, name=None):
|
||||
if name is None:
|
||||
name_token = self._next()
|
||||
if name_token is None or name_token.kind != "ident":
|
||||
raise ValueError(f"expected block name at token {self._pos}")
|
||||
name = name_token.value
|
||||
lparen = self._next()
|
||||
if lparen is None or lparen.kind != "lparen":
|
||||
raise ValueError(f"expected '(' after '{name}'")
|
||||
|
||||
block = {"_type": name}
|
||||
while True:
|
||||
token = self._peek()
|
||||
if token is None:
|
||||
raise ValueError(f"unexpected EOF inside block '{name}'")
|
||||
if token.kind == "rparen":
|
||||
self._next()
|
||||
break
|
||||
if token.kind != "ident":
|
||||
raise ValueError(f"unexpected token '{token.value}' in block '{name}'")
|
||||
key = self._next().value
|
||||
nxt = self._peek()
|
||||
if nxt is not None and nxt.kind == "lparen":
|
||||
child = self.parse_block(name=key)
|
||||
block.setdefault(key, []).append(child)
|
||||
else:
|
||||
value = self._next()
|
||||
if value is None:
|
||||
raise ValueError(f"expected value for '{key}' in block '{name}'")
|
||||
block[key] = value.value
|
||||
return block
|
||||
|
||||
|
||||
def parse_dat_text(text):
|
||||
tokens = _tokenize(text)
|
||||
parser = _Parser(tokens)
|
||||
header = None
|
||||
entries = []
|
||||
while parser._peek() is not None:
|
||||
block = parser.parse_block()
|
||||
kind = block.get("_type")
|
||||
if kind == "clrmamepro":
|
||||
header = block
|
||||
elif kind in ("game", "machine"):
|
||||
entries.append(block)
|
||||
# ignore any other top-level blocks
|
||||
return header, entries
|
||||
|
||||
|
||||
def parse_dat_file(path):
|
||||
return parse_dat_text(Path(path).read_text(encoding="utf-8-sig"))
|
||||
Reference in New Issue
Block a user