feat: support user-defined functions and classes in Ren'Py python

- Converter: def/return/global/nonlocal, class (inheritance, property,
  staticmethod, classmethod), lambda, *args/**kwargs and call-site unpacking.
  Unsupported dunder methods, generators and unknown decorators are still refused
- Player: closures, super(), instances that survive save/load, isinstance,
  hasattr/getattr/setattr, map/filter/sorted(key=), recursion limit
- audit_ir accepts calls to names, classes and methods the game defines itself;
  unknown renpy.* calls still block the import. Volume/pan/pause/replay calls
  are accepted as presentation-only no-ops; build.* settings are skipped
- Tests: IR fixture compiled from tests/fixtures/classes.py (tests/make_ir.py)
  and run in the interpreter; converter tests updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 12:01:09 +03:00
1 parent 12ec82c7e6
commit 962595c925
12 files changed
+782 -23

No files matched your search

+53 -2
View File
@@ -99,11 +99,60 @@ CALLS |= {"renpy." + method for method in ("play", "notify", "jump", "call", "in
"seen_label", "showing", "get_attributes", "full_restart", "quit", "version")}
CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
for method in ("play", "queue", "stop", "get_playing", "is_playing")}
# Presentation-only calls the player accepts and ignores (volume/pan/pause tweaks, replay and
# rollback bookkeeping). They never change the story state.
CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
for method in ("set_volume", "set_pause", "set_pan")}
CALLS |= {"renpy.end_replay", "renpy.block_rollback"}
CALLS |= {"map", "filter", "hasattr", "getattr", "setattr", "callable", "super"}
METHODS = set("append extend insert pop remove index count reverse sort clear copy add discard get keys values items setdefault update lower upper capitalize title strip lstrip rstrip startswith endswith replace split join format find isdigit isalpha".split())
def _bound_names(root) -> tuple[set[str], set[str]]:
"""Names the game binds itself and the method names of its classes.
Calling any of them is the game's own code, which the player interprets.
"""
names: set[str] = set()
methods: set[str] = set()
def target(value):
if isinstance(value, dict):
if value.get("k") == "name":
names.add(value["id"])
elif value.get("k") == "tuple":
for item in value["v"]:
target(item)
def visit(value, in_class=False):
if isinstance(value, list):
for item in value:
visit(item, in_class)
elif isinstance(value, dict):
op, kind = value.get("op"), value.get("k")
if op == "def":
(methods if in_class else names).add(value["name"])
names.update(p["n"] for p in value["params"])
elif op == "class":
names.add(value["name"])
elif op in ("set", "aug", "for"):
target(value.get("t"))
elif kind == "lambda":
names.update(p["n"] for p in value["params"])
elif kind == "comp":
target(value.get("t"))
for key, child in value.items():
visit(child, op == "class" and key == "body")
visit(root)
return names, methods
def audit_ir(data) -> None:
"""Reject calls which the browser would otherwise silently replace by None."""
user_names, user_methods = _bound_names([data.modules, data.init_code, data.defaults, data.images])
user_names |= {name for name, _ in data.defaults}
def dotted(value):
if value.get("k") == "name":
return value["id"]
@@ -119,8 +168,10 @@ def audit_ir(data) -> None:
if value.get("k") == "call":
function = value.get("f", {})
name = dotted(function)
if name not in CALLS and not (function.get("k") == "attr" and
function.get("a") in METHODS and not name.startswith(("renpy.", "math.", "random."))):
is_method = (function.get("k") == "attr" and not name.startswith(("renpy.", "math.", "random."))
and (function.get("a") in METHODS or function.get("a") in user_methods
or function.get("a") in user_names))
if name not in CALLS and name not in user_names and not is_method:
data.warn(f"Вызов не поддерживается плеером: {name or 'динамическая функция'}", location)
if value.get("op") in {"jump", "call"} and value.get("target"):
if value["target"] not in labels:
+70 -9
View File
@@ -105,10 +105,13 @@ def expr(node: ast.AST) -> Expr:
raise Unsupported("распаковка ** в словаре")
return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]}
case ast.Call(func=func, args=args, keywords=kws):
if any(isinstance(a, ast.Starred) for a in args) or any(k.arg is None for k in kws):
raise Unsupported("распаковка аргументов *args/**kwargs")
return {"k": "call", "f": expr(func), "a": [expr(a) for a in args],
"kw": {k.arg: expr(k.value) for k in 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:
@@ -125,8 +128,8 @@ def expr(node: ast.AST) -> Expr:
is_async=0)])):
return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it),
"if": [expr(i) for i in ifs]}
case ast.Lambda():
raise Unsupported("lambda")
case ast.Lambda(args=args, body=body):
return {"k": "lambda", "params": params(args), "body": expr(body)}
raise Unsupported(f"выражение {type(node).__name__}")
@@ -173,7 +176,7 @@ def stmt(node: ast.stmt) -> list[Stmt]:
return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}]
case ast.Delete(targets=targets):
return [{"op": "del", "t": target(t)} for t in targets]
case ast.Pass() | ast.Global() | ast.Nonlocal():
case ast.Pass():
return []
case ast.Break():
return [{"op": "break"}]
@@ -184,6 +187,64 @@ def stmt(node: ast.stmt) -> list[Stmt]:
if mod.split(".")[0] in KNOWN_MODULES:
return []
raise Unsupported(f"импорт модуля {mod}")
case ast.FunctionDef(name=name) | ast.ClassDef(name=name):
raise Unsupported(f"определение {name}()")
case ast.FunctionDef(name=name, args=args, body=body, decorator_list=decos):
return [{"op": "def", "name": name, "params": params(args), "body": function_body(body),
"decos": [decorator(d) for d in decos]}]
case ast.ClassDef(name=name, bases=bases, keywords=[], body=body, decorator_list=[]):
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.
SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__"}
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 function_body(body: list[ast.stmt]) -> list[Stmt]:
for n in ast.walk(ast.Module(body=body, type_ignores=[])):
if isinstance(n, (ast.Yield, ast.YieldFrom, ast.Await)):
raise Unsupported("генераторы и async")
return stmts(body)
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):
raise Unsupported(f"специальный метод {name}.{n.name}()")
return stmts(body)
+3
View File
@@ -373,6 +373,9 @@ class RpyParser:
except ValueError:
pass
continue
if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
and ast.unparse(node.value.func).startswith("build.")):
continue # distribution build settings (build.classify, build.name ...), no gameplay
try:
self.d.init_code.extend(pyexpr.stmt(node))
except Unsupported as e:
+37 -1
View File
@@ -69,10 +69,17 @@ def expression(value, depth=0):
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():
@@ -86,6 +93,16 @@ def expression(value, depth=0):
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")
@@ -95,7 +112,8 @@ def statements(code, depth=0):
op = s.get("op")
keys = {"set": ("t", "v"), "aug": ("t", "v"), "eval": ("e",),
"if": ("c",), "while": ("c",), "for": ("t", "it"),
"del": ("t",), "break": (), "continue": ()}
"del": ("t",), "break": (), "continue": (), "def": (), "class": (),
"return": (), "global": (), "nonlocal": ()}
if op not in keys:
raise ValueError(f"Неизвестный оператор AST: {op}")
for key in keys[op]:
@@ -105,6 +123,24 @@ def statements(code, depth=0):
statements(s.get("else"), depth + 1)
if op in ("while", "for"):
statements(s.get("body"), 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):