Files
pgames-manager/backend/app/pgvn.py
T
mrleo1nidandClaude Sonnet 5.5 ff82d882c0 feat: add ATL animation timelines and try/raise to the Ren'Py player
Python sandbox
- try/except/else/finally, raise (incl. re-raise), assert, for/while ... else
- Multi-generator and dict comprehensions
- Builtin exception hierarchy; user exceptions subclass it
- Inside try, missing keys/indexes, division by zero, attribute access on None
  and unknown names raise Python exceptions; lookups stay lenient elsewhere
- int()/float() raise ValueError on non-numeric strings

ATL
- converter/atl.py: set/move (linear, ease, easein, easeout), pause, repeat,
  parallel, choice, block, on show/hide and named transforms become a timeline
  shipped as game.json "atl"; Move() becomes a linear slide
- player/atl.ts: timeline runner wired into the renderer; `on hide` plays
  before the picture is removed
- function, time, contains, matrixcolor and expressions still block the import

Tests: exceptions/ATL fixtures and runtime tests, converter ATL tests; README
and ARCHITECTURE updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 12:11:07 +03:00

205 lines
8.8 KiB
Python

"""Validation of the expression/statement AST accepted by the web interpreter."""
def expression(value, depth=0):
if depth > 64 or not isinstance(value, dict):
raise ValueError("Некорректное PGVN-выражение")
kind = value.get("k")
def ex(key, optional=False):
v = value.get(key)
if not optional or v is not None:
expression(v, depth + 1)
def seq(key):
v = value.get(key)
if not isinstance(v, list):
raise ValueError(f"Выражение {kind}: нужен список {key}")
return v
if kind == "nani":
nani_expression(value.get("ast"), depth + 1)
elif kind == "const":
if "v" not in value:
raise ValueError("const без значения")
elif kind == "name":
if not isinstance(value.get("id"), str):
raise ValueError("Имя переменной должно быть строкой")
elif kind == "attr":
ex("o")
if not isinstance(value.get("a"), str):
raise ValueError("Атрибут должен быть строкой")
elif kind in ("sub", "slice"):
ex("o")
if kind == "sub":
ex("i")
else:
for key in ("lo", "hi", "st"):
ex(key, True)
elif kind in ("bin", "unary", "bool"):
from app.converter.pyexpr import BIN_OPS, UNARY_OPS
allowed = set(BIN_OPS.values()) if kind == "bin" else set(UNARY_OPS.values()) if kind == "unary" else {"and", "or"}
if value.get("op") not in allowed:
raise ValueError("Неизвестная операция выражения")
if kind == "bin":
ex("l")
ex("r")
elif kind == "unary":
ex("v")
else:
for item in seq("v"):
expression(item, depth + 1)
elif kind == "cmp":
from app.converter.pyexpr import CMP_OPS
ex("l")
operators, operands = seq("ops"), seq("r")
if len(operators) != len(operands) or any(op not in CMP_OPS.values() for op in operators):
raise ValueError("Некорректное сравнение")
for item in operands:
expression(item, depth + 1)
elif kind == "if":
for key in ("c", "t", "e"):
ex(key)
elif kind in ("list", "tuple", "fmt", "dict"):
for item in seq("v"):
if kind == "dict":
if not isinstance(item, list) or len(item) != 2:
raise ValueError("Некорректная запись словаря")
for part in item:
expression(part, depth + 1)
elif kind != "fmt" or not isinstance(item, str):
expression(item, depth + 1)
elif kind == "star":
ex("v")
elif kind == "lambda":
parameters(value.get("params"), depth + 1)
ex("body")
elif kind == "call":
ex("f")
for item in seq("a"):
expression(item, depth + 1)
for item in value.get("ks", []):
expression(item, depth + 1)
if not isinstance(value.get("kw"), dict):
raise ValueError("Некорректные аргументы вызова")
for item in value["kw"].values():
expression(item, depth + 1)
elif kind == "comp":
ex("e")
ex("v", True)
if "gens" in value:
gens = seq("gens")
if not gens or len(gens) > 8:
raise ValueError("Некорректный генератор")
for gen in gens:
if not isinstance(gen, dict) or not isinstance(gen.get("if"), list):
raise ValueError("Некорректный генератор")
expression(gen.get("t"), depth + 1)
expression(gen.get("it"), depth + 1)
for item in gen["if"]:
expression(item, depth + 1)
else: # saved by an older converter
for key in ("t", "it"):
ex(key)
for item in seq("if"):
expression(item, depth + 1)
else:
raise ValueError(f"Неизвестное выражение: {kind}")
def parameters(params, depth):
if not isinstance(params, list) or len(params) > 64:
raise ValueError("Некорректные параметры функции")
for p in params:
if not isinstance(p, dict) or not isinstance(p.get("n"), str) or p.get("kind") not in ("p", "k", "*", "**"):
raise ValueError("Некорректный параметр функции")
if p.get("d") is not None:
expression(p["d"], depth + 1)
def statements(code, depth=0):
if depth > 64 or not isinstance(code, list):
raise ValueError("Некорректный PGVN AST")
for s in code:
if not isinstance(s, dict):
raise ValueError("Некорректный оператор AST")
op = s.get("op")
keys = {"set": ("t", "v"), "aug": ("t", "v"), "eval": ("e",),
"if": ("c",), "while": ("c",), "for": ("t", "it"),
"del": ("t",), "break": (), "continue": (), "def": (), "class": (),
"return": (), "global": (), "nonlocal": (), "try": (), "raise": (),
"assert": ("c",)}
if op not in keys:
raise ValueError(f"Неизвестный оператор AST: {op}")
for key in keys[op]:
expression(s.get(key), depth + 1)
if op == "if":
statements(s.get("then"), depth + 1)
statements(s.get("else"), depth + 1)
if op in ("while", "for"):
statements(s.get("body"), depth + 1)
statements(s.get("else", []), depth + 1)
if op == "try":
statements(s.get("body"), depth + 1)
statements(s.get("else", []), depth + 1)
statements(s.get("final", []), depth + 1)
handlers = s.get("handlers")
if not isinstance(handlers, list) or len(handlers) > 32:
raise ValueError("Некорректные обработчики исключений")
for h in handlers:
if not isinstance(h, dict) or not (h.get("name") is None or isinstance(h.get("name"), str)):
raise ValueError("Некорректный обработчик исключения")
if h.get("types") is not None:
expression(h["types"], depth + 1)
statements(h.get("body"), depth + 1)
if op == "raise" and s.get("v") is not None:
expression(s["v"], depth + 1)
if op == "assert" and s.get("m") is not None:
expression(s["m"], depth + 1)
if op == "def":
if not isinstance(s.get("name"), str):
raise ValueError("У функции нет имени")
parameters(s.get("params"), depth + 1)
statements(s.get("body"), depth + 1)
for item in s.get("decos", []):
expression(item, depth + 1)
if op == "class":
if not isinstance(s.get("name"), str):
raise ValueError("У класса нет имени")
for item in s.get("bases", []):
expression(item, depth + 1)
statements(s.get("body"), depth + 1)
if op == "return" and s.get("v") is not None:
expression(s["v"], depth + 1)
if op in ("global", "nonlocal") and not (
isinstance(s.get("names"), list) and all(isinstance(n, str) for n in s["names"])):
raise ValueError("Некорректный список имён")
def nani_expression(node, depth=0):
"""Validate the optional Naninovel AST already supported by the shared player."""
if depth > 64 or not isinstance(node, list) or not node:
raise ValueError("Некорректный AST Naninovel")
kind = node[0]
if kind == "const" and len(node) == 2 and (node[1] is None or isinstance(node[1], (str, int, float, bool))):
return
if kind == "name" and len(node) == 2 and isinstance(node[1], str):
return
children = None
if kind == "unary" and len(node) == 3 and node[1] in ("!", "not", "+", "-", "~"):
children = node[2:]
elif kind == "bin" and len(node) == 4:
from app.converter.nani_expr import PRECEDENCE
if node[1] in PRECEDENCE:
children = node[2:]
elif kind == "if" and len(node) == 4:
children = node[1:]
elif kind == "call" and len(node) == 3:
from app.converter.nani_expr import FUNCTIONS
if node[1] in FUNCTIONS and isinstance(node[2], list):
children = node[2]
if children is None:
raise ValueError("Неизвестная операция AST Naninovel")
for child in children:
nani_expression(child, depth + 1)