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:
1 parent
12ec82c7e6
commit
962595c925
12 files changed
+782
-23
No files matched your search
@@ -99,11 +99,60 @@ CALLS |= {"renpy." + method for method in ("play", "notify", "jump", "call", "in
|
|||||||
"seen_label", "showing", "get_attributes", "full_restart", "quit", "version")}
|
"seen_label", "showing", "get_attributes", "full_restart", "quit", "version")}
|
||||||
CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
|
CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
|
||||||
for method in ("play", "queue", "stop", "get_playing", "is_playing")}
|
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())
|
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:
|
def audit_ir(data) -> None:
|
||||||
"""Reject calls which the browser would otherwise silently replace by 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):
|
def dotted(value):
|
||||||
if value.get("k") == "name":
|
if value.get("k") == "name":
|
||||||
return value["id"]
|
return value["id"]
|
||||||
@@ -119,8 +168,10 @@ def audit_ir(data) -> None:
|
|||||||
if value.get("k") == "call":
|
if value.get("k") == "call":
|
||||||
function = value.get("f", {})
|
function = value.get("f", {})
|
||||||
name = dotted(function)
|
name = dotted(function)
|
||||||
if name not in CALLS and not (function.get("k") == "attr" and
|
is_method = (function.get("k") == "attr" and not name.startswith(("renpy.", "math.", "random."))
|
||||||
function.get("a") in METHODS 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)
|
data.warn(f"Вызов не поддерживается плеером: {name or 'динамическая функция'}", location)
|
||||||
if value.get("op") in {"jump", "call"} and value.get("target"):
|
if value.get("op") in {"jump", "call"} and value.get("target"):
|
||||||
if value["target"] not in labels:
|
if value["target"] not in labels:
|
||||||
|
|||||||
@@ -105,10 +105,13 @@ def expr(node: ast.AST) -> Expr:
|
|||||||
raise Unsupported("распаковка ** в словаре")
|
raise Unsupported("распаковка ** в словаре")
|
||||||
return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]}
|
return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]}
|
||||||
case ast.Call(func=func, args=args, keywords=kws):
|
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):
|
call = {"k": "call", "f": expr(func),
|
||||||
raise Unsupported("распаковка аргументов *args/**kwargs")
|
"a": [{"k": "star", "v": expr(a.value)} if isinstance(a, ast.Starred) else expr(a)
|
||||||
return {"k": "call", "f": expr(func), "a": [expr(a) for a in args],
|
for a in args],
|
||||||
"kw": {k.arg: expr(k.value) for k in kws}}
|
"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):
|
case ast.JoinedStr(values=values):
|
||||||
parts: list[Any] = []
|
parts: list[Any] = []
|
||||||
for v in values:
|
for v in values:
|
||||||
@@ -125,8 +128,8 @@ def expr(node: ast.AST) -> Expr:
|
|||||||
is_async=0)])):
|
is_async=0)])):
|
||||||
return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it),
|
return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it),
|
||||||
"if": [expr(i) for i in ifs]}
|
"if": [expr(i) for i in ifs]}
|
||||||
case ast.Lambda():
|
case ast.Lambda(args=args, body=body):
|
||||||
raise Unsupported("lambda")
|
return {"k": "lambda", "params": params(args), "body": expr(body)}
|
||||||
raise Unsupported(f"выражение {type(node).__name__}")
|
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)}]
|
return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}]
|
||||||
case ast.Delete(targets=targets):
|
case ast.Delete(targets=targets):
|
||||||
return [{"op": "del", "t": target(t)} for t in targets]
|
return [{"op": "del", "t": target(t)} for t in targets]
|
||||||
case ast.Pass() | ast.Global() | ast.Nonlocal():
|
case ast.Pass():
|
||||||
return []
|
return []
|
||||||
case ast.Break():
|
case ast.Break():
|
||||||
return [{"op": "break"}]
|
return [{"op": "break"}]
|
||||||
@@ -184,6 +187,64 @@ def stmt(node: ast.stmt) -> list[Stmt]:
|
|||||||
if mod.split(".")[0] in KNOWN_MODULES:
|
if mod.split(".")[0] in KNOWN_MODULES:
|
||||||
return []
|
return []
|
||||||
raise Unsupported(f"импорт модуля {mod}")
|
raise Unsupported(f"импорт модуля {mod}")
|
||||||
case ast.FunctionDef(name=name) | ast.ClassDef(name=name):
|
case ast.FunctionDef(name=name, args=args, body=body, decorator_list=decos):
|
||||||
raise Unsupported(f"определение {name}()")
|
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__}")
|
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)
|
||||||
@@ -373,6 +373,9 @@ class RpyParser:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
continue
|
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:
|
try:
|
||||||
self.d.init_code.extend(pyexpr.stmt(node))
|
self.d.init_code.extend(pyexpr.stmt(node))
|
||||||
except Unsupported as e:
|
except Unsupported as e:
|
||||||
|
|||||||
+37
-1
@@ -69,10 +69,17 @@ def expression(value, depth=0):
|
|||||||
expression(part, depth + 1)
|
expression(part, depth + 1)
|
||||||
elif kind != "fmt" or not isinstance(item, str):
|
elif kind != "fmt" or not isinstance(item, str):
|
||||||
expression(item, depth + 1)
|
expression(item, depth + 1)
|
||||||
|
elif kind == "star":
|
||||||
|
ex("v")
|
||||||
|
elif kind == "lambda":
|
||||||
|
parameters(value.get("params"), depth + 1)
|
||||||
|
ex("body")
|
||||||
elif kind == "call":
|
elif kind == "call":
|
||||||
ex("f")
|
ex("f")
|
||||||
for item in seq("a"):
|
for item in seq("a"):
|
||||||
expression(item, depth + 1)
|
expression(item, depth + 1)
|
||||||
|
for item in value.get("ks", []):
|
||||||
|
expression(item, depth + 1)
|
||||||
if not isinstance(value.get("kw"), dict):
|
if not isinstance(value.get("kw"), dict):
|
||||||
raise ValueError("Некорректные аргументы вызова")
|
raise ValueError("Некорректные аргументы вызова")
|
||||||
for item in value["kw"].values():
|
for item in value["kw"].values():
|
||||||
@@ -86,6 +93,16 @@ def expression(value, depth=0):
|
|||||||
raise ValueError(f"Неизвестное выражение: {kind}")
|
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):
|
def statements(code, depth=0):
|
||||||
if depth > 64 or not isinstance(code, list):
|
if depth > 64 or not isinstance(code, list):
|
||||||
raise ValueError("Некорректный PGVN AST")
|
raise ValueError("Некорректный PGVN AST")
|
||||||
@@ -95,7 +112,8 @@ def statements(code, depth=0):
|
|||||||
op = s.get("op")
|
op = s.get("op")
|
||||||
keys = {"set": ("t", "v"), "aug": ("t", "v"), "eval": ("e",),
|
keys = {"set": ("t", "v"), "aug": ("t", "v"), "eval": ("e",),
|
||||||
"if": ("c",), "while": ("c",), "for": ("t", "it"),
|
"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:
|
if op not in keys:
|
||||||
raise ValueError(f"Неизвестный оператор AST: {op}")
|
raise ValueError(f"Неизвестный оператор AST: {op}")
|
||||||
for key in keys[op]:
|
for key in keys[op]:
|
||||||
@@ -105,6 +123,24 @@ def statements(code, depth=0):
|
|||||||
statements(s.get("else"), depth + 1)
|
statements(s.get("else"), depth + 1)
|
||||||
if op in ("while", "for"):
|
if op in ("while", "for"):
|
||||||
statements(s.get("body"), depth + 1)
|
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):
|
def nani_expression(node, depth=0):
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Compile a Python file to PGVN IR JSON: python -m tests.make_ir SOURCE.py OUT.json"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app import pgvn
|
||||||
|
from app.converter import pyexpr
|
||||||
|
|
||||||
|
|
||||||
|
def main(source: Path, out: Path) -> None:
|
||||||
|
code = pyexpr.parse_stmts(source.read_text(encoding="utf-8"))
|
||||||
|
pgvn.statements(code)
|
||||||
|
out.write_text(json.dumps(code, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(Path(sys.argv[1]), Path(sys.argv[2]))
|
||||||
@@ -78,7 +78,7 @@ def test_renpy_only_declarations_are_not_execution_blockers(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("source,reason", [
|
@pytest.mark.parametrize("source,reason", [
|
||||||
('init python:\n def helper():\n pass\nlabel start:\n "Hi"', "init python"),
|
('init python:\n def helper():\n yield 1\nlabel start:\n "Hi"', "init python"),
|
||||||
('label start:\n $ result = renpy.show_screen("map")', "renpy.show_screen"),
|
('label start:\n $ result = renpy.show_screen("map")', "renpy.show_screen"),
|
||||||
('label start:\n jump missing', "missing"),
|
('label start:\n jump missing', "missing"),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def test_expr_translation():
|
|||||||
e = pyexpr.parse_expr("a >= 1 and 'x' in inv or not flag")
|
e = pyexpr.parse_expr("a >= 1 and 'x' in inv or not flag")
|
||||||
assert e["k"] == "bool" and e["op"] == "or"
|
assert e["k"] == "bool" and e["op"] == "or"
|
||||||
with pytest.raises(pyexpr.Unsupported):
|
with pytest.raises(pyexpr.Unsupported):
|
||||||
pyexpr.parse_expr("lambda: 1")
|
pyexpr.parse_expr("(y := 1)")
|
||||||
|
|
||||||
|
|
||||||
def test_say_variants():
|
def test_say_variants():
|
||||||
@@ -57,10 +57,43 @@ def test_if_chain_and_menu():
|
|||||||
|
|
||||||
|
|
||||||
def test_unsupported_python_is_reported_not_fatal():
|
def test_unsupported_python_is_reported_not_fatal():
|
||||||
d = parse("init python:\n def f():\n pass\n x = 1\nlabel start:\n $ y = x\n")
|
d = parse("init python:\n def f():\n yield 1\n x = 1\nlabel start:\n $ y = x\n")
|
||||||
assert d.warnings and d.init_code[0]["op"] == "set"
|
assert d.warnings and d.init_code[0]["op"] == "set"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_functions_and_classes_are_interpreted_not_rejected():
|
||||||
|
from app.converter.compatibility import audit_ir
|
||||||
|
|
||||||
|
d = parse(
|
||||||
|
"init python:\n"
|
||||||
|
" class Mood(object):\n"
|
||||||
|
" def __init__(self, v=0):\n"
|
||||||
|
" self.v = v\n"
|
||||||
|
" def up(self, n=1):\n"
|
||||||
|
" self.v += n\n"
|
||||||
|
" return self\n"
|
||||||
|
" def bonus(m, *rest, **kw):\n"
|
||||||
|
" return m.up(2).v\n"
|
||||||
|
"default mood = Mood()\n"
|
||||||
|
"label start:\n"
|
||||||
|
" $ x = bonus(mood)\n"
|
||||||
|
" $ mood.up()\n")
|
||||||
|
audit_ir(d)
|
||||||
|
assert not d.warnings and not d.blockers
|
||||||
|
assert [s["op"] for s in d.init_code] == ["class", "def"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_renpy_calls_and_unsupported_class_features_are_still_reported():
|
||||||
|
from app.converter.compatibility import audit_ir
|
||||||
|
|
||||||
|
d = parse("label start:\n $ renpy.no_such_call()\n $ missing_function()\n")
|
||||||
|
audit_ir(d)
|
||||||
|
messages = " ".join(d.warnings)
|
||||||
|
assert "renpy.no_such_call" in messages and "missing_function" in messages
|
||||||
|
d = parse("init python:\n class A(object):\n def __eq__(self, other):\n return True\n")
|
||||||
|
assert any("__eq__" in w for w in d.warnings)
|
||||||
|
|
||||||
|
|
||||||
def test_full_pipeline(data_dir, tmp_path):
|
def test_full_pipeline(data_dir, tmp_path):
|
||||||
archive = build(tmp_path / "sample.zip")
|
archive = build(tmp_path / "sample.zip")
|
||||||
steps = []
|
steps = []
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// PGVN interpreter + in-game UI.
|
// PGVN interpreter + in-game UI.
|
||||||
|
|
||||||
import { AudioSystem } from "./audio";
|
import { AudioSystem } from "./audio";
|
||||||
import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, type Scope } from "./python";
|
import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python";
|
||||||
import { Renderer, type ResolvedImage } from "./renderer";
|
import { Renderer, type ResolvedImage } from "./renderer";
|
||||||
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
|
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
|
||||||
import { languageName, rememberLanguage, type Translator } from "./i18n";
|
import { languageName, rememberLanguage, type Translator } from "./i18n";
|
||||||
@@ -352,9 +352,9 @@ export class Engine {
|
|||||||
reversed: (xs: any[]) => [...xs].reverse(),
|
reversed: (xs: any[]) => [...xs].reverse(),
|
||||||
enumerate: (xs: any[]) => [...xs].map((x, i) => [i, x]),
|
enumerate: (xs: any[]) => [...xs].map((x, i) => [i, x]),
|
||||||
zip: (...lists: any[][]) => lists[0].map((_, i) => lists.map((l) => l[i])),
|
zip: (...lists: any[][]) => lists[0].map((_, i) => lists.map((l) => l[i])),
|
||||||
isinstance: () => false,
|
|
||||||
_: (s: string) => s,
|
_: (s: string) => s,
|
||||||
__: (s: string) => this.tr.str(s),
|
__: (s: string) => this.tr.str(s),
|
||||||
|
...userBuiltins(this.py),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,81 @@ export class JumpSignal {
|
|||||||
|
|
||||||
class BreakSignal {}
|
class BreakSignal {}
|
||||||
class ContinueSignal {}
|
class ContinueSignal {}
|
||||||
|
class ReturnSignal {
|
||||||
|
constructor(public value: any) {}
|
||||||
|
}
|
||||||
|
|
||||||
export type Builtin = ((...args: any[]) => any) & { __py?: true };
|
export type Builtin = ((...args: any[]) => any) & { __py?: true };
|
||||||
|
|
||||||
|
// --- user-defined classes -------------------------------------------------------
|
||||||
|
// `class` statements of the game become PyClass objects. Instances keep their data in
|
||||||
|
// `attrs`, so a plain JSON round trip (saves) restores them given the class registry.
|
||||||
|
|
||||||
|
const SUPPORTED_DUNDERS = new Set(["__init__", "__str__", "__repr__"]);
|
||||||
|
const MAX_CALL_DEPTH = 150;
|
||||||
|
|
||||||
|
export class PyClass {
|
||||||
|
attrs = new Map<string, any>();
|
||||||
|
constructor(public name: string, public bases: PyClass[]) {}
|
||||||
|
toJSON() {
|
||||||
|
return undefined; // classes are rebuilt by the game's init code, never saved
|
||||||
|
}
|
||||||
|
mro(): PyClass[] {
|
||||||
|
const out: PyClass[] = [];
|
||||||
|
const visit = (c: PyClass) => {
|
||||||
|
if (out.includes(c)) return;
|
||||||
|
out.push(c);
|
||||||
|
c.bases.forEach(visit);
|
||||||
|
};
|
||||||
|
visit(this);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
find(name: string): { value: any; owner: PyClass } | undefined {
|
||||||
|
for (const c of this.mro()) if (c.attrs.has(name)) return { value: c.attrs.get(name), owner: c };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PyInstance {
|
||||||
|
constructor(public cls: PyClass, public attrs: Record<string, any> = Object.create(null)) {}
|
||||||
|
toJSON() {
|
||||||
|
return { __inst__: this.cls.name, a: this.attrs };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SuperProxy {
|
||||||
|
constructor(public owner: PyClass, public self: any) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** staticmethod / classmethod / property wrapper stored in a class body. */
|
||||||
|
class PyDescriptor {
|
||||||
|
constructor(public kind: "static" | "class" | "property", public fn: any, public setter?: any) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OBJECT = new PyClass("object", []);
|
||||||
|
const classRegistry = new Map<string, PyClass>();
|
||||||
|
|
||||||
|
type FnDef = { name: string; params: any[]; body?: PyStmt[]; expr?: Expr };
|
||||||
|
type FnCtx = {
|
||||||
|
vars: Map<string, any>;
|
||||||
|
globals: Set<string>;
|
||||||
|
nonlocals: Set<string>;
|
||||||
|
owner?: PyClass;
|
||||||
|
self?: any;
|
||||||
|
isClassBody: boolean;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let activePython: Python | null = null;
|
||||||
|
|
||||||
|
function instanceText(v: PyInstance): string {
|
||||||
|
for (const name of ["__str__", "__repr__"]) {
|
||||||
|
const found = v.cls.find(name);
|
||||||
|
if (found && typeof found.value === "function" && activePython) return pyStr(found.value(v));
|
||||||
|
}
|
||||||
|
return `<${v.cls.name} object>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function truthy(v: any): boolean {
|
export function truthy(v: any): boolean {
|
||||||
if (v === null || v === undefined || v === false || v === 0 || v === "") return false;
|
if (v === null || v === undefined || v === false || v === 0 || v === "") return false;
|
||||||
if (Array.isArray(v)) return v.length > 0;
|
if (Array.isArray(v)) return v.length > 0;
|
||||||
@@ -59,6 +131,7 @@ export function pyStr(v: any): string {
|
|||||||
if (v === false) return "False";
|
if (v === false) return "False";
|
||||||
if (typeof v === "number") return Number.isInteger(v) ? String(v) : String(v);
|
if (typeof v === "number") return Number.isInteger(v) ? String(v) : String(v);
|
||||||
if (Array.isArray(v)) return "[" + v.map(pyRepr).join(", ") + "]";
|
if (Array.isArray(v)) return "[" + v.map(pyRepr).join(", ") + "]";
|
||||||
|
if (v instanceof PyInstance) return instanceText(v);
|
||||||
if (v instanceof PyDict)
|
if (v instanceof PyDict)
|
||||||
return "{" + [...v.map.entries()].map(([k, x]) => `${pyRepr(k)}: ${pyRepr(x)}`).join(", ") + "}";
|
return "{" + [...v.map.entries()].map(([k, x]) => `${pyRepr(k)}: ${pyRepr(x)}`).join(", ") + "}";
|
||||||
return String(v);
|
return String(v);
|
||||||
@@ -253,8 +326,12 @@ export interface Scope {
|
|||||||
export class Python {
|
export class Python {
|
||||||
locals: Map<string, any>[] = [];
|
locals: Map<string, any>[] = [];
|
||||||
warned = new Set<string>();
|
warned = new Set<string>();
|
||||||
|
fn: FnCtx | null = null;
|
||||||
|
depth = 0;
|
||||||
|
|
||||||
constructor(public scope: Scope) {}
|
constructor(public scope: Scope) {
|
||||||
|
activePython = this;
|
||||||
|
}
|
||||||
|
|
||||||
warnOnce(msg: string) {
|
warnOnce(msg: string) {
|
||||||
if (!this.warned.has(msg)) {
|
if (!this.warned.has(msg)) {
|
||||||
@@ -283,6 +360,12 @@ export class Python {
|
|||||||
return this.lookup(e.id);
|
return this.lookup(e.id);
|
||||||
case "attr": {
|
case "attr": {
|
||||||
const o = this.eval(e.o);
|
const o = this.eval(e.o);
|
||||||
|
if (o instanceof PyInstance || o instanceof PyClass || o instanceof SuperProxy) {
|
||||||
|
if (!safeProperty(e.a) && !SUPPORTED_DUNDERS.has(e.a)) return null;
|
||||||
|
const v = this.attrOf(o, e.a);
|
||||||
|
if (v === undefined) this.warnOnce(`у ${o instanceof PyInstance ? o.cls.name : "класса"} нет атрибута ${e.a}`);
|
||||||
|
return v === undefined ? null : v;
|
||||||
|
}
|
||||||
if (!safeProperty(e.a) || typeof o === "function") return null;
|
if (!safeProperty(e.a) || typeof o === "function") return null;
|
||||||
if (o === null || o === undefined) {
|
if (o === null || o === undefined) {
|
||||||
this.warnOnce(`атрибут ${e.a} у None`);
|
this.warnOnce(`атрибут ${e.a} у None`);
|
||||||
@@ -356,16 +439,36 @@ export class Python {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
case "lambda": {
|
||||||
|
const closure = this.fn?.isClassBody ? this.locals.slice(0, this.fn.index) : this.locals.slice();
|
||||||
|
return this.makeFunction({ name: "<lambda>", params: e.params, expr: e.body }, closure,
|
||||||
|
e.params.map((p: any) => (p.d ? this.eval(p.d) : undefined)));
|
||||||
|
}
|
||||||
|
case "star":
|
||||||
|
this.warnOnce("*выражение вне вызова");
|
||||||
|
return null;
|
||||||
case "call": {
|
case "call": {
|
||||||
|
const isSuper = e.f.k === "name" && e.f.id === "super" && !this.locals.some((l) => l.has("super"));
|
||||||
|
if (isSuper) return this.makeSuper();
|
||||||
const f = this.eval(e.f);
|
const f = this.eval(e.f);
|
||||||
const args = e.a.map((x: Expr) => this.eval(x));
|
const args: any[] = [];
|
||||||
|
for (const x of e.a as Expr[]) {
|
||||||
|
if (x.k === "star") args.push(...iterate(this.eval(x.v)));
|
||||||
|
else args.push(this.eval(x));
|
||||||
|
}
|
||||||
const kw: Record<string, any> = {};
|
const kw: Record<string, any> = {};
|
||||||
for (const [k, v] of Object.entries(e.kw)) kw[k] = this.eval(v as Expr);
|
for (const [k, v] of Object.entries(e.kw)) kw[k] = this.eval(v as Expr);
|
||||||
|
for (const x of (e.ks ?? []) as Expr[]) {
|
||||||
|
const m = this.eval(x);
|
||||||
|
if (m instanceof PyDict) for (const [k, v] of m.map.entries()) kw[String(k)] = v;
|
||||||
|
}
|
||||||
|
const call = Object.keys(kw).length ? [...args, { __kw: kw }] : args;
|
||||||
|
if (f instanceof PyClass) return this.instantiate(f, call);
|
||||||
if (typeof f !== "function") {
|
if (typeof f !== "function") {
|
||||||
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
|
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return Object.keys(kw).length ? f(...args, { __kw: kw }) : f(...args);
|
return f(...call);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.warnOnce(`неизвестное выражение ${e.k}`);
|
this.warnOnce(`неизвестное выражение ${e.k}`);
|
||||||
@@ -377,7 +480,7 @@ export class Python {
|
|||||||
case "name":
|
case "name":
|
||||||
if (!safeProperty(t.id)) throw new Error("Служебное имя недоступно");
|
if (!safeProperty(t.id)) throw new Error("Служебное имя недоступно");
|
||||||
if (frame) frame.set(t.id, value);
|
if (frame) frame.set(t.id, value);
|
||||||
else this.scope.set(t.id, value);
|
else this.bindName(t.id, value);
|
||||||
return;
|
return;
|
||||||
case "tuple": {
|
case "tuple": {
|
||||||
const items = [...iterate(value)];
|
const items = [...iterate(value)];
|
||||||
@@ -387,7 +490,14 @@ export class Python {
|
|||||||
case "attr": {
|
case "attr": {
|
||||||
const o = this.eval(t.o);
|
const o = this.eval(t.o);
|
||||||
if (!safeProperty(t.a) || typeof o === "function") throw new Error("Служебный атрибут недоступен");
|
if (!safeProperty(t.a) || typeof o === "function") throw new Error("Служебный атрибут недоступен");
|
||||||
if (o instanceof PyDict) o.set(t.a, value);
|
if (o instanceof PyInstance) {
|
||||||
|
const found = o.cls.find(t.a);
|
||||||
|
if (found?.value instanceof PyDescriptor && found.value.kind === "property") {
|
||||||
|
if (!found.value.setter) throw new Error(`Свойство ${t.a} доступно только для чтения`);
|
||||||
|
found.value.setter(o, value);
|
||||||
|
} else o.attrs[t.a] = value;
|
||||||
|
} else if (o instanceof PyClass) o.attrs.set(t.a, value);
|
||||||
|
else if (o instanceof PyDict) o.set(t.a, value);
|
||||||
else if (o) o[t.a] = value;
|
else if (o) o[t.a] = value;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -433,7 +543,10 @@ export class Python {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "del":
|
case "del":
|
||||||
if (s.t.k === "name") this.scope.del(s.t.id);
|
if (s.t.k === "name") {
|
||||||
|
if (this.fn && !this.fn.globals.has(s.t.id)) this.fn.vars.delete(s.t.id);
|
||||||
|
else this.scope.del(s.t.id);
|
||||||
|
}
|
||||||
else if (s.t.k === "sub") {
|
else if (s.t.k === "sub") {
|
||||||
const o = this.eval(s.t.o);
|
const o = this.eval(s.t.o);
|
||||||
const i = this.eval(s.t.i);
|
const i = this.eval(s.t.i);
|
||||||
@@ -441,6 +554,21 @@ export class Python {
|
|||||||
else if (Array.isArray(o)) o.splice(index(o.length, i), 1);
|
else if (Array.isArray(o)) o.splice(index(o.length, i), 1);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "def":
|
||||||
|
this.defineFunction(s);
|
||||||
|
break;
|
||||||
|
case "class":
|
||||||
|
this.defineClass(s);
|
||||||
|
break;
|
||||||
|
case "return":
|
||||||
|
if (!this.fn) throw new Error("return вне функции");
|
||||||
|
throw new ReturnSignal(s.v ? this.eval(s.v) : null);
|
||||||
|
case "global":
|
||||||
|
for (const n of s.names) this.fn?.globals.add(n);
|
||||||
|
break;
|
||||||
|
case "nonlocal":
|
||||||
|
for (const n of s.names) this.fn?.nonlocals.add(n);
|
||||||
|
break;
|
||||||
case "break":
|
case "break":
|
||||||
throw new BreakSignal();
|
throw new BreakSignal();
|
||||||
case "continue":
|
case "continue":
|
||||||
@@ -449,6 +577,188 @@ export class Python {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- functions and classes ---------------------------------------------------------
|
||||||
|
|
||||||
|
/** Assign a name in the scope the running code sees: function local, global or enclosing. */
|
||||||
|
bindName(name: string, value: any) {
|
||||||
|
const f = this.fn;
|
||||||
|
if (!f || f.globals.has(name)) return void this.scope.set(name, value);
|
||||||
|
if (f.nonlocals.has(name)) {
|
||||||
|
for (let i = this.locals.length - 2; i >= 0; i--) {
|
||||||
|
if (this.locals[i].has(name)) return void this.locals[i].set(name, value);
|
||||||
|
}
|
||||||
|
throw new Error(`nonlocal ${name}: переменная не найдена`);
|
||||||
|
}
|
||||||
|
f.vars.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
makeFunction(def: FnDef, closure: Map<string, any>[], defaults: any[]): Builtin {
|
||||||
|
const fn: any = (...args: any[]) => this.callUser(fn, args);
|
||||||
|
fn.__py = true;
|
||||||
|
fn.__def = def;
|
||||||
|
fn.__closure = closure;
|
||||||
|
fn.__defaults = defaults;
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private callUser(fn: any, given: any[]): any {
|
||||||
|
const def: FnDef = fn.__def;
|
||||||
|
let args = given;
|
||||||
|
let kw: Record<string, any> = {};
|
||||||
|
const last = args[args.length - 1];
|
||||||
|
if (last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw")) {
|
||||||
|
kw = last.__kw;
|
||||||
|
args = args.slice(0, -1);
|
||||||
|
}
|
||||||
|
const frame = new Map<string, any>();
|
||||||
|
const named = new Set<string>();
|
||||||
|
let position = 0;
|
||||||
|
def.params.forEach((p: any, i: number) => {
|
||||||
|
if (p.kind === "p") {
|
||||||
|
named.add(p.n);
|
||||||
|
if (position < args.length) {
|
||||||
|
if (Object.hasOwn(kw, p.n)) throw new TypeError(`${def.name}(): параметр ${p.n} задан дважды`);
|
||||||
|
frame.set(p.n, args[position++]);
|
||||||
|
} else if (Object.hasOwn(kw, p.n)) frame.set(p.n, kw[p.n]);
|
||||||
|
else if (p.d) frame.set(p.n, fn.__defaults[i]);
|
||||||
|
else throw new TypeError(`${def.name}(): не хватает аргумента ${p.n}`);
|
||||||
|
} else if (p.kind === "k") {
|
||||||
|
named.add(p.n);
|
||||||
|
if (Object.hasOwn(kw, p.n)) frame.set(p.n, kw[p.n]);
|
||||||
|
else if (p.d) frame.set(p.n, fn.__defaults[i]);
|
||||||
|
else throw new TypeError(`${def.name}(): не хватает именованного аргумента ${p.n}`);
|
||||||
|
} else if (p.kind === "*") {
|
||||||
|
frame.set(p.n, args.slice(position));
|
||||||
|
position = args.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (position < args.length) throw new TypeError(`${def.name}(): слишком много аргументов`);
|
||||||
|
const rest = Object.keys(kw).filter((k) => !named.has(k));
|
||||||
|
const kwParam = def.params.find((p: any) => p.kind === "**");
|
||||||
|
if (kwParam) frame.set(kwParam.n, new PyDict(rest.map((k) => [k, kw[k]])));
|
||||||
|
else if (rest.length) throw new TypeError(`${def.name}(): неожиданный аргумент ${rest[0]}`);
|
||||||
|
|
||||||
|
if (this.depth >= MAX_CALL_DEPTH) throw new Error("Слишком глубокая рекурсия в python");
|
||||||
|
const previousLocals = this.locals;
|
||||||
|
const previousFn = this.fn;
|
||||||
|
this.depth++;
|
||||||
|
this.locals = [...fn.__closure, frame];
|
||||||
|
this.fn = {
|
||||||
|
vars: frame, globals: new Set(), nonlocals: new Set(), owner: fn.__owner,
|
||||||
|
self: def.params.length ? frame.get(def.params[0].n) : undefined, isClassBody: false, index: 0,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (def.expr) return this.eval(def.expr);
|
||||||
|
try {
|
||||||
|
this.exec(def.body ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ReturnSignal) return err.value;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
this.depth--;
|
||||||
|
this.locals = previousLocals;
|
||||||
|
this.fn = previousFn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private defineFunction(s: PyStmt) {
|
||||||
|
const closure = this.fn?.isClassBody ? this.locals.slice(0, this.fn.index) : this.locals.slice();
|
||||||
|
let value: any = this.makeFunction({ name: s.name, params: s.params, body: s.body }, closure,
|
||||||
|
s.params.map((p: any) => (p.d ? this.eval(p.d) : undefined)));
|
||||||
|
for (const deco of [...(s.decos ?? [])].reverse()) {
|
||||||
|
if (deco.k === "name") {
|
||||||
|
const kind = { staticmethod: "static", classmethod: "class", property: "property" }[deco.id as string];
|
||||||
|
value = new PyDescriptor(kind as any, value);
|
||||||
|
} else {
|
||||||
|
const base = this.eval(deco.o);
|
||||||
|
if (!(base instanceof PyDescriptor) || base.kind !== "property") throw new Error("Неверный декоратор setter");
|
||||||
|
value = new PyDescriptor("property", base.fn, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.defineName(s.name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like bindName, but for names produced by def/class (may be dunder methods). */
|
||||||
|
private defineName(name: string, value: any) {
|
||||||
|
if (this.fn?.isClassBody) this.fn.vars.set(name, value);
|
||||||
|
else this.bindName(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private defineClass(s: PyStmt) {
|
||||||
|
const bases: PyClass[] = s.bases.map((b: Expr) => {
|
||||||
|
const base = this.eval(b);
|
||||||
|
if (!(base instanceof PyClass)) throw new Error(`Класс ${s.name}: неподдерживаемый базовый класс`);
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
const cls = new PyClass(s.name, bases.length ? bases : [OBJECT]);
|
||||||
|
const previousFn = this.fn;
|
||||||
|
const index = this.locals.length;
|
||||||
|
this.locals.push(cls.attrs);
|
||||||
|
this.fn = { vars: cls.attrs, globals: new Set(), nonlocals: new Set(), isClassBody: true, index };
|
||||||
|
try {
|
||||||
|
this.exec(s.body);
|
||||||
|
} finally {
|
||||||
|
this.locals.splice(index);
|
||||||
|
this.fn = previousFn;
|
||||||
|
}
|
||||||
|
for (const value of cls.attrs.values()) {
|
||||||
|
const fns = value instanceof PyDescriptor ? [value.fn, value.setter] : [value];
|
||||||
|
for (const f of fns) if (typeof f === "function" && f.__def && !f.__owner) f.__owner = cls;
|
||||||
|
}
|
||||||
|
classRegistry.set(cls.name, cls);
|
||||||
|
this.defineName(s.name, cls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeSuper() {
|
||||||
|
const ctx = this.fn;
|
||||||
|
if (!ctx?.owner) throw new Error("super() вне метода класса");
|
||||||
|
return new SuperProxy(ctx.owner, ctx.self);
|
||||||
|
}
|
||||||
|
|
||||||
|
instantiate(cls: PyClass, call: any[]): PyInstance {
|
||||||
|
const instance = new PyInstance(cls);
|
||||||
|
const init = cls.find("__init__");
|
||||||
|
if (init && typeof init.value === "function") init.value(instance, ...call);
|
||||||
|
else if (call.length) throw new TypeError(`${cls.name}() не принимает аргументов`);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attribute of an instance, class or super() proxy; undefined when missing. */
|
||||||
|
attrOf(o: PyInstance | PyClass | SuperProxy, name: string): any {
|
||||||
|
if (o instanceof PyInstance) {
|
||||||
|
if (Object.hasOwn(o.attrs, name)) return o.attrs[name];
|
||||||
|
const found = o.cls.find(name);
|
||||||
|
return found ? this.member(found.value, o, o.cls) : undefined;
|
||||||
|
}
|
||||||
|
if (o instanceof SuperProxy) {
|
||||||
|
for (const base of o.owner.bases) {
|
||||||
|
const found = base.find(name);
|
||||||
|
if (found) return this.member(found.value, o.self, base);
|
||||||
|
}
|
||||||
|
return name === "__init__" ? () => null : undefined;
|
||||||
|
}
|
||||||
|
const found = o.find(name);
|
||||||
|
if (!found) return undefined;
|
||||||
|
const value = found.value;
|
||||||
|
if (value instanceof PyDescriptor) {
|
||||||
|
if (value.kind === "class") return (...a: any[]) => value.fn(o, ...a);
|
||||||
|
return value.kind === "static" ? value.fn : null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private member(value: any, self: any, cls: PyClass): any {
|
||||||
|
if (value instanceof PyDescriptor) {
|
||||||
|
if (value.kind === "property") return value.fn(self);
|
||||||
|
if (value.kind === "static") return value.fn;
|
||||||
|
return (...a: any[]) => value.fn(cls, ...a);
|
||||||
|
}
|
||||||
|
if (typeof value === "function" && value.__def) return (...a: any[]) => value(self, ...a);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/** Runs a loop body; returns true on break. */
|
/** Runs a loop body; returns true on break. */
|
||||||
private loopBody(body: PyStmt[]): boolean {
|
private loopBody(body: PyStmt[]): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -478,9 +788,69 @@ export function decodeValue(v: any): any {
|
|||||||
if (v && typeof v === "object") {
|
if (v && typeof v === "object") {
|
||||||
if (Array.isArray(v.__dict__))
|
if (Array.isArray(v.__dict__))
|
||||||
return new PyDict(v.__dict__.map(([k, x]: [any, any]) => [k, decodeValue(x)]));
|
return new PyDict(v.__dict__.map(([k, x]: [any, any]) => [k, decodeValue(x)]));
|
||||||
|
if (typeof v.__inst__ === "string" && v.a && typeof v.a === "object") {
|
||||||
|
const attrs: Record<string, any> = Object.create(null);
|
||||||
|
for (const [k, x] of Object.entries(v.a)) attrs[k] = decodeValue(x);
|
||||||
|
const cls = classRegistry.get(v.__inst__);
|
||||||
|
return cls ? new PyInstance(cls, attrs) : attrs; // class gone: keep the data as a record
|
||||||
|
}
|
||||||
const out: Record<string, any> = {};
|
const out: Record<string, any> = {};
|
||||||
for (const [k, x] of Object.entries(v)) out[k] = decodeValue(x);
|
for (const [k, x] of Object.entries(v)) out[k] = decodeValue(x);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Builtins that need the interpreter: type checks, attribute access, callbacks. */
|
||||||
|
export function userBuiltins(py: Python): Record<string, any> {
|
||||||
|
const split = (args: any[]) => {
|
||||||
|
const last = args[args.length - 1];
|
||||||
|
return last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw")
|
||||||
|
? { pos: args.slice(0, -1), kw: last.__kw as Record<string, any> } : { pos: args, kw: {} };
|
||||||
|
};
|
||||||
|
const typeOf = (name: string) => py.scope.get(name);
|
||||||
|
const isa = (o: any, c: any): boolean => {
|
||||||
|
if (Array.isArray(c)) return c.some((x) => isa(o, x));
|
||||||
|
if (c instanceof PyClass) return c === OBJECT || (o instanceof PyInstance && o.cls.mro().includes(c));
|
||||||
|
if (c === typeOf("int") || c === typeOf("float")) return typeof o === "number";
|
||||||
|
if (c === typeOf("str")) return typeof o === "string";
|
||||||
|
if (c === typeOf("bool")) return typeof o === "boolean";
|
||||||
|
if (c === typeOf("list") || c === typeOf("tuple") || c === typeOf("set")) return Array.isArray(o);
|
||||||
|
if (c === typeOf("dict")) return o instanceof PyDict;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const get = (o: any, name: string): any => {
|
||||||
|
if (typeof name !== "string" || (!safeProperty(name) && !SUPPORTED_DUNDERS.has(name))) return undefined;
|
||||||
|
if (o instanceof PyInstance || o instanceof PyClass) return py.attrOf(o, name);
|
||||||
|
if (o instanceof PyDict) return o.has(name) ? o.get(name) : undefined;
|
||||||
|
if (o === null || o === undefined || typeof o === "function") return undefined;
|
||||||
|
return methodOf(o, name) ?? (Object.hasOwn(Object(o), name) ? o[name] : undefined);
|
||||||
|
};
|
||||||
|
const less = (a: any, b: any) => (a < b ? -1 : a > b ? 1 : 0);
|
||||||
|
return {
|
||||||
|
object: OBJECT,
|
||||||
|
python_object: OBJECT,
|
||||||
|
NoRollback: OBJECT,
|
||||||
|
isinstance: (o: any, c: any) => isa(o, c),
|
||||||
|
hasattr: (o: any, name: string) => get(o, name) !== undefined,
|
||||||
|
getattr: (...a: any[]) => {
|
||||||
|
const v = get(a[0], a[1]);
|
||||||
|
if (v !== undefined) return v;
|
||||||
|
if (a.length > 2) return a[2];
|
||||||
|
throw new Error(`нет атрибута ${a[1]}`);
|
||||||
|
},
|
||||||
|
setattr: (o: any, name: string, value: any) => {
|
||||||
|
if (!safeProperty(name)) throw new Error("Служебный атрибут недоступен");
|
||||||
|
py.bind({ k: "attr", o: { k: "const", v: o }, a: name }, value);
|
||||||
|
},
|
||||||
|
callable: (x: any) => typeof x === "function" || x instanceof PyClass,
|
||||||
|
map: (f: any, xs: any) => [...iterate(xs)].map((x) => f(x)),
|
||||||
|
filter: (f: any, xs: any) => [...iterate(xs)].filter((x) => (f === null ? truthy(x) : truthy(f(x)))),
|
||||||
|
sorted: (...a: any[]) => {
|
||||||
|
const { pos, kw } = split(a);
|
||||||
|
const key = kw.key ?? ((x: any) => x);
|
||||||
|
const out = [...iterate(pos[0])].map((x) => [key(x), x]).sort((p, q) => less(p[0], q[0])).map((p) => p[1]);
|
||||||
|
return truthy(kw.reverse) ? out.reverse() : out;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
[{"op":"class","name":"Perk","bases":[{"k":"name","id":"object"}],"body":[{"op":"set","t":{"k":"name","id":"count"},"v":{"k":"const","v":0}},{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"name","d":null,"kind":"p"},{"n":"cost","d":{"k":"const","v":1},"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"name"},"v":{"k":"name","id":"name"}},{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"cost"},"v":{"k":"name","id":"cost"}},{"op":"aug","t":{"k":"attr","o":{"k":"name","id":"Perk"},"a":"count"},"o":"+","v":{"k":"const","v":1}}],"decos":[]},{"op":"def","name":"__str__","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"bin","op":"%","l":{"k":"const","v":"Perk(%s)"},"r":{"k":"attr","o":{"k":"name","id":"self"},"a":"name"}}}],"decos":[]}]},{"op":"class","name":"Player","bases":[{"k":"name","id":"object"}],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"name","d":null,"kind":"p"},{"n":"perks","d":null,"kind":"*"},{"n":"extra","d":null,"kind":"**"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"name"},"v":{"k":"name","id":"name"}},{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"perks"},"v":{"k":"call","f":{"k":"name","id":"list"},"a":[{"k":"name","id":"perks"}],"kw":{}}},{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"extra"},"v":{"k":"name","id":"extra"}},{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"_hp"},"v":{"k":"const","v":10}}],"decos":[]},{"op":"def","name":"has_perk","params":[{"n":"self","d":null,"kind":"p"},{"n":"name","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"call","f":{"k":"name","id":"any"},"a":[{"k":"comp","e":{"k":"cmp","l":{"k":"attr","o":{"k":"name","id":"p"},"a":"name"},"ops":["=="],"r":[{"k":"name","id":"name"}]},"t":{"k":"name","id":"p"},"it":{"k":"attr","o":{"k":"name","id":"self"},"a":"perks"},"if":[]}],"kw":{}}}],"decos":[]},{"op":"def","name":"add","params":[{"n":"self","d":null,"kind":"p"},{"n":"perk","d":null,"kind":"p"}],"body":[{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"attr","o":{"k":"name","id":"self"},"a":"perks"},"a":"append"},"a":[{"k":"name","id":"perk"}],"kw":{}}},{"op":"return","v":{"k":"name","id":"self"}}],"decos":[]},{"op":"def","name":"hp","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"attr","o":{"k":"name","id":"self"},"a":"_hp"}}],"decos":[{"k":"name","id":"property"}]},{"op":"def","name":"hp","params":[{"n":"self","d":null,"kind":"p"},{"n":"value","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"_hp"},"v":{"k":"call","f":{"k":"name","id":"max"},"a":[{"k":"const","v":0},{"k":"name","id":"value"}],"kw":{}}}],"decos":[{"k":"attr","o":{"k":"name","id":"hp"},"a":"setter"}]},{"op":"def","name":"make","params":[{"n":"name","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"call","f":{"k":"name","id":"Player"},"a":[{"k":"name","id":"name"}],"kw":{}}}],"decos":[{"k":"name","id":"staticmethod"}]},{"op":"def","name":"label","params":[{"n":"cls","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"cmp","l":{"k":"name","id":"cls"},"ops":["is"],"r":[{"k":"name","id":"Player"}]}}],"decos":[{"k":"name","id":"classmethod"}]}]},{"op":"class","name":"Hero","bases":[{"k":"name","id":"Player"}],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"name","d":null,"kind":"p"}],"body":[{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"call","f":{"k":"name","id":"super"},"a":[],"kw":{}},"a":"__init__"},"a":[{"k":"name","id":"name"},{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"brave"}],"kw":{}}],"kw":{"role":{"k":"const","v":"hero"}}}},{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"level"},"v":{"k":"const","v":1}}],"decos":[]},{"op":"def","name":"has_perk","params":[{"n":"self","d":null,"kind":"p"},{"n":"name","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"bool","op":"or","v":[{"k":"cmp","l":{"k":"name","id":"name"},"ops":["=="],"r":[{"k":"const","v":"legend"}]},{"k":"call","f":{"k":"attr","o":{"k":"call","f":{"k":"name","id":"super"},"a":[],"kw":{}},"a":"has_perk"},"a":[{"k":"name","id":"name"}],"kw":{}}]}}],"decos":[]}]},{"op":"set","t":{"k":"name","id":"total"},"v":{"k":"const","v":0}},{"op":"def","name":"bump","params":[{"n":"step","d":{"k":"const","v":1},"kind":"p"},{"n":"scale","d":{"k":"const","v":2},"kind":"k"}],"body":[{"op":"global","names":["total"]},{"op":"aug","t":{"k":"name","id":"total"},"o":"+","v":{"k":"bin","op":"*","l":{"k":"name","id":"step"},"r":{"k":"name","id":"scale"}}},{"op":"return","v":{"k":"name","id":"total"}}],"decos":[]},{"op":"def","name":"fact","params":[{"n":"n","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"if","c":{"k":"cmp","l":{"k":"name","id":"n"},"ops":["<="],"r":[{"k":"const","v":1}]},"t":{"k":"const","v":1},"e":{"k":"bin","op":"*","l":{"k":"name","id":"n"},"r":{"kLine truncated
|
||||||
Vendored
+103
@@ -0,0 +1,103 @@
|
|||||||
|
# Source of classes.ir.json. Regenerate from backend/ with:
|
||||||
|
# python -m tests.make_ir ../frontend/tests/fixtures/classes.py ../frontend/tests/fixtures/classes.ir.json
|
||||||
|
# The Python below is also valid CPython, so expected values can be checked there.
|
||||||
|
|
||||||
|
|
||||||
|
class Perk(object):
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
def __init__(self, name, cost=1):
|
||||||
|
self.name = name
|
||||||
|
self.cost = cost
|
||||||
|
Perk.count += 1
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Perk(%s)" % self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Player(object):
|
||||||
|
def __init__(self, name, *perks, **extra):
|
||||||
|
self.name = name
|
||||||
|
self.perks = list(perks)
|
||||||
|
self.extra = extra
|
||||||
|
self._hp = 10
|
||||||
|
|
||||||
|
def has_perk(self, name):
|
||||||
|
return any([p.name == name for p in self.perks])
|
||||||
|
|
||||||
|
def add(self, perk):
|
||||||
|
self.perks.append(perk)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hp(self):
|
||||||
|
return self._hp
|
||||||
|
|
||||||
|
@hp.setter
|
||||||
|
def hp(self, value):
|
||||||
|
self._hp = max(0, value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def make(name):
|
||||||
|
return Player(name)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def label(cls):
|
||||||
|
return cls is Player
|
||||||
|
|
||||||
|
|
||||||
|
class Hero(Player):
|
||||||
|
def __init__(self, name):
|
||||||
|
super().__init__(name, Perk("brave"), role="hero")
|
||||||
|
self.level = 1
|
||||||
|
|
||||||
|
def has_perk(self, name):
|
||||||
|
return name == "legend" or super().has_perk(name)
|
||||||
|
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
|
||||||
|
def bump(step=1, *, scale=2):
|
||||||
|
global total
|
||||||
|
total += step * scale
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def fact(n):
|
||||||
|
return 1 if n <= 1 else n * fact(n - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def counter():
|
||||||
|
n = 0
|
||||||
|
|
||||||
|
def inc():
|
||||||
|
nonlocal n
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
return inc
|
||||||
|
|
||||||
|
|
||||||
|
hero = Hero("Ann")
|
||||||
|
hero.add(Perk("fast", cost=3))
|
||||||
|
hero.hp = -5
|
||||||
|
call_args = [2]
|
||||||
|
call_kw = {"scale": 10}
|
||||||
|
result_brave = hero.has_perk("brave")
|
||||||
|
result_legend = hero.has_perk("legend")
|
||||||
|
result_missing = hero.has_perk("nope")
|
||||||
|
result_count = Perk.count
|
||||||
|
result_str = str(Perk("x"))
|
||||||
|
result_hp = hero.hp
|
||||||
|
result_role = hero.extra["role"]
|
||||||
|
result_static = Player.make("Bob").name
|
||||||
|
result_cls = Player.label()
|
||||||
|
result_bump = bump(*call_args, **call_kw)
|
||||||
|
result_total = total
|
||||||
|
result_fact = fact(6)
|
||||||
|
tick = counter()
|
||||||
|
tick()
|
||||||
|
result_closure = tick()
|
||||||
|
result_lambda = sorted([3, 1, 2], key=lambda v: -v)
|
||||||
|
result_isinstance = [isinstance(hero, Player), isinstance(hero, Perk), isinstance(1, int)]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { build } from "esbuild";
|
||||||
|
|
||||||
|
const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/python.ts", import.meta.url))],
|
||||||
|
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
|
||||||
|
const { Python, PyDict, userBuiltins, encodeValue, decodeValue, pyStr } =
|
||||||
|
await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
|
||||||
|
|
||||||
|
const ir = (name) => JSON.parse(readFileSync(new URL(`./fixtures/${name}.ir.json`, import.meta.url), "utf8"));
|
||||||
|
|
||||||
|
function runtime() {
|
||||||
|
const store = Object.create(null);
|
||||||
|
const builtins = {
|
||||||
|
any: (xs) => xs.some(Boolean), max: Math.max, int: Number, str: pyStr, list: (xs = []) => [...xs],
|
||||||
|
};
|
||||||
|
const scope = {
|
||||||
|
get: (n) => (n in store ? store[n] : builtins[n]),
|
||||||
|
has: (n) => n in store || n in builtins,
|
||||||
|
set: (n, v) => { store[n] = v; },
|
||||||
|
del: (n) => { delete store[n]; },
|
||||||
|
};
|
||||||
|
const py = new Python(scope);
|
||||||
|
Object.assign(builtins, userBuiltins(py));
|
||||||
|
return { py, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("user classes, functions, closures and lambdas behave like CPython", () => {
|
||||||
|
const { py, store } = runtime();
|
||||||
|
py.exec(ir("classes"));
|
||||||
|
assert.equal(store.result_brave, true);
|
||||||
|
assert.equal(store.result_legend, true);
|
||||||
|
assert.equal(store.result_missing, false);
|
||||||
|
assert.equal(store.result_count, 2);
|
||||||
|
assert.equal(store.result_str, "Perk(x)");
|
||||||
|
assert.equal(store.result_hp, 0); // property setter clamps
|
||||||
|
assert.equal(store.result_role, "hero"); // **kwargs
|
||||||
|
assert.equal(store.result_static, "Bob");
|
||||||
|
assert.equal(store.result_cls, true); // classmethod gets the class
|
||||||
|
assert.equal(store.result_bump, 20); // *args / **kwargs call and `global`
|
||||||
|
assert.equal(store.total, 20);
|
||||||
|
assert.equal(store.result_fact, 720);
|
||||||
|
assert.equal(store.result_closure, 2); // nonlocal
|
||||||
|
assert.deepEqual(store.result_lambda, [3, 2, 1]);
|
||||||
|
assert.deepEqual(store.result_isinstance, [true, false, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("instances survive a save round trip once the class is defined again", () => {
|
||||||
|
const { py, store } = runtime();
|
||||||
|
py.exec(ir("classes"));
|
||||||
|
const saved = JSON.parse(JSON.stringify(encodeValue({ hero: store.hero, dict: new PyDict([["a", store.hero]]) })));
|
||||||
|
assert.equal(saved.hero.__inst__, "Hero");
|
||||||
|
assert.equal("Hero" in saved, false);
|
||||||
|
const restored = decodeValue(saved);
|
||||||
|
assert.equal(restored.hero.cls.name, "Hero");
|
||||||
|
store.hero = restored.hero;
|
||||||
|
assert.equal(py.eval({ k: "call", f: { k: "attr", o: { k: "name", id: "hero" }, a: "has_perk" },
|
||||||
|
a: [{ k: "const", v: "fast" }], kw: {} }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the sandbox still refuses native members through user objects", () => {
|
||||||
|
const { py, store } = runtime();
|
||||||
|
py.exec(ir("classes"));
|
||||||
|
const hero = { k: "name", id: "hero" };
|
||||||
|
for (const key of ["constructor", "__proto__", "__class__", "__dict__", "prototype"]) {
|
||||||
|
assert.equal(py.eval({ k: "attr", o: hero, a: key }), null);
|
||||||
|
assert.throws(() => py.bind({ k: "attr", o: hero, a: key }, 1), /недоступ/);
|
||||||
|
}
|
||||||
|
assert.equal(Object.getPrototypeOf(store.hero.attrs), null);
|
||||||
|
assert.equal({}.injected, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unbounded recursion and bad calls fail with explicit errors", () => {
|
||||||
|
const { py } = runtime();
|
||||||
|
py.exec([{ op: "def", name: "loop", params: [], decos: [], body: [{ op: "return",
|
||||||
|
v: { k: "call", f: { k: "name", id: "loop" }, a: [], kw: {} } }] }]);
|
||||||
|
assert.throws(() => py.eval({ k: "call", f: { k: "name", id: "loop" }, a: [], kw: {} }), /рекурси/);
|
||||||
|
py.exec([{ op: "def", name: "one", params: [{ n: "x", d: null, kind: "p" }], decos: [], body: [] }]);
|
||||||
|
assert.throws(() => py.eval({ k: "call", f: { k: "name", id: "one" }, a: [], kw: {} }), /не хватает/);
|
||||||
|
assert.throws(() => py.eval({ k: "call", f: { k: "name", id: "one" },
|
||||||
|
a: [{ k: "const", v: 1 }, { k: "const", v: 2 }], kw: {} }), /слишком много/);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user