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