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

+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)