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

+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;
},
};
}