"""Translate the subset of Python used by visual novels into PGVN IR. Expressions become JSON trees ``{"k": kind, ...}``; statements become ``{"op": ..., ...}`` nodes executed by the player's JS interpreter. Anything outside the subset raises :class:`Unsupported` so the caller can report it and degrade gracefully. """ import ast from typing import Any Expr = dict[str, Any] Stmt = dict[str, Any] BIN_OPS = { ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/", ast.FloorDiv: "//", ast.Mod: "%", ast.Pow: "**", ast.BitAnd: "&", ast.BitOr: "|", ast.BitXor: "^", ast.LShift: "<<", ast.RShift: ">>", } CMP_OPS = { ast.Eq: "==", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "<=", ast.Gt: ">", ast.GtE: ">=", ast.In: "in", ast.NotIn: "not in", ast.Is: "is", ast.IsNot: "is not", } UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"} # Modules whose import is harmless: the player provides the functions it supports. KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections", "itertools", "string", "functools", "operator", "json", "gc", "hashlib", "uuid", "pygame", "colorsys", "fnmatch", "traceback", "builtins"} class Unsupported(Exception): pass def const(v: Any) -> Expr: return {"k": "const", "v": v} def python2_operators(source: str) -> str: """Ren'Py 7 games are Python 2: `a <> b` means `a != b`. Quoted text is left alone.""" out: list[str] = [] quote = "" i = 0 while i < len(source): ch = source[i] if quote: out.append(ch) if ch == "\\" and i + 1 < len(source): out.append(source[i + 1]) i += 1 elif ch == quote: quote = "" elif ch in "\"'": quote = ch out.append(ch) elif source.startswith("<>", i): out.append("!=") i += 1 else: out.append(ch) i += 1 return "".join(out) def parse_expr(source: str) -> Expr: try: tree = ast.parse(source.strip(), mode="eval") except SyntaxError as e: # a long expression continued on the next lines (`A | B`, `FontGroup().add(...)`) needs brackets for candidate in (python2_operators(source), "(" + source.strip() + ")"): try: tree = ast.parse(candidate.strip(), mode="eval") break except SyntaxError: continue else: raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e return expr(tree.body) def parse_stmts(source: str) -> list[Stmt]: try: tree = python_ast(source) except SyntaxError as e: try: tree = python_ast(python2_operators(source)) except SyntaxError: raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e return stmts(tree.body) def python_ast(source: str, mode: str = "exec") -> ast.AST: """Parse an embedded block as Ren'Py does, preserving string contents/columns. Physical continuation lines may start at column zero. Wrapping the indented code avoids dedenting those lines or changing whitespace inside literals. """ first = next((line for line in source.splitlines() if line.strip()), "") indented = mode == "exec" and first.startswith((" ", "\t")) tree = ast.parse("if True:\n" + source if indented else source, mode=mode) if indented: tree.body = tree.body[0].body ast.increment_lineno(tree, -1) return tree def literal(source: str) -> Any: """Evaluate a constant expression at conversion time, or raise Unsupported.""" try: return ast.literal_eval(source.strip()) except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError) as e: raise Unsupported(f"не константа: {source!r}") from e def expr(node: ast.AST) -> Expr: match node: case ast.Constant(value=v): if isinstance(v, bytes): v = v.decode("utf-8", "replace") if isinstance(v, complex) or v is Ellipsis: raise Unsupported("неподдерживаемая константа") return const(v) case ast.Name(id=name): return {"k": "name", "id": name} case ast.Attribute(value=ast.Name(id="store"), attr=attr): return {"k": "name", "id": attr} case ast.Attribute(value=ast.Attribute(value=ast.Name(id="renpy"), attr="store"), attr=attr): return {"k": "name", "id": attr} # renpy.store.x is the same variable as x case ast.Attribute(value=value, attr=attr): return {"k": "attr", "o": expr(value), "a": attr} case ast.Subscript(value=value, slice=ast.Slice(lower=lo, upper=hi, step=st)): return {"k": "slice", "o": expr(value), "lo": _opt(lo), "hi": _opt(hi), "st": _opt(st)} case ast.Subscript(value=value, slice=index): return {"k": "sub", "o": expr(value), "i": expr(index)} case ast.BinOp(left=l, op=op, right=r): if type(op) not in BIN_OPS: raise Unsupported(f"оператор {type(op).__name__}") return {"k": "bin", "op": BIN_OPS[type(op)], "l": expr(l), "r": expr(r)} case ast.BoolOp(op=op, values=values): return {"k": "bool", "op": "and" if isinstance(op, ast.And) else "or", "v": [expr(v) for v in values]} case ast.UnaryOp(op=op, operand=operand): return {"k": "unary", "op": UNARY_OPS[type(op)], "v": expr(operand)} case ast.Compare(left=l, ops=ops, comparators=rs): return {"k": "cmp", "l": expr(l), "ops": [CMP_OPS[type(o)] for o in ops], "r": [expr(r) for r in rs]} case ast.IfExp(test=t, body=b, orelse=e): return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)} case ast.List(elts=elts) | ast.Set(elts=elts): return {"k": "list", "v": [element(e) for e in elts]} case ast.Tuple(elts=elts): return {"k": "tuple", "v": [element(e) for e in elts]} case ast.Dict(keys=keys, values=values): # `{**other, "k": v}`: a spread entry is a star key with an empty value return {"k": "dict", "v": [[{"k": "star", "v": expr(v)}, {"k": "const", "v": None}] if k is None else [expr(k), expr(v)] for k, v in zip(keys, values)]} case ast.Call(func=func, args=args, keywords=kws): call = {"k": "call", "f": expr(func), "a": [{"k": "star", "v": expr(a.value)} if isinstance(a, ast.Starred) else expr(a) for a in args], "kw": {k.arg: expr(k.value) for k in kws if k.arg is not None}} if any(k.arg is None for k in kws): call["ks"] = [expr(k.value) for k in kws if k.arg is None] # **mapping, in order return call case ast.JoinedStr(values=values): parts: list[Any] = [] for v in values: if isinstance(v, ast.Constant): parts.append(v.value) elif isinstance(v, ast.FormattedValue): parts.append(expr(v.value)) return {"k": "fmt", "v": parts} case ast.ListComp(elt=elt, generators=gens) | ast.GeneratorExp(elt=elt, generators=gens) \ | ast.SetComp(elt=elt, generators=gens): return {"k": "comp", "e": expr(elt), "gens": generators(gens)} case ast.DictComp(key=key, value=value, generators=gens): return {"k": "comp", "e": expr(key), "v": expr(value), "gens": generators(gens)} case ast.Lambda(args=args, body=body): return {"k": "lambda", "params": params(args), "body": expr(body)} raise Unsupported(f"выражение {type(node).__name__}") def _opt(node: ast.AST | None) -> Expr | None: return None if node is None else expr(node) def target(node: ast.AST) -> Expr: match node: case ast.Name() | ast.Attribute() | ast.Subscript(): return expr(node) case ast.Tuple(elts=elts) | ast.List(elts=elts): return {"k": "tuple", "v": [target(e) for e in elts]} raise Unsupported(f"присваивание в {type(node).__name__}") def stmts(nodes: list[ast.stmt]) -> list[Stmt]: out: list[Stmt] = [] for n in nodes: out.extend(stmt(n)) return out def stmt(node: ast.stmt) -> list[Stmt]: match node: case ast.Assign(targets=targets, value=value): v = expr(value) return [{"op": "set", "t": target(t), "v": v} for t in targets] case ast.AnnAssign(target=t, value=value) if value is not None: return [{"op": "set", "t": target(t), "v": expr(value)}] case ast.AugAssign(target=t, op=op, value=value): if type(op) not in BIN_OPS: raise Unsupported(f"оператор {type(op).__name__}") return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}] case ast.Expr(value=ast.Call(func=func)) if ast.unparse(func).startswith("build."): return [] # distribution settings (build.classify, build.archive ...) have no meaning in the browser case ast.Expr(value=ast.Yield(value=value)): return [{"op": "yield", "v": None if value is None else expr(value)}] case ast.Expr(value=ast.YieldFrom(value=value)): return [{"op": "yield_from", "v": expr(value)}] case ast.Expr(value=ast.Constant()): return [] # docstring / bare literal case ast.Expr(value=value): return [{"op": "eval", "e": expr(value)}] case ast.If(test=test, body=body, orelse=orelse): return [{"op": "if", "c": expr(test), "then": stmts(body), "else": stmts(orelse)}] case ast.While(test=test, body=body, orelse=orelse): return [{"op": "while", "c": expr(test), "body": stmts(body), "else": stmts(orelse)}] case ast.For(target=t, iter=it, body=body, orelse=orelse): return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body), "else": stmts(orelse)}] case ast.Try(body=body, handlers=handlers, orelse=orelse, finalbody=final): return [{"op": "try", "body": stmts(body), "handlers": [handler(h) for h in handlers], "else": stmts(orelse), "final": stmts(final)}] case ast.Match(subject=subject, cases=cases): return match_statement(subject, cases) case ast.Raise(exc=exc): return [{"op": "raise", "v": None if exc is None else expr(exc)}] # `from cause` is dropped case ast.Assert(test=test, msg=msg): return [{"op": "assert", "c": expr(test), "m": None if msg is None else expr(msg)}] case ast.Delete(targets=targets): return [{"op": "del", "t": target(t)} for t in targets] case ast.Pass(): return [] case ast.Break(): return [{"op": "break"}] case ast.Continue(): return [{"op": "continue"}] case ast.Import(names=names) | ast.ImportFrom(names=names): mod = getattr(node, "module", None) or names[0].name if mod.split(".")[0] in KNOWN_MODULES: # `import time as t` / `from renpy.text.text import Text as RenpyText`: the new name is the old one return [{"op": "set", "t": {"k": "name", "id": alias.asname}, "v": {"k": "name", "id": alias.name.split(".")[0] if isinstance(node, ast.Import) else alias.name}} for alias in names if alias.asname and alias.asname != alias.name] raise Unsupported(f"импорт модуля {mod}") case ast.FunctionDef(decorator_list=decos) if any( ast.unparse(d).split("(")[0] in INACTIVE_VARIANTS for d in decos): return [] # screen-size/touch variants never apply to the desktop browser player case ast.FunctionDef(name=name, args=args, body=body, decorator_list=decos): decos = [d for d in decos if ast.unparse(d) not in TRANSPARENT_DECORATORS] node = {"op": "def", "name": name, "params": params(args), "body": function_body(body), "decos": [decorator(d) for d in decos]} if is_generator(body): node["gen"] = True return [node] case ast.ClassDef(name=name, bases=bases, keywords=[], body=body, decorator_list=decos) if all(ast.unparse(d) in TRANSPARENT_DECORATORS for d in decos): return [{"op": "class", "name": name, "bases": [expr(b) for b in bases], "body": class_body(name, body)}] case ast.ClassDef(name=name): raise Unsupported(f"определение класса {name} с декораторами или метаклассом") case ast.Return(value=value): return [{"op": "return", "v": None if value is None else expr(value)}] case ast.Global(names=names): return [{"op": "global", "names": list(names)}] case ast.Nonlocal(names=names): return [{"op": "nonlocal", "names": list(names)}] raise Unsupported(f"оператор {type(node).__name__}") # Methods the player can run implicitly. Any other dunder method would silently # never be called, so the converter refuses it instead. # Pickle hooks only matter for Ren'Py's own save files; the player keeps its own IGNORED_DUNDERS = {"__setstate__", "__getstate__", "__reduce__", "__reduce_ex__"} SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__", "__getitem__", "__contains__"} INACTIVE_VARIANTS = {"gui.variant", "renpy.variant"} TRANSPARENT_DECORATORS = {"renpy.pure"} # marks a function as side-effect free; no runtime meaning DECORATORS = {"staticmethod", "classmethod", "property"} def params(args: ast.arguments) -> list[dict[str, Any]]: """Parameter list: kind ``p`` positional/keyword, ``k`` keyword-only, ``*`` and ``**`` collectors.""" out: list[dict[str, Any]] = [] positional = [*args.posonlyargs, *args.args] defaults = [None] * (len(positional) - len(args.defaults)) + list(args.defaults) for a, d in zip(positional, defaults): out.append({"n": a.arg, "d": None if d is None else expr(d), "kind": "p"}) if args.vararg: out.append({"n": args.vararg.arg, "d": None, "kind": "*"}) for a, d in zip(args.kwonlyargs, args.kw_defaults): out.append({"n": a.arg, "d": None if d is None else expr(d), "kind": "k"}) if args.kwarg: out.append({"n": args.kwarg.arg, "d": None, "kind": "**"}) return out def decorator(node: ast.expr) -> Expr: match node: case ast.Name(id=name) if name in DECORATORS: return {"k": "name", "id": name} case ast.Attribute(value=ast.Name(), attr="setter"): return expr(node) raise Unsupported(f"декоратор {ast.unparse(node)}") def is_generator(body: list[ast.stmt]) -> bool: """A function with `yield` statements. The player runs it to the end and hands out what it yielded, which is right for generators that end; `x = yield` (sending values in) and async code stay unsupported.""" module = ast.Module(body=body, type_ignores=[]) statement_yields = {id(n.value) for n in ast.walk(module) if isinstance(n, ast.Expr) and isinstance(n.value, (ast.Yield, ast.YieldFrom))} found = False for n in ast.walk(module): if isinstance(n, ast.Await): raise Unsupported("async") if isinstance(n, (ast.Yield, ast.YieldFrom)): if id(n) not in statement_yields: raise Unsupported("yield как выражение") found = True return found def function_body(body: list[ast.stmt]) -> list[Stmt]: is_generator(body) return stmts(body) def element(node: ast.expr) -> Expr: """One item of a list or tuple; `*rest` spreads a sequence into it.""" return {"k": "star", "v": expr(node.value)} if isinstance(node, ast.Starred) else expr(node) def class_body(name: str, body: list[ast.stmt]) -> list[Stmt]: for n in body: if (isinstance(n, ast.FunctionDef) and n.name.startswith("__") and n.name.endswith("__") and n.name not in SUPPORTED_DUNDERS | IGNORED_DUNDERS): raise Unsupported(f"специальный метод {name}.{n.name}()") return stmts([n for n in body if not (isinstance(n, ast.FunctionDef) and n.name in IGNORED_DUNDERS)]) def generators(gens: list[ast.comprehension]) -> list[dict[str, Any]]: if any(g.is_async for g in gens): raise Unsupported("async comprehension") return [{"t": target(g.target), "it": expr(g.iter), "if": [expr(i) for i in g.ifs]} for g in gens] def handler(node: ast.ExceptHandler) -> dict[str, Any]: """``types`` is None for a bare ``except:``; ``name`` binds the exception.""" return {"types": None if node.type is None else expr(node.type), "name": node.name, "body": stmts(node.body)} _match_counter = 0 def match_statement(subject: ast.expr, cases: list[ast.match_case]) -> list[Stmt]: """`match` becomes an if/elif chain over a temporary holding the subject.""" global _match_counter _match_counter += 1 temp = f"_pgm_{_match_counter}" name = {"k": "name", "id": temp} def pattern(p: ast.pattern) -> tuple[Expr, list[Stmt]]: match p: case ast.MatchValue(value=value): return {"k": "cmp", "l": name, "ops": ["=="], "r": [expr(value)]}, [] case ast.MatchSingleton(value=value): return {"k": "cmp", "l": name, "ops": ["is"], "r": [const(value)]}, [] case ast.MatchAs(pattern=None, name=None): return const(True), [] case ast.MatchAs(pattern=None, name=capture): return const(True), [{"op": "set", "t": {"k": "name", "id": capture}, "v": name}] case ast.MatchAs(pattern=inner, name=capture): cond, binds = pattern(inner) if capture: binds = [*binds, {"op": "set", "t": {"k": "name", "id": capture}, "v": name}] return cond, binds case ast.MatchOr(patterns=alternatives): parts = [pattern(a) for a in alternatives] if any(binds for _, binds in parts): raise Unsupported("match: захват внутри альтернативы") return {"k": "bool", "op": "or", "v": [c for c, _ in parts]}, [] raise Unsupported(f"match: шаблон {type(p).__name__}") chain: list[Stmt] = [] for case in reversed(cases): cond, binds = pattern(case.pattern) if case.guard is not None: if binds: raise Unsupported("match: захват вместе с условием guard") cond = {"k": "bool", "op": "and", "v": [cond, expr(case.guard)]} chain = [{"op": "if", "c": cond, "then": [*binds, *stmts(case.body)], "else": chain}] return [{"op": "set", "t": name, "v": expr(subject)}, *chain]