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