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

+2 -2
View File
@@ -1,7 +1,7 @@
// PGVN interpreter + in-game UI.
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 { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
import { languageName, rememberLanguage, type Translator } from "./i18n";
@@ -352,9 +352,9 @@ export class Engine {
reversed: (xs: any[]) => [...xs].reverse(),
enumerate: (xs: any[]) => [...xs].map((x, i) => [i, x]),
zip: (...lists: any[][]) => lists[0].map((_, i) => lists.map((l) => l[i])),
isinstance: () => false,
_: (s: string) => s,
__: (s: string) => this.tr.str(s),
...userBuiltins(this.py),
};
}
+376 -6
View File
@@ -34,9 +34,81 @@ export class JumpSignal {
class BreakSignal {}
class ContinueSignal {}
class ReturnSignal {
constructor(public value: any) {}
}
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 {
if (v === null || v === undefined || v === false || v === 0 || v === "") return false;
if (Array.isArray(v)) return v.length > 0;
@@ -59,6 +131,7 @@ export function pyStr(v: any): string {
if (v === false) return "False";
if (typeof v === "number") return Number.isInteger(v) ? String(v) : String(v);
if (Array.isArray(v)) return "[" + v.map(pyRepr).join(", ") + "]";
if (v instanceof PyInstance) return instanceText(v);
if (v instanceof PyDict)
return "{" + [...v.map.entries()].map(([k, x]) => `${pyRepr(k)}: ${pyRepr(x)}`).join(", ") + "}";
return String(v);
@@ -253,8 +326,12 @@ export interface Scope {
export class Python {
locals: Map<string, any>[] = [];
warned = new Set<string>();
fn: FnCtx | null = null;
depth = 0;
constructor(public scope: Scope) {}
constructor(public scope: Scope) {
activePython = this;
}
warnOnce(msg: string) {
if (!this.warned.has(msg)) {
@@ -283,6 +360,12 @@ export class Python {
return this.lookup(e.id);
case "attr": {
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 (o === null || o === undefined) {
this.warnOnce(`атрибут ${e.a} у None`);
@@ -356,16 +439,36 @@ export class Python {
}
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": {
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 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> = {};
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") {
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
return null;
}
return Object.keys(kw).length ? f(...args, { __kw: kw }) : f(...args);
return f(...call);
}
}
this.warnOnce(`неизвестное выражение ${e.k}`);
@@ -377,7 +480,7 @@ export class Python {
case "name":
if (!safeProperty(t.id)) throw new Error("Служебное имя недоступно");
if (frame) frame.set(t.id, value);
else this.scope.set(t.id, value);
else this.bindName(t.id, value);
return;
case "tuple": {
const items = [...iterate(value)];
@@ -387,7 +490,14 @@ export class Python {
case "attr": {
const o = this.eval(t.o);
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;
return;
}
@@ -433,7 +543,10 @@ export class Python {
}
break;
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") {
const o = this.eval(s.t.o);
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);
}
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":
throw new BreakSignal();
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. */
private loopBody(body: PyStmt[]): boolean {
try {
@@ -478,9 +788,69 @@ export function decodeValue(v: any): any {
if (v && typeof v === "object") {
if (Array.isArray(v.__dict__))
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> = {};
for (const [k, x] of Object.entries(v)) out[k] = decodeValue(x);
return out;
}
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
View File
@@ -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
+103
View File
@@ -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)]
+84
View File
@@ -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: {} }), /слишком много/);
});