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,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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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