diff --git a/IMPLEMENTATION_PLAN_INTERPRETER.md b/IMPLEMENTATION_PLAN_INTERPRETER.md index aeac900..84b2dd7 100644 --- a/IMPLEMENTATION_PLAN_INTERPRETER.md +++ b/IMPLEMENTATION_PLAN_INTERPRETER.md @@ -796,3 +796,22 @@ matrix executions retain their existing scope. The unchanged console source totals 372,607 bytes (+62); its [diagnostic payload record](docs/measurements/2026-10-22-soft-keyword.json) retains all hashes and preceding measurements. No execution feature is added. + + +### Generator and object-protocol checkpoint (2026-10-26) + +The finite G1/G2 and O1/O2 contracts add owned suspended generator functions, +yield-from delegation, next/send/throw/close, isolated comprehensions, descriptor +attribute reflection and binary/reflected/in-place arithmetic. Both source +profiles keep their explicit selection and bytecode 4/5; class protocols remain +in classes-source/1. Node/Chromium draining and stepping, resource interruptions, +collection between instructions and offline source/Stop/Restart/update acceptance +are required. Existing composed resource baselines remain unchanged. + +See [generators](docs/interpreter-generators.md) and +[object protocols](docs/interpreter-object-protocols.md) for exact signatures, +reference cases and remaining boundaries. Construction, super, richer type +relations, metaclasses, builtin subclasses, custom attribute hooks, dynamic +source, comprehension assignment expressions and async remain separately gated. +The broader #120/#123 and #33/#34 milestones remain open; this checkpoint does +not select a publication, deployment, dependency or host capability. diff --git a/README.md b/README.md index 73b6421..32f9ecd 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,14 @@ memoryviews. Run `uv run python -m examples.interpreter_values`, or add `--core` for primitive keys. Broader set methods, bytearray resizing, buffer casts and native buffers retain explicit future boundaries. +The [generator profile](docs/interpreter-generators.md) adds owned suspended +functions, yield-from delegation, next/send/throw/close and scoped list/dict/set/ +generator comprehensions in both profiles. [Object protocols](docs/interpreter-object-protocols.md) +add attribute reflection and forward/reflected/in-place arithmetic in the class +profile. Run `python -m examples.interpreter_generators`, adding `--core` for +generators without classes. Construction, richer reflection and async retain +separate contracts. + The [continuing interpreter roadmap](docs/interpreter-roadmap.md) targets broad Python 3.14+ compatibility through finite supported profiles. Execution ownership is consolidated; the [coverage inventory](docs/interpreter-compatibility.md) diff --git a/docs/interpreter-compatibility.json b/docs/interpreter-compatibility.json index 658d879..2f2ffec 100644 --- a/docs/interpreter-compatibility.json +++ b/docs/interpreter-compatibility.json @@ -422,9 +422,9 @@ }, { "id": "further-object-protocols", - "status": "deferred", - "scope": "Arithmetic/reflected/in-place slots, __new__, metaclasses, custom attribute hooks and builtin subclassing.", - "gaps": "Each slice requires an adopted reference and owned continuation contract.", + "status": "partial", + "scope": "Binary/reflected/in-place arithmetic, unary +,-,~, divmod and power slots through owned descriptor callbacks, with subclass priority and NotImplemented fallback.", + "gaps": "Construction, super, richer type relations, metaclasses, custom attribute hooks, builtin subclassing and broader reflection remain separate contracts.", "reference_version": "3.14.7", "targets": [ "node", @@ -437,7 +437,10 @@ "metaclass", "builtin-super" ], - "evidence": [] + "evidence": [ + "tests/fixtures/interpreter/object-protocols.json:subclass-priority", + "tests/fixtures/interpreter/object-protocols.json:inplace-fallback" + ] }, { "id": "exceptions", @@ -1122,8 +1125,8 @@ { "id": "divmod", "status": "partial", - "scope": "Positional-only primitive int/float quotient and remainder with owned tuple results and Python sign/zero rules.", - "gaps": "General user arithmetic/reflected slots remain INT-OBJECT-01.", + "scope": "Positional-only primitive int/float quotient and remainder with owned tuple results and Python sign/zero rules. Adopted user forward/reflected slots use owned descriptor dispatch.", + "gaps": "Further object construction/reflection remain separately gated.", "reference_version": "3.14.7", "targets": [ "node", @@ -1135,7 +1138,9 @@ "probes": [ "builtin-divmod" ], - "evidence": [] + "evidence": [ + "tests/fixtures/interpreter/object-protocols.json:power-divmod" + ] }, { "id": "enumerate", @@ -1721,8 +1726,8 @@ { "id": "pow", "status": "partial", - "scope": "Two-argument real/integer power, modular integer power/inverse, and complex integer powers from -100 to 100 with finite intermediates; named base/exp/mod keywords.", - "gaps": "Remaining complex power/recovery families are INT-NUM-02; user arithmetic slots are INT-OBJECT-01.", + "scope": "Two-argument real/integer power, modular integer power/inverse, and complex integer powers from -100 to 100 with finite intermediates; named base/exp/mod keywords. Adopted user forward/reflected slots use owned descriptor dispatch.", + "gaps": "Remaining complex power/recovery families are INT-NUM-02; User-valued power modulus and remaining construction/reflection remain separately gated.", "reference_version": "3.14.7", "targets": [ "node", @@ -1735,7 +1740,9 @@ "probes": [ "builtin-pow" ], - "evidence": [] + "evidence": [ + "tests/fixtures/interpreter/object-protocols.json:power-divmod" + ] }, { "id": "print", diff --git a/docs/interpreter-compatibility.md b/docs/interpreter-compatibility.md index 4b28868..f151988 100644 --- a/docs/interpreter-compatibility.md +++ b/docs/interpreter-compatibility.md @@ -67,7 +67,7 @@ issue families; availability alone never certifies an entire type or module. | indexed-user-objects | partial | __index__/__getitem__/__setitem__/__delitem__ through current type lookup and owned callbacks; raw scalar/tuple/slice keys, builtin sequence conversion, __len__ index results and bounded list/tuple RHS slice mutation. Other iterable RHS slice assignment, slice repr/hash/indices, builtin subclasses and broader numeric/sequence APIs remain deferred. | | iterated-user-objects | partial | Owned __iter__/__next__/__contains__, indexed fallback, iter/next signatures and iterator membership search in classes-source/1. Generators/async protocols, broader iterable-consuming APIs and ordering remain separate. | | user-equality-hash | partial | Owned __eq__/__ne__/__hash__, NotImplemented, reflected priority, bounded container equality and user dictionary keys. Structural dictionary mutation during comparison, tuples containing user instances, bound-method/slice hashing and ordering remain separately gated. | -| further-object-protocols | deferred | Arithmetic/reflected/in-place slots, __new__, metaclasses, custom attribute hooks and builtin subclassing. Each slice requires an adopted reference and owned continuation contract. | +| further-object-protocols | partial | Owned binary/reflected/in-place and unary arithmetic slots, divmod and power. Construction, super, metaclasses, builtin subclasses and custom attribute hooks remain separate. | | exceptions | partial | Adopted builtin exception classes, catch/raise/bare reraise, try/except/else/finally, active context and source tracebacks. Custom exception inheritance, explicit causes/suppression and exception groups/except* remain deferred. | | imports | partial | Static absolute/relative imports from a finite granted source registry, packages, identity/cache, initialization order, cycles and failed-init retry. No ambient filesystem/sys.path/PyPI loader, dynamic __import__ or import hooks. Missing grants raise IMPORT/ImportError; that is an authority boundary, not proof of a missing language API. | | generators-comprehensions | partial | Owned generator functions, yield/yield from, next/send/throw/close and shared budgets. Scoped list/dict/set/generator comprehensions are implemented; async/reflection and comprehension assignment expressions remain separate stages. | @@ -75,7 +75,7 @@ issue families; availability alone never certifies an entire type or module. | annotations-generics | deferred | Python 3.14 annotation timing, type parameters, type aliases and required generic aliases. Annotations and generic syntax are not adopted. Recognized type aliases receive UNSUPPORTED without execution support. | | formatted-strings | deferred | f-strings and formatting grammar; t-strings, Template/Interpolation and string.templatelib APIs. Tokenizer rejects these literal forms. Server-side t-string use by a consumer does not establish browser interpreter support. | | async-language | deferred | Native coroutine/await semantics, async iteration/generators/context managers and bounded scheduling/cancellation. Owned instruction stepping is implemented, but multiple parked owners, persistent events and native coroutine syntax are not. Host Promises are not Python coroutines. | -| dynamic-code-reflection | deferred | compile/eval/exec, dynamic imports and broader globals/locals/type/attribute reflection. Source admission, namespace/identity and target authority need separate explicit contracts. | +| dynamic-code-reflection | partial | getattr/setattr/delattr/hasattr through owned class descriptors. Dynamic source, namespaces and broader reflection remain deferred. | | selected-library-apis | partial | core-only/1 has no library modules; operator/1 includes exactly add, sub, mul, floordiv, mod, eq, lt, neg, not_, truth, getitem from own Python source. No whole-stdlib claim. json/math/random and licensed pure-Python colorsys API pilots require qualification. collections/itertools/functools/dataclasses/typing remain later API-selection decisions. | | real-package-certification | deferred | A pinned package and exact entry-point use with source/resource/license closure, original/reduced differential execution and offline checks. No general package certification. Shimify reachability is not interpreter compatibility; spatial_ui/expose_api and compiler integrations retain consumer-specific contracts. | | browser-capabilities | host-restricted | Offline browser worker with explicit source/assets/import grants; no ambient Python process/filesystem/network/device capabilities. Durable sessions/storage, event/view adapters and one bounded awaited host capability remain under INT-10. Device inference needs separate real-device evidence in APP-MODEL-01. | diff --git a/docs/interpreter-generators.md b/docs/interpreter-generators.md index ad0f2b8..9a3f1a4 100644 --- a/docs/interpreter-generators.md +++ b/docs/interpreter-generators.md @@ -34,3 +34,18 @@ roots and external leases. These logical observations make no host RSS claim. No new host capability, runtime dependency, publication or deployment is included. See the [yield expression and generator method reference](https://docs.python.org/3.14/reference/expressions.html#yield-expressions). + +## Acceptance + +The corpus contains 28 adopted class comparisons and three explicit refusals; +the shared core selection has 25 comparisons and three refusals. CI runs all +eight target/profile/driver combinations and requires zero logical teardown. +Dedicated tests collect between every instruction, retain suspended aliases and +cycles, and interrupt execution in generator bodies and comprehension callbacks. +The existing eight composed resource baselines remain unchanged. + +Run `python -m examples.interpreter_generators` to build a composed console; +`--core` selects generators without class protocols. Offline acceptance covers +new source after reload, method/body errors, hard Stop/Restart and waiting +class-to-core updates. Assignment expressions in comprehensions, multi-argument +throw, generator frame introspection and async retain their future boundaries. diff --git a/docs/interpreter-object-protocols.md b/docs/interpreter-object-protocols.md index 75a4d01..b81c330 100644 --- a/docs/interpreter-object-protocols.md +++ b/docs/interpreter-object-protocols.md @@ -2,7 +2,7 @@ INT-OBJECT-01 adopts finite slices against CPython 3.14.7. The authored `object-protocols.json` reference must also execute independently in Node and -Chromium, draining and instruction-boundary drivers. O1 is implemented; O2 is the next stage. Attribute names can be arbitrary strings +Chromium, draining and instruction-boundary drivers. O1 and O2 are implemented. Attribute names can be arbitrary strings within the 256-code-unit name quota. Classes remain explicit in classes-source/1; core-source/1 keeps primitive arithmetic and generator support. @@ -10,7 +10,7 @@ classes-source/1; core-source/1 keeps primitive arithmetic and generator support | --- | --- | | O1 | getattr(object, name[, default]), hasattr(object, name), setattr(object, name, value), delattr(object, name) on admitted class attributes. Existing descriptor precedence, Python callbacks and metadata rules apply. Names must be strings; only AttributeError is suppressed by defaults/hasattr. No lexical mangling is applied to dynamic strings. | | O2 | Binary/reflected/in-place slots for +, -, *, /, //, %, **, shifts and bitwise operators; unary +, -, ~; divmod and two/three-argument pow. Strict right-subclass reflected overrides have priority, same-type reflected fallback is suppressed, NotImplemented advances dispatch, and in-place slots fall back to binary dispatch. Results can be arbitrary owned Python values. | -| Future | __new__, super, richer type/isinstance/issubclass, builtin subclasses, metaclasses, custom attribute hooks, ordering, dir/vars/globals/locals/id, and dynamic exec/eval/compile/import remain independently gated. Matrix multiplication requires a separate source/operator slice. | +| Future | __new__, super, richer type/isinstance/issubclass, builtin subclasses, metaclasses, custom attribute hooks, ordering, dir/vars/globals/locals/id, and dynamic exec/eval/compile/import remain independently gated. Matrix multiplication, user-valued power modulus dispatch and ordinary list/tuple/text repetition require separate slices. Existing bytes repetition and list in-place repetition accept the adopted index hook. | Implicit operations consult type/MRO slots and descriptor binding, ignoring instance shadow attributes. Signature and callback failures propagate; a method @@ -25,3 +25,18 @@ This delivery does not complete INT-OBJECT-01, INT-09, native extensions or asyn host integration. No publication, deployment, service or dependency is selected. See [numeric emulation](https://docs.python.org/3.14/reference/datamodel.html#emulating-numeric-types) and [attribute builtins](https://docs.python.org/3.14/library/functions.html#getattr). + +## Acceptance + +The corpus contains 44 adopted comparisons and two explicit construction/hook +refusals. CI runs Node and Chromium through draining and instruction-boundary +drivers. Dedicated tests cover all twelve binary/reflected/in-place operator +families, unary hooks, subclass priority, descriptor binding, sequence index +fallback, source errors, recursion/work/allocation limits, cancellation and +collection between instructions. All continuations retain complete operand and +outer-destination roots; logical teardown must be zero. + +The generator console composes these protocols with suspended source functions +and comprehensions. Offline acceptance includes freshly entered source, callback +errors and Stop/Restart. INT-OBJECT-01 remains open for its construction, super, +type-relation, metaclass, builtin-subclass and broader reflection slices. diff --git a/examples/interpreter_generators.py b/examples/interpreter_generators.py new file mode 100644 index 0000000..dba13be --- /dev/null +++ b/examples/interpreter_generators.py @@ -0,0 +1,63 @@ +"""Build a checkout console demonstrating generators and object protocols.""" + +import argparse +from pathlib import Path + +from examples.interpreter_console import write_source +from pocket_python.interpreter import build_interpreter, read_interpreter + +ROOT = Path(__file__).resolve().parents[1] +CORE_SOURCE = '''def squares(limit): + try: + for number in range(limit): + yield number * number + finally: + print("finished") + +odd = [number for number in squares(5) if number % 2] +print("odd squares", len(odd), odd[0], odd[1]) +stream = (number + 10 for number in range(3)) +print("stream", next(stream), stream.send(None)) +stream.close() + +def child(): + value = yield 2 + return value + 3 + +def parent(): + result = yield from child() + yield result + +stream = parent() +print("delegation", next(stream), stream.send(4)) +stream.close() +''' +SOURCE = CORE_SOURCE + ''' +class Counter: + def __init__(self, value): self.value = value + def __add__(self, other): return Counter(self.value + other) + def __radd__(self, other): return self + other + def __iadd__(self, other): + self.value += other + return self + @property + def label(self): return str(self.value) + +counter = Counter(4) +alias = counter +counter += 3 +print("counter", counter is alias, getattr(counter, "label"), (2 + counter).value) +print("attributes", hasattr(counter, "missing"), getattr(counter, "missing", 9)) +''' + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source', type=Path, default=Path('build/generators-console-source')) + parser.add_argument('--output', type=Path, default=Path('build/generators-console')) + parser.add_argument('--core', action='store_true', help='Use generators without class protocols.') + args = parser.parse_args() + receipt = build_interpreter(write_source(args.source, source=CORE_SOURCE if args.core else SOURCE), + read_interpreter(ROOT / 'runtime'), args.output, + language_profile='core-source/1' if args.core else 'classes-source/1', + title='Python Generators Console') + print(f"Created {args.output.resolve()} ({receipt['size']['artifact_bytes']} bytes)") diff --git a/runtime/browser-host.test.mjs b/runtime/browser-host.test.mjs index dd4d1a6..a601723 100644 --- a/runtime/browser-host.test.mjs +++ b/runtime/browser-host.test.mjs @@ -58,7 +58,7 @@ test("explicit class identity selects v5, composes libraries and keeps each run await app.run('import operator\nclass A:\n def __init__(self,n): self.n=n\n def add(self,x): return operator.add(self.n,x)\nprint(A(7).add(5))\n'); assert.equal(output,'12\n'); await assert.rejects(app.run('A()'),error=>error.name==='NameError'); - await assert.rejects(app.run("print('never')\nclass A:\n def __add__(self,other): return 7\n"),error=>error.code==='UNSUPPORTED'); + await assert.rejects(app.run("print('never')\nclass A:\n def __matmul__(self,other): return 7\n"),error=>error.code==='UNSUPPORTED'); assert.equal(output,'12\n');await app.run('class B: pass\nprint(type(B()) is B)\n'); assert.equal(output,'12\nTrue\n');assert.equal(app.stats().pending,0); }finally{app.stop();} diff --git a/runtime/class-policy.mjs b/runtime/class-policy.mjs index b9123df..dd080db 100644 --- a/runtime/class-policy.mjs +++ b/runtime/class-policy.mjs @@ -1,9 +1,14 @@ // Admission is separate from descriptor binding and scoped metadata validation. export const DESCRIPTOR_HOOKS = Object.freeze(["__get__", "__set__", "__delete__", "__set_name__"]); +export const ARITHMETIC_SLOTS = Object.freeze({add: "add", subtract: "sub", multiply: "mul", divide: "truediv", "floor-divide": "floordiv", + modulo: "mod", power: "pow", "bit-and": "and", "bit-or": "or", "bit-xor": "xor", "left-shift": "lshift", "right-shift": "rshift", divmod: "divmod"}); const contexts = new Set(["define", "attribute", "mutate", "implicit"]); const ordinarySlot = Object.freeze({define: true, attribute: true, mutate: true, implicit: false}); export const CLASS_SPECIAL_POLICY = Object.freeze(Object.fromEntries([ ...["__call__", "__len__", "__bool__", "__repr__", "__str__", "__index__", "__bytes__", "__getitem__", "__setitem__", "__delitem__", "__iter__", "__next__", "__contains__", "__eq__", "__ne__", "__hash__", "__int__", "__float__", "__complex__", "__abs__", "__round__"].map(name => [name, Object.freeze({...ordinarySlot, implicit: true})]), + ...Object.values(ARITHMETIC_SLOTS).flatMap(stem => ["", "r", ...(stem === "divmod" ? [] : ["i"])].map(prefix => + ["__" + prefix + stem + "__", Object.freeze({...ordinarySlot, implicit: true})])), + ...["__neg__", "__pos__", "__invert__"].map(name => [name, Object.freeze({...ordinarySlot, implicit: true})]), ["__init__", ordinarySlot], ...DESCRIPTOR_HOOKS.map(name => [name, ordinarySlot]), ["__module__", Object.freeze({define: true})], ["__qualname__", Object.freeze({define: true})] ])); diff --git a/runtime/class-policy.test.mjs b/runtime/class-policy.test.mjs index 4c50b6f..86cc9cf 100644 --- a/runtime/class-policy.test.mjs +++ b/runtime/class-policy.test.mjs @@ -8,7 +8,7 @@ const profile = {languageProfile: 'classes-source/1'}; test('shared policy is immutable and keeps metadata distinct from ordinary slot admission', () => { assert.ok(Object.isFrozen(CLASS_SPECIAL_POLICY)); assert.ok(Object.isFrozen(DESCRIPTOR_HOOKS)); assert.throws(() => { CLASS_SPECIAL_POLICY.__init__.mutate = false; }, TypeError); - assert.throws(() => { CLASS_SPECIAL_POLICY.__add__ = {}; }, TypeError); + assert.throws(() => { CLASS_SPECIAL_POLICY.__matmul__ = {}; }, TypeError); for (const name of ['__init__', '__call__', '__len__', '__bool__', '__repr__', '__str__', '__index__', '__getitem__', '__setitem__', '__delitem__', ...DESCRIPTOR_HOOKS]) for (const context of ['define', 'attribute', 'mutate']) assert.equal(allowsClassSpecial(name, context), true); for (const name of ['__call__', '__len__', '__bool__', '__repr__', '__str__', '__index__', '__getitem__', '__setitem__', '__delitem__']) assert.equal(allowsClassSpecial(name, 'implicit'), true); @@ -22,7 +22,7 @@ test('shared policy is immutable and keeps metadata distinct from ordinary slot test('source and direct class records reject unadopted protocols through the same policy', () => { const runtime = new ClassRuntime(), root = runtime.root(); - for (const name of ['__add__', '__format__', '__new__', '__getattribute__']) { + for (const name of ['__matmul__', '__format__', '__new__', '__getattribute__']) { assert.equal(allowsClassSpecial(name, 'define'), false); assert.throws(() => compileSource('print("never")\nclass A:\n '+name+'=None\n', profile), {code: 'UNSUPPORTED'}); assert.throws(() => runtime.create('A', 'A', 'app', [], new Map([[name, null]]), root), {code: 'UNSUPPORTED'}); diff --git a/runtime/class-values.test.mjs b/runtime/class-values.test.mjs index 822f261..4285633 100644 --- a/runtime/class-values.test.mjs +++ b/runtime/class-values.test.mjs @@ -75,7 +75,7 @@ test("identity/truth, metadata and explicit unsupported boundaries remain separa assert.throws(() => s.runtime.get(a, "__dict__"), {code: "UNSUPPORTED"}); assert.throws(() => s.runtime.mutate(A, "__bases__", null), {code: "UNSUPPORTED"}); assert.throws(() => s.runtime.mutate(s.root, "value", 7n), {name: "TypeError"}); - assert.throws(() => s.make("Special", [], new Map([["__add__", method("f")]])), {code: "UNSUPPORTED"}); + assert.throws(() => s.make("Special", [], new Map([["__matmul__", method("f")]])), {code: "UNSUPPORTED"}); assert.throws(() => s.make("BadInit", [], new Map([["__init__", A]])), {code: "UNSUPPORTED"}); assert.throws(() => s.runtime.mutate(A, "__init__", s.runtime.make(BoundMethodValue, A, a)), {code: "UNSUPPORTED"}); assert.throws(() => s.runtime.subclass(a, A), {name: "TypeError"}); diff --git a/runtime/classes-source.test.mjs b/runtime/classes-source.test.mjs index 4b1ed6c..9e54061 100644 --- a/runtime/classes-source.test.mjs +++ b/runtime/classes-source.test.mjs @@ -43,7 +43,7 @@ test('metadata overrides and implicit class cells refuse before source effects', 'class A:\n __module__="custom"\n', 'class A:\n def __qualname__(self): pass\n', 'class A:\n from helper import value as __module__\n', - 'class A:\n def __add__(self,x): return 7\n', + 'class A:\n def __matmul__(self,x): return 7\n', 'class A: pass\na=A()\na.__class__=A\n', 'class A(metaclass=type): pass\n','class A[T]: pass\n','class A(*bases): pass\n', 'async def f(): pass\n' diff --git a/runtime/functions.mjs b/runtime/functions.mjs index 9b4b6bd..bebac1f 100644 --- a/runtime/functions.mjs +++ b/runtime/functions.mjs @@ -1,3 +1,4 @@ +import {ARITHMETIC_SLOTS} from "./class-policy.mjs"; import {verifyFunctions} from "./function-bytecode.mjs"; import {GeneratorValue, Cell, Environment, CodePool, ModuleValue, FunctionValue, CallArguments, BuiltinValue, AppendValue, ValueMethod, ExceptionValue, EXCEPTIONS, matchesException, ClassValue, InstanceValue, BoundMethodValue, PropertyValue, MethodWrapperValue, ProtocolIteratorValue, NotImplementedValue} from "./function-objects.mjs"; import {ClassRuntime} from "./class-values.mjs"; @@ -6,7 +7,7 @@ import {MemoryArena} from "./memory.mjs"; import {ExecutionError} from "./vm.mjs"; import {isModuleContext} from "./module-context.mjs"; import {RegistryError} from "./module-registry.mjs"; -import {completion as continuation, completionRoots, unaryTask, itemTask, sliceTask, containsTask, compareTask, dictionaryTask, dictionaryBuildTask, consumeTask, controlRoots, retainTransfer} from "./operations.mjs"; +import {completion as continuation, completionRoots, unaryTask, itemTask, sliceTask, containsTask, compareTask, dictionaryTask, dictionaryBuildTask, arithmeticTask, consumeTask, controlRoots, retainTransfer} from "./operations.mjs"; import {Raised, operationRuntime} from "./operation-runtime.mjs"; class Unhandled { constructor(value) { this.value = value; } } @@ -616,11 +617,9 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = else if (["less", "less-equal", "greater", "greater-equal"].includes(op) && frame.callable.pool.code.version === 5 && (values.hasUserEquality(stack.at(-2)) || values.hasUserEquality(stack.at(-1)))) hard("UNSUPPORTED", "UnsupportedOperationError", "Ordering with user values requires its own comparison stage."); - else if (op === "inplace-add" && stack.at(-1) instanceof InstanceValue && values.isList(stack.at(-2))) { - const right = stack.pop(), left = stack.pop(), iterator = classes.lookup(right.type, "__iter__"); - if (iterator.found ? iterator.value !== null : classes.lookup(right.type, "__getitem__").found) - hard("UNSUPPORTED", "UnsupportedOperationError", "List += with a user iterable requires its own sequence stage."); - stack.push(values.inplace("add", left, right)); + else if (Object.hasOwn(ARITHMETIC_SLOTS, op.startsWith("inplace-") ? op.slice(8) : op) && + (stack.at(-2) instanceof InstanceValue || stack.at(-1) instanceof InstanceValue)) { + const right = stack.pop(), left = stack.pop(); dispatch(arithmeticTask(op, [left, right], summary(frame, cursor))); } else { const right = stack.pop(), left = stack.pop(); stack.push(op.startsWith("inplace-") ? values.inplace(op.slice(8), left, right) : values.binary(op, left, right)); } } catch (error) { handle(error); } diff --git a/runtime/indexing.test.mjs b/runtime/indexing.test.mjs index c9820df..dc256b7 100644 --- a/runtime/indexing.test.mjs +++ b/runtime/indexing.test.mjs @@ -100,7 +100,7 @@ test('default source profile keeps class and slice-constructor admission explici const core=await runSourceRequest({modules:[unit('print("A😀"[::-1])\n')]}); assert.equal(core.stdout,'😀A\n'); closed(core); const refused=await runSourceRequest({modules:[unit('slice(7)\n')]}); assert.equal(refused.error.type,'NameError'); closed(refused); - const future=await run('print("never")\nclass A:\n def __add__(self,other): return 7\n'); assert.equal(future.error.code,'UNSUPPORTED'); assert.equal(future.stdout,''); + const future=await run('print("never")\nclass A:\n def __matmul__(self,other): return 7\n'); assert.equal(future.error.code,'UNSUPPORTED'); assert.equal(future.stdout,''); }); test('mutation continuations retain the RHS and outer completion with exhaustive roots', () => { diff --git a/runtime/length-truth.test.mjs b/runtime/length-truth.test.mjs index 77607fe..d17af8b 100644 --- a/runtime/length-truth.test.mjs +++ b/runtime/length-truth.test.mjs @@ -106,6 +106,6 @@ test('memory exhaustion inside a truth binding unwinds pending operands and rece test('primitive core behavior and future protocol refusal remain explicit', async () => { const core = await runSourceRequest({modules: [unit('print(len("A😀"),bool([]),not 0)\ni=2\nwhile i: i-=1\nelse: print(i)\n')]}); assert.equal(core.error, undefined); assert.equal(core.stdout, '2 False True\n0\n'); closed(core); - const future = await run('print("never")\nclass Index:\n def __add__(self,other): return 7\n'); + const future = await run('print("never")\nclass Index:\n def __matmul__(self,other): return 7\n'); assert.equal(future.error.code, 'UNSUPPORTED'); assert.equal(future.stdout, ''); assert.equal(future.stats, null); }); diff --git a/runtime/numeric-callbacks.test.mjs b/runtime/numeric-callbacks.test.mjs index 0808fc8..ed8ad77 100644 --- a/runtime/numeric-callbacks.test.mjs +++ b/runtime/numeric-callbacks.test.mjs @@ -19,7 +19,7 @@ test('numeric continuations retain every operand and outer destination with clos assert.deepEqual(operationRoots(numericTask('round',[receiver,digits],null,after)),[receiver,digits,pending]); assert.throws(()=>numericTask('future',[],null),TypeError); assert.throws(()=>numericTask('round',[receiver],null,null,0,3),TypeError); - assert.throws(()=>completion('numeric-result',{...fields,slot:'__add__'}),TypeError); + assert.throws(()=>completion('numeric-result',{...fields,slot:'__matmul__'}),TypeError); assert.throws(()=>completion('numeric-index',{...fields,position:2}),TypeError); }); diff --git a/runtime/object-protocols.test.mjs b/runtime/object-protocols.test.mjs index 28af1d5..16c268a 100644 --- a/runtime/object-protocols.test.mjs +++ b/runtime/object-protocols.test.mjs @@ -4,7 +4,7 @@ import {runSourceRequest} from './source-request.mjs'; import {compileSourceModules} from './source-modules.mjs'; import {ModuleSession} from './module-session.mjs'; import {Execution} from './functions.mjs'; -import {completion, completionRoots} from './operations.mjs'; +import {completion, completionRoots, arithmeticTask, operationRoots} from './operations.mjs'; const profile = {languageProfile: 'classes-source/1'}; const unit = source => ({name: 'app', package: false, imports: [], source, notice: 'Synthetic object protocol fixture'}); @@ -67,3 +67,65 @@ test('reflection callbacks share recursion, work and memory budgets', async () = const oom = await run('class A:\n @property\n def x(self):\n a=[]\n while True: a.append([7])\ngetattr(A(),"x")\n', {maxSteps: 100000}, {maxBytes: 20000}); assert.equal(oom.error.code, 'OOM'); closed(oom.closed); }); + +test('binary, reflected and in-place arithmetic slots share complete operand roots', async () => { + const left = {}, right = {}, modulus = {}, outer = {}, after = completion('relay', {receiver: outer}); + assert.deepEqual(operationRoots(arithmeticTask('power', [left, right, modulus], null, after)), [left, right, modulus, outer]); + assert.throws(() => arithmeticTask('matrix', [left, right], null), TypeError); + for (const [symbol, stem] of [['+', 'add'], ['-', 'sub'], ['*', 'mul'], ['/', 'truediv'], ['//', 'floordiv'], ['%', 'mod'], ['**', 'pow'], ['<<', 'lshift'], ['>>', 'rshift'], ['&', 'and'], ['|', 'or'], ['^', 'xor']]) { + const source = 'class A:\n def __'+stem+'__(self,x): return [x+1]\n def __i'+stem+'__(self,x): return [x+3]\nclass R:\n def __r'+stem+'__(self,x): return [x+2]\na=A()\na'+symbol+'=4\nprint((A()'+symbol+'4)[0],(4'+symbol+'R())[0],a[0])\n'; + const result = await run(source); assert.equal(result.error, undefined, JSON.stringify(result.error)); + assert.equal(result.stdout, '5 6 7\n'); closed(result.closed); + } +}); + +test('reflected overrides have subclass priority while inherited slots and instance shadows do not', async () => { + const result = await run('class A:\n def __add__(self,x):\n print("left")\n return NotImplemented\n def __radd__(self,x):\n print("right")\n return 8\nclass B(A): pass\na=A()\na.__add__=None\nprint(a+B())\nclass C(A):\n __radd__=None\ntry: A()+C()\nexcept TypeError: print("override")\n'); + assert.equal(result.error, undefined, JSON.stringify(result.error)); assert.equal(result.stdout, 'left\nright\n8\noverride\n'); closed(result.closed); +}); + +test('sequence repetition falls back to index conversion and retains in-place list aliases', async () => { + const result = await run('class I:\n def __index__(self): return 2\na=[7]\nb=a\na*=I()\nprint(a is b,len(b),len(I()*b"x"),len(b"x"*I()))\nclass R:\n def __rmul__(self,other): return 9\nprint([1]*R())\n'); + assert.equal(result.error, undefined, JSON.stringify(result.error)); assert.equal(result.stdout, 'True 2 2 2\n9\n'); closed(result.closed); +}); + +test('optional power modulus distinguishes None without asserting integer interning', async () => { + const result = await run('class A:\n def __pow__(self,x,mod=None): return x if mod is None else mod\n def __rpow__(self,x,mod=None): return x+mod\nprint(pow(A(),3),pow(A(),3,7),pow(2,A(),5))\n'); + assert.equal(result.error, undefined, JSON.stringify(result.error)); assert.equal(result.stdout, '3 7 7\n'); closed(result.closed); +}); + +test('arithmetic binding/body failures retain original frames and never treat AttributeError as a missing slot', async () => { + for (const binding of [false, true]) { + const result = await run('class A:\n '+(binding ? '@property\n ' : '')+'def __add__(self'+(binding ? '' : ',other')+'):\n raise AttributeError("original")\nclass B:\n def __radd__(self,other):\n print("bad")\n return 7\nA()+B()\n'); + assert.equal(result.error.type, 'AttributeError'); assert.equal(result.error.traceback.at(-1).function, '__add__'); assert.equal(result.stdout, ''); closed(result.closed); + } +}); + +test('arithmetic callbacks survive collection and unwind on cancellation, budget and allocation failure', async () => { + const source = 'class A:\n @property\n def __add__(self):\n print("entered")\n def use(other): return [other[0]+3]\n return use\nprint((A()+[4])[0])\n'; + const {registry} = await compileSourceModules([unit(source)], profile); + try { for (const mode of ['finish', 'cancel', 'close']) { + const session = new ModuleSession(registry); let output = '', cancel = false, reached = false; + session.context.begin({shouldCancel: () => cancel}, text => { output += text; }); const execution = Execution.modules(session.context); + try { + while (execution.state !== 'completed') { + execution.advance(); session.memory.collect([], {cleanup: true}); + if (output === 'entered\n') { + reached = true; + if (mode === 'cancel') { cancel = true; assert.throws(() => execution.advance(), {code: 'CANCELLED'}); break; } + if (mode === 'close') break; + } + } + assert.ok(reached); if (mode === 'finish') assert.equal(output, 'entered\n7\n'); + } finally { execution.close(); execution.close(); session.context.finish(); session.close(); } + closed(session.stats()); + } } finally { registry.close(); } + for (const [body, options, limits, code] of [ + ['return self+other', {}, undefined, 'PYTHON'], + ['while True: pass', {maxSteps: 200}, undefined, 'BUDGET'], + ['a=[]\n while True: a.append([7])', {maxSteps: 100000}, {maxBytes: 20000}, 'OOM'], + ]) { + const result = await run('class A:\n def __add__(self,other):\n '+body+'\nA()+[4]\n', options, limits); + assert.equal(result.error.code, code); if (code === 'PYTHON') assert.equal(result.error.type, 'RecursionError'); closed(result.closed); + } +}); diff --git a/runtime/operation-runtime.mjs b/runtime/operation-runtime.mjs index d1d9d17..f73a934 100644 --- a/runtime/operation-runtime.mjs +++ b/runtime/operation-runtime.mjs @@ -1,5 +1,6 @@ +import {ARITHMETIC_SLOTS} from "./class-policy.mjs"; import {GeneratorValue, FunctionValue, CallArguments, BuiltinValue, AppendValue, ValueMethod, ExceptionValue, EXCEPTIONS, matchesException, ClassValue, InstanceValue, BoundMethodValue, PropertyValue, MethodWrapperValue, PropertyAccessorValue, ProtocolIteratorValue, NotImplementedValue} from "./function-objects.mjs"; -import {completion as continuation, callTask, unaryTask, valueTask, itemTask, sliceTask, containsTask, compareTask, dictionaryTask, dictionaryBuildTask, numericTask, consumeTask, operationRoots} from "./operations.mjs"; +import {completion as continuation, callTask, unaryTask, valueTask, itemTask, sliceTask, containsTask, compareTask, dictionaryTask, dictionaryBuildTask, arithmeticTask, numericTask, consumeTask, operationRoots} from "./operations.mjs"; export class Raised { constructor(value) { this.value = value; } } @@ -334,6 +335,16 @@ export function operationRuntime({state, memory, values, classes, moduleContext, } completion = completion.after; } + if (completion?.kind === "arithmetic-result") { + if (!(value instanceof NotImplementedValue)) return valueTask(value, site, completion.after); + const inplace = completion.operation.startsWith("inplace-"); + return arithmeticTask(inplace ? completion.operation.slice(8) : completion.operation, completion.args, site, completion.after, + completion.depth, inplace ? 0 : completion.position, inplace ? null : completion.order, inplace || completion.nativeInplace); + } + if (completion?.kind === "arithmetic-index") { + const args = [...completion.args]; args[completion.position] = value; + return valueTask(completion.operation.startsWith("inplace-") ? values.inplace(completion.operation.slice(8), ...args) : values.binary(completion.operation, ...args), site, completion.after); + } if (completion?.kind === "attribute-write") return valueTask(null, site, completion.after); if (completion?.kind === "attribute-result") return valueTask(completion.mode === "hasattr" ? true : value, site, completion.after); if (completion?.kind === "delegate-bind") return callTask(value, completion.args, [], [], site, completion.after); @@ -665,6 +676,48 @@ export function operationRuntime({state, memory, values, classes, moduleContext, state.temporaries = operationRoots(task); python("TypeError", task.operation === "call-keyword-duplicate" ? "Call received multiple values for a keyword argument" : "Argument after ** must be a mapping"); } + if (task.kind === "arithmetic") { + state.temporaries = operationRoots(task); + const [left, right, modulus = null] = args, inplace = task.operation.startsWith("inplace-"), operation = inplace ? task.operation.slice(8) : task.operation; + if (modulus instanceof InstanceValue) hard("UNSUPPORTED", "UnsupportedOperationError", "User modulus dispatch requires a separate power stage."); + const stem = ARITHMETIC_SLOTS[operation], ordinary = "__" + stem + "__", reflected = "__r" + stem + "__"; + const rightFirst = task.order ?? (left instanceof InstanceValue && right instanceof InstanceValue && left.type !== right.type && + right.type.mro.includes(left.type) && classes.lookup(right.type, reflected).found && + (!classes.lookup(left.type, reflected).found || classes.lookup(right.type, reflected).value !== classes.lookup(left.type, reflected).value)); + const candidates = inplace ? [[left, "__i" + stem + "__", right]] : rightFirst ? + [[right, reflected, left], [left, ordinary, right]] : [[left, ordinary, right], [right, reflected, left]]; + let position = task.position, plan = null; + for (; position < candidates.length; position++) { + charge(1); + const [receiver, slot] = candidates[position]; + if (!(receiver instanceof InstanceValue) || !inplace && slot === reflected && left instanceof InstanceValue && right instanceof InstanceValue && left.type === right.type) continue; + plan = classes.special(receiver, slot); if (plan) break; + } + if (plan) { + if (++depth > maxDepth) python("RecursionError", "maximum implicit arithmetic dispatch depth exceeded"); + const hookArgs = [candidates[position][2], ...(operation === "power" && modulus !== null ? [modulus] : [])]; + const finish = continuation("arithmetic-result", {operation: task.operation, args, position: position + 1, order: rightFirst, nativeInplace: task.nativeInplace, depth, after: completion}); + task = plan.kind === "value" ? callTask(plan.value, hookArgs, [], [], site, finish, depth) : + callTask(plan.callable, plan.args, [], [], site, continuation("invoke", {args: hookArgs, keywords: [], keywordValues: [], after: finish, depth}), depth); + continue; + } + if (inplace) { task = arithmeticTask(operation, args, site, completion, depth, 0, null, true); continue; } + if (task.nativeInplace && operation === "add" && values.isList(left) && right instanceof InstanceValue) { + const iterator = classes.lookup(right.type, "__iter__"); + if (iterator.found ? iterator.value !== null : classes.lookup(right.type, "__getitem__").found) + hard("UNSUPPORTED", "UnsupportedOperationError", "List += with a user iterable requires its own sequence stage."); + } + if (operation === "multiply") { + const repeatable = value => values.isString(value) || values.isBinary(value) || values.isList(value) || values.isSequenceType(value, "tuple"); + const count = repeatable(left) && right instanceof InstanceValue ? 1 : repeatable(right) && left instanceof InstanceValue ? 0 : -1; + if (count >= 0 && classes.lookup(args[count].type, "__index__").found) { + if (!(values.isBinary(args[1 - count]) || task.nativeInplace && values.isList(left))) + hard("UNSUPPORTED", "UnsupportedOperationError", "Ordinary list/tuple/text repetition requires its separate sequence stage."); + task = unaryTask("index", args[count], site, continuation("arithmetic-index", {operation: task.nativeInplace ? "inplace-" + operation : operation, args, position: count, order: rightFirst, nativeInplace: task.nativeInplace, depth, after: completion}), depth); continue; + } + } + python("TypeError", "unsupported operands for " + operation); + } if (task.kind === "consume") { state.temporaries = operationRoots(task); const {name, receiver} = task, building = ["list", "tuple"].includes(name); @@ -681,6 +734,9 @@ export function operationRuntime({state, memory, values, classes, moduleContext, if (task.kind === "numeric") { state.temporaries = operationRoots(task); const name = task.name; + if (["pow", "divmod"].includes(name) && args.some(value => value instanceof InstanceValue)) { + task = arithmeticTask(name === "pow" ? "power" : "divmod", args.slice(0, name === "pow" && args[2] !== null ? 3 : 2), site, completion, depth); continue; + } let position = task.position; if (name === "range") { while (position < args.length && !(args[position] instanceof InstanceValue)) { @@ -968,6 +1024,14 @@ export function operationRuntime({state, memory, values, classes, moduleContext, {operation: "repr-default", receiver: value, after: completion, depth} : null}), depth); continue; } + if (value instanceof InstanceValue && ["negative", "positive", "invert"].includes(operation)) { + const slot = {negative: "__neg__", positive: "__pos__", invert: "__invert__"}[operation], plan = classes.special(value, slot); + if (!plan) python("TypeError", "bad operand type for unary " + operation); + if (++depth > maxDepth) python("RecursionError", "maximum implicit arithmetic dispatch depth exceeded"); + const finish = continuation("relay", {receiver: value, after: completion}); + task = plan.kind === "value" ? callTask(plan.value, [], [], [], site, finish, depth) : + callTask(plan.callable, plan.args, [], [], site, continuation("invoke", {args: [], keywords: [], keywordValues: [], after: finish, depth}), depth); continue; + } if (!(value instanceof InstanceValue) || !["len", "truth", "not"].includes(operation)) { task = deliver(values.unary(operation, value), completion, site); continue; } diff --git a/runtime/operations.mjs b/runtime/operations.mjs index 107f24d..8ab5539 100644 --- a/runtime/operations.mjs +++ b/runtime/operations.mjs @@ -1,3 +1,4 @@ +import {ARITHMETIC_SLOTS} from "./class-policy.mjs"; // Closed internal records. Root order and multiplicity implement the initial // resource contract, including copied ancestor roots and the extra init root. const records = new WeakSet(), roots = new WeakMap(), heights = new WeakMap(); @@ -122,6 +123,9 @@ export function completion(kind, fields = {}) { break; case "protocol-result": record = {kind, receiver: fields.receiver, operation: fields.operation, slot: fields.slot, depth: fields.depth ?? 0, after}; break; case "optional-truth": record = {kind, receiver: fields.receiver, operation: fields.operation, depth: fields.depth, after}; break; + case "arithmetic-result": case "arithmetic-index": + record = {kind, operation: fields.operation, args: operands(fields.args), position: fields.position, order: fields.order, nativeInplace: fields.nativeInplace === true, depth: fields.depth, after}; + arithmeticCheck(record.operation, record.args, record.position, record.order); depthCheck(record.depth); break; case "attribute-write": record = {kind, receiver: fields.receiver, after}; break; case "attribute-result": record = {kind, receiver: fields.receiver, default: fields.default, mode: fields.mode, after}; if (!["getattr", "hasattr"].includes(record.mode)) throw new TypeError("Invalid attribute result mode."); break; @@ -173,6 +177,7 @@ function visit(record) { case "index-key": return [record.receiver, record.key, ...(record.operation === "set" ? [record.item] : []), ...completionRoots(record.after)]; case "slice-index": return [record.receiver, ...record.parts, ...(record.operation === "set" ? [record.item] : []), ...completionRoots(record.after)]; case "print-argument": return [...record.args, ...completionRoots(record.after)]; + case "arithmetic-result": case "arithmetic-index": return [...record.args, ...completionRoots(record.after)]; case "attribute-write": return [record.receiver, ...completionRoots(record.after)]; case "attribute-result": return [record.receiver, record.default, ...completionRoots(record.after)]; case "delegate-bind": return [record.receiver, ...record.args, ...completionRoots(record.after)]; @@ -212,6 +217,15 @@ export function unaryTask(operation, value, site, after = null, depth = 0, optio depthCheck(depth); completionRoots(after); return Object.freeze({kind: "unary", operation, value, site, completion: after, depth, optionalLength}); } +const arithmeticCheck = (operation, args, position, order) => { + const name = operation.startsWith("inplace-") ? operation.slice(8) : operation; + if (!Object.hasOwn(ARITHMETIC_SLOTS, name) || args.length < 2 || args.length > (name === "power" ? 3 : 2) || + !Number.isSafeInteger(position) || position < 0 || position > 2 || ![null, false, true].includes(order)) throw new TypeError("Invalid arithmetic operation."); +}; +export function arithmeticTask(operation, args, site, after = null, depth = 0, position = 0, order = null, nativeInplace = false) { + arithmeticCheck(operation, args, position, order); depthCheck(depth); completionRoots(after); + return Object.freeze({kind: "arithmetic", operation, args: operands(args), site, completion: after, depth, position, order, nativeInplace}); +} export function numericTask(name, args, site, after = null, depth = 0, position = 0) { depthCheck(depth); completionRoots(after); numericPosition(position); if (args.length > 3 || position > (name === "range" ? 3 : 2)) throw new TypeError("Invalid numeric operands."); @@ -254,6 +268,7 @@ export function dictionaryBuildTask(receiver, parts, site, after = null, depth = export function operationRoots(task) { switch (task.kind) { case "call": return [task.callable, ...task.args, ...task.keywordValues, ...completionRoots(task.completion)]; + case "arithmetic": case "numeric": return [...task.args, ...completionRoots(task.completion)]; case "consume": return [task.receiver, ...(task.result === null ? [] : [task.result]), ...completionRoots(task.completion)]; case "item": return [task.receiver, task.key, ...(task.operation === "set" ? [task.item] : []), ...completionRoots(task.completion)]; diff --git a/runtime/private-names.test.mjs b/runtime/private-names.test.mjs index 2f35ba1..beaabf3 100644 --- a/runtime/private-names.test.mjs +++ b/runtime/private-names.test.mjs @@ -72,7 +72,7 @@ test('private from-imports require the transformed member rather than the litera test('default core syntax and later class features keep their existing refusal boundary', async () => { assert.equal(compileSource('__x=7\nprint(__x)\n').program.version, 4); assert.throws(() => compileSource('class A:\n __x=7\n'), {code: 'UNSUPPORTED'}); - for (const source of ['class A:\n def __add__(self,other): return 7\n', + for (const source of ['class A:\n def __matmul__(self,other): return 7\n', 'async def f(): pass\n', 'class A:\n def read(self): return __class__\n']) { const actual = await run("print('never')\n" + source); assert.equal(actual.error.code, 'UNSUPPORTED'); diff --git a/runtime/special-calls.test.mjs b/runtime/special-calls.test.mjs index f2a2042..ac29e99 100644 --- a/runtime/special-calls.test.mjs +++ b/runtime/special-calls.test.mjs @@ -57,7 +57,7 @@ test('busy callable work and cancellation unwind the same owned graphs', async ( }); test('later protocols still refuse before effects and default core selection stays unchanged', async () => { - const later = await run('print("never")\nclass A:\n def __add__(self,other): return 7\n'); + const later = await run('print("never")\nclass A:\n def __matmul__(self,other): return 7\n'); assert.equal(later.error.code, 'UNSUPPORTED'); assert.equal(later.stdout, ''); assert.equal(later.stats, null); const core = await runSourceRequest({modules: [unit('class A:\n def __call__(self): return 7\n')]}); assert.equal(core.error.code, 'UNSUPPORTED'); assert.equal(core.stats, null); diff --git a/runtime/values.mjs b/runtime/values.mjs index 7230957..d33aa60 100644 --- a/runtime/values.mjs +++ b/runtime/values.mjs @@ -1062,6 +1062,7 @@ export class ValueRuntime { if (operation === "less-equal") return this.less(left, right) || this.equal(left, right); if (operation === "greater-equal") return this.less(right, left) || this.equal(left, right); if (operation === "is") { + if (left === null || right === null) return left === right; if (typeof left === "bigint" || typeof right === "bigint") this.fail("UnsupportedOperationError", "Integer interning identity is outside this stage.", "UNSUPPORTED"); return left === right; } diff --git a/scripts/classes_console_browser_smoke.py b/scripts/classes_console_browser_smoke.py index bb26708..e252ff1 100644 --- a/scripts/classes_console_browser_smoke.py +++ b/scripts/classes_console_browser_smoke.py @@ -23,6 +23,7 @@ from examples.interpreter_functions import SOURCE as FUNCTION_SOURCE, CORE_SOURCE as CORE_FUNCTION_SOURCE from examples.interpreter_syntax import SOURCE as SYNTAX_SOURCE, CORE_SOURCE as CORE_SYNTAX_SOURCE from examples.interpreter_values import SOURCE as VALUES_SOURCE, CORE_SOURCE as CORE_VALUES_SOURCE +from examples.interpreter_generators import SOURCE as GENERATOR_SOURCE, CORE_SOURCE as GENERATOR_CORE_SOURCE def smoke(channel=None): @@ -152,6 +153,12 @@ def refused(source, prefix): 'view-basic', 'view-alias', 'view-stride', 'view-owner-lifetime', 'view-released-equality', 'view-released-hash-cached', 'view-binary-operations', 'view-byte-index-callback']) programs.append(VALUES_SOURCE) + generators = json.loads((ROOT / 'tests/fixtures/interpreter/generators.json').read_bytes()) + objects = json.loads((ROOT / 'tests/fixtures/interpreter/object-protocols.json').read_bytes()) + for corpus in [generators, objects]: + programs.extend(c['source'] for c in corpus['cases'] + if c['stage'] in corpus['implemented_stages'] and 'error_type' not in c) + programs.append(GENERATOR_SOURCE) for program in programs: expected = subprocess.check_output([sys.executable, '-I', '-S', '-X', 'utf8', '-c', program], text=True, encoding='utf-8', timeout=5) @@ -160,7 +167,7 @@ def refused(source, prefix): refused('class A:\n def fail(self): return 1//0\nA().fail()\n', 'ZeroDivisionError:') assert 'sources/app.py:2' in page.locator('#status').inner_text() assert 'fail at sources/app.py:2' in page.locator('#diagnostic').inner_text() - refused("print('never')\nclass A:\n def __add__(self,other): return 7\n", 'UnsupportedSyntaxError:') + refused("print('never')\nclass A:\n def __matmul__(self,other): return 7\n", 'UnsupportedSyntaxError:') assert page.locator('#result').inner_text() == '' refused('A()\n', 'NameError:') refused('import browser\n', 'ImportError:') @@ -339,6 +346,13 @@ def refused(source, prefix): run(VALUES_SOURCE, 'sets 2 True True\ntext Café afé False\nview CBA 3 -1\n') run('class Byte:\n @property\n def __bytes__(self): return lambda: "café".encode()\nraw=bytes(Byte()); a=bytearray(raw); v=memoryview(a)[::-1]; v[0]=ord(b"!")\nprint(raw.decode(),a[-1],v[0],len({frozenset([1,2]),frozenset([2,1])}))\n', 'café 33 33 1\n') checks.append('new-sets-codecs-bytes-binding-and-buffer-alias-source-offline') + generator_expected = subprocess.check_output([sys.executable, '-I', '-S', '-X', 'utf8', '-c', GENERATOR_SOURCE], text=True, encoding='utf-8') + run(GENERATOR_SOURCE, generator_expected) + checks.append('new-generators-comprehensions-reflection-and-arithmetic-source-offline') + refused('def g():\n yield 1\n raise StopIteration("escaped")\na=g()\nnext(a)\nnext(a)\n', 'RuntimeError:') + refused('class A:\n @property\n def x(self): raise ValueError("body")\nhasattr(A(),"x")\n', 'ValueError:') + refused('class A:\n def __add__(self,x): raise KeyError("body")\nA()+1\n', 'KeyError:') + checks.append('generator-and-object-body-errors-retain-offline-diagnostics') busy = [ 'class Spin:\n def __call__(self):\n while True: pass\nSpin()()\n', 'class Spin:\n @property\n def __call__(self):\n while True: pass\nSpin()()\n', @@ -423,6 +437,14 @@ def refused(source, prefix): 'class I:\n def __index__(self):\n while True: pass\nbytes([I()])\n', 'class I:\n def __index__(self):\n while True: pass\nv=memoryview(bytearray(b"a")); v[I()]=0\n' ]) + busy.extend([ + 'def g():\n while True: yield 1\nall(g())\n', + 'def g():\n try: yield 1\n finally:\n while True: pass\na=g()\nnext(a)\na.close()\n', + 'def busy(x):\n while True: pass\na=[busy(x) for x in range(2)]\n', + 'class Spin:\n def __add__(self,other):\n while True: pass\nSpin()+[7]\n', + 'class Spin:\n @property\n def __radd__(self):\n while True: pass\n[7]+Spin()\n', + 'class Spin:\n @property\n def x(self):\n while True: pass\ngetattr(Spin(),"x",[7])\n' + ]) for cycle in range(len(busy)): page.fill('#input', busy[cycle % len(busy)]) page.click('#run') @@ -474,6 +496,9 @@ def refused(source, prefix): checks.append('ordinary-syntax-retained-after-core-profile-update') run(CORE_VALUES_SOURCE, 'sets 2 True True\ntext Café afé False\nview CBA 3 -1\n') checks.append('sets-binary-codecs-and-buffer-values-retained-after-core-profile-update') + core_generator_expected = subprocess.check_output([sys.executable, '-I', '-S', '-X', 'utf8', '-c', GENERATOR_CORE_SOURCE], text=True, encoding='utf-8') + run(GENERATOR_CORE_SOURCE, core_generator_expected) + checks.append('generators-and-comprehensions-retained-after-offline-core-profile-update') refused('slice(7)\n', 'NameError:') refused('next(iter([]),7)\n', 'UnsupportedOperationError:') refused('iter(str,7)\n', 'UnsupportedOperationError:') diff --git a/src/pocket_python/_interpreter_lock.py b/src/pocket_python/_interpreter_lock.py index dfa6532..a148b40 100644 --- a/src/pocket_python/_interpreter_lock.py +++ b/src/pocket_python/_interpreter_lock.py @@ -1,14 +1,14 @@ """Known source-console assets, canonical LF bytes; refresh with scripts/lock_interpreter.py.""" -ENGINE_FINGERPRINT = '03d4255b4396e0016f2b34d86c3968d12f5f9573781a72d37ccd95f244a36d9f' +ENGINE_FINGERPRINT = '838cf574d88d26ca28e945fd3fa077e7c98bf4e19a4bb202429d54beecbef697' ENGINE_FILES = ( ('browser-protocol.mjs', 10589, '920e8d07c7070995636dff40fdf4333afc9bd4143483db5d8dff82ec1f28ff76'), ('bytecode.mjs', 6836, 'e76fabeea879bc1da8016283f7e74df5974479666b100199c2d3c31a53b8ecf4'), - ('class-policy.mjs', 1362, '586d8800afd0e5d5ec7d4d3e758dbf56942f7d74207c111c0b32fb4483b0d1f6'), + ('class-policy.mjs', 1968, 'e3275e080c2d26584bb318e88d9a0278191815ac072c48e3054fd4c908cb771b'), ('class-values.mjs', 15372, '67fc606a6c91f7aeb1d4309cc4a0be33778181cdccb51190e5f76d4969d9b43d'), ('function-bytecode.mjs', 20438, 'ca6b3abd73d2be35422eea244b876a851bf98e38836e31a33fa6d18d4252ae68'), ('function-objects.mjs', 4277, '06af0ca936de1dd336cb7a2ac13f24efae960ea3fe448c93fb95a2e92213c4e2'), - ('functions.mjs', 56452, '3f60a5034ff614f6bc12743194bbcd46096da549a677afb58b00fa5fed8da527'), + ('functions.mjs', 56333, '4450009703952a1dab3c62a94dd84aadeee9c0f2fee0a31db44495eddcc20e96'), ('interpreter-host.mjs', 7019, '0bb0a64faffcf77e0064258e4cfcaa7a937c4372129849f76901bee91b5a5ae2'), ('interpreter-worker.mjs', 8282, '937a97cc49d987492de0bc16021a4a804ef51e4ea5b398f79c92e5d30ac82558'), ('library-catalog.mjs', 907, '0adcaae0f6f07b4e1acb22e46493ec99f7247a5828b426c703530d8b2736032c'), @@ -20,15 +20,15 @@ ('module-registry.mjs', 10085, 'f23f8313c0804a052cba155e193df920b0221ef978a06f16c1a22f208dfbef91'), ('module-session.mjs', 1470, '08e4ccf16361299c15b99621fdfcef0d9ef93de50d28d738158c7bc85a7c23a3'), ('numeric.mjs', 13954, '2431f099a7d40aa38d6b3c68b556207752e4191211e01005acde94325bd91bad'), - ('operation-runtime.mjs', 87150, 'fab6eed0c35813348cd17b6524ab006b51af6d0bbad7405752de939e65fe354e'), - ('operations.mjs', 22088, 'cd872da96bc09f14c105ebb977ea3b5ed1441ec43f538df6d208966dc82bf014'), + ('operation-runtime.mjs', 93126, '18aff2a37a30c3fe0deebb51dae85f8200f9674588308941f5147ae10c809c9f'), + ('operations.mjs', 23452, 'bd4c064811e6400576cae60bfcdbd35a76c829a25085794f95520d6307fad78e'), ('source-compiler.mjs', 35239, '4201a25a0c45b8fba4777c8e98b75e37f3277888708093a77e2827dec5ff7063'), ('source-identifiers.mjs', 25949, '1e4e6a90728a2d9393eb4b2055d4bd759542f4768d1870b508d629c6888e8914'), ('source-modules.mjs', 5115, '3f824a8cf79a9ab78c1b817ed1597d114d851748a6b0894a4ac6b4bdbe01cfef'), ('source-parser.mjs', 33396, '578c6663213e46049c51ba23a52427af8c301943ef67433054a6bf3fb2218ed2'), ('source-tokens.mjs', 15061, 'e044276920224f8ed788f4e3445bee926698444e6cf0b9e8573bd15fec94ad54'), ('string-printability.mjs', 13287, '355a086a142e06822eff821dd0ac6a2ca70c4e06f4ec659236dc3cea1ddd639f'), - ('values.mjs', 85128, '07d52caaef9774c1132a02f17420674eb27961676000990d6f60271fd3310d6b'), + ('values.mjs', 85194, '3a9b349575ecccd4c933aa9d8fc7877590290812e60074869ef45f4c1abbbdda'), ('vm.mjs', 6080, '796eaac2848ee7aa2d740797eb2afbdb3e577918866a88a9f25e5adbc55e985d'), ) ENGINE_IMPORTS = ( @@ -43,6 +43,7 @@ ('function-bytecode.mjs', 'source-identifiers.mjs'), ('function-objects.mjs', 'class-policy.mjs'), ('function-objects.mjs', 'operations.mjs'), + ('functions.mjs', 'class-policy.mjs'), ('functions.mjs', 'class-values.mjs'), ('functions.mjs', 'function-bytecode.mjs'), ('functions.mjs', 'function-objects.mjs'), @@ -71,8 +72,10 @@ ('module-session.mjs', 'memory.mjs'), ('module-session.mjs', 'module-context.mjs'), ('module-session.mjs', 'values.mjs'), + ('operation-runtime.mjs', 'class-policy.mjs'), ('operation-runtime.mjs', 'function-objects.mjs'), ('operation-runtime.mjs', 'operations.mjs'), + ('operations.mjs', 'class-policy.mjs'), ('source-compiler.mjs', 'class-policy.mjs'), ('source-compiler.mjs', 'function-bytecode.mjs'), ('source-compiler.mjs', 'function-objects.mjs'), diff --git a/src/pocket_python/interpreter_templates.py b/src/pocket_python/interpreter_templates.py index 52d96b1..b11494a 100644 --- a/src/pocket_python/interpreter_templates.py +++ b/src/pocket_python/interpreter_templates.py @@ -77,8 +77,8 @@ prepareOffline().catch(error=>{byId('offline').textContent='Offline unavailable: '+error.message;}); try{ config=JSON.parse(await verified('./__CONFIG__','__CONFIG_SHA__',65536)); - const descriptions={'classes-source/1':'Language profile: classes-source/1. Supports the core subset plus classes, inheritance, attributes, methods, private names, descriptors, properties, decorators, callable objects, length/truth, repr/str, integer indices and item/slice access and mutation, iteration and containment, equality and hashing. Ranges, list/tuple construction and all/any support adopted user protocols; user length hints are a later stage. Functions support computed/mutable defaults, global/nonlocal state, variadic parameters and starred calls. Assertions, assignment expressions, lambdas, unpacking targets and starred sequence/dictionary displays are available. Finite sets, bytes, bytearrays and memoryviews, with strict UTF-8/ASCII/Latin-1 codecs, are available in both profiles. Bytearray resizing, buffer casts and native buffers are later stages. Numeric operators, helpers and real/complex values are available in both profiles; numeric conversion and abs/round hooks use class type lookup. Float/container/default-object formatting, ordering protocols and async are later stages.', - 'core-source/1':'Language profile: core-source/1. Supports values, loops, functions with computed/mutable defaults, global/nonlocal state, variadic parameters and starred calls, assertions, assignment expressions, lambdas, unpacking targets and starred sequence/dictionary displays, exceptions, primitive repr/str, ranges, primitive list/tuple construction, all/any, numeric operators/helpers and real/complex values. Finite sets, bytes, bytearrays and memoryviews, with strict UTF-8/ASCII/Latin-1 codecs, are available. Bytearray resizing, buffer casts and native buffers are later stages. Classes require the explicit class profile; async and external packages are later stages.'}; + const descriptions={'classes-source/1':'Language profile: classes-source/1. Supports the core subset plus classes, inheritance, attributes, methods, private names, descriptors, properties, decorators, callable objects, length/truth, repr/str, integer indices and item/slice access and mutation, iteration and containment, equality and hashing. Ranges, list/tuple construction and all/any support adopted user protocols; user length hints are a later stage. Functions support computed/mutable defaults, global/nonlocal state, variadic parameters and starred calls. Assertions, assignment expressions, lambdas, unpacking targets and starred sequence/dictionary displays are available. Finite sets, bytes, bytearrays and memoryviews, with strict UTF-8/ASCII/Latin-1 codecs, are available in both profiles. Bytearray resizing, buffer casts and native buffers are later stages. Numeric operators, helpers and real/complex values are available in both profiles; numeric conversion and abs/round hooks use class type lookup. Generators, yield from, send/throw/close and scoped comprehensions are available. Attribute reflection and arithmetic/reflected/in-place hooks use ordinary class descriptors. Float/container/default-object formatting, ordering protocols and async are later stages.', + 'core-source/1':'Language profile: core-source/1. Supports values, loops, functions with computed/mutable defaults, global/nonlocal state, variadic parameters and starred calls, assertions, assignment expressions, lambdas, unpacking targets and starred sequence/dictionary displays, exceptions, primitive repr/str, ranges, primitive list/tuple construction, all/any, numeric operators/helpers and real/complex values. Finite sets, bytes, bytearrays and memoryviews, with strict UTF-8/ASCII/Latin-1 codecs, are available. Bytearray resizing, buffer casts and native buffers are later stages. Generators, yield from, send/throw/close and scoped comprehensions are available. Classes require the explicit class profile; async and external packages are later stages.'}; if(!Object.hasOwn(descriptions,config.application.engine.language_profile))throw new Error('Source language profile is unavailable.'); byId('language').textContent=descriptions[config.application.engine.language_profile]; config.library_sources=[]; diff --git a/tests/fixtures/interpreter/indexing.json b/tests/fixtures/interpreter/indexing.json index 0bc8cd0..c95df46 100644 --- a/tests/fixtures/interpreter/indexing.json +++ b/tests/fixtures/interpreter/indexing.json @@ -504,7 +504,7 @@ }, { "id": "future-arithmetic", - "stage": "I3", + "stage": "I1", "source": "class A:\n def __add__(self,x): return 7\nprint(A()+1)\n", "stdout": "7\n" }, diff --git a/tests/fixtures/interpreter/numeric.json b/tests/fixtures/interpreter/numeric.json index 94746e9..53ec127 100644 --- a/tests/fixtures/interpreter/numeric.json +++ b/tests/fixtures/interpreter/numeric.json @@ -961,7 +961,7 @@ }, { "id": "future-binary-user-slot", - "stage": "Future", + "stage": "N1", "source": "class A:\n def __add__(self,other): return 7\nprint(A()+3)\n", "stdout": "7\n" }, diff --git a/tests/fixtures/interpreter/object-protocols.json b/tests/fixtures/interpreter/object-protocols.json index 31c3c10..96e258c 100644 --- a/tests/fixtures/interpreter/object-protocols.json +++ b/tests/fixtures/interpreter/object-protocols.json @@ -4,7 +4,8 @@ "profile": "classes-source/1", "reference_version": "3.14.7", "implemented_stages": [ - "O1" + "O1", + "O2" ], "cases": [ { @@ -96,6 +97,192 @@ "stage": "Future", "source": "class A:\n def __getattr__(self,name): return 2\nprint(A().x)\n", "stdout": "2\n" + }, + { + "id": "arithmetic-add", + "stage": "O2", + "source": "class A:\n def __add__(self,x): return [x+1]\nclass B:\n def __radd__(self,x): return [x+2]\nprint((A()+4)[0],(4+B())[0])\n", + "stdout": "5 6\n" + }, + { + "id": "inplace-add", + "stage": "O2", + "source": "class A:\n def __iadd__(self,x): return [x+3]\na=A()\na+=4\nprint(a[0])\n", + "stdout": "7\n" + }, + { + "id": "arithmetic-sub", + "stage": "O2", + "source": "class A:\n def __sub__(self,x): return [x+1]\nclass B:\n def __rsub__(self,x): return [x+2]\nprint((A()-4)[0],(4-B())[0])\n", + "stdout": "5 6\n" + }, + { + "id": "inplace-sub", + "stage": "O2", + "source": "class A:\n def __isub__(self,x): return [x+3]\na=A()\na-=4\nprint(a[0])\n", + "stdout": "7\n" + }, + { + "id": "arithmetic-mul", + "stage": "O2", + "source": "class A:\n def __mul__(self,x): return [x+1]\nclass B:\n def __rmul__(self,x): return [x+2]\nprint((A()*4)[0],(4*B())[0])\n", + "stdout": "5 6\n" + }, + { + "id": "inplace-mul", + "stage": "O2", + "source": "class A:\n def __imul__(self,x): return [x+3]\na=A()\na*=4\nprint(a[0])\n", + "stdout": "7\n" + }, + { + "id": "arithmetic-truediv", + "stage": "O2", + "source": "class A:\n def __truediv__(self,x): return [x+1]\nclass B:\n def __rtruediv__(self,x): return [x+2]\nprint((A()/4)[0],(4/B())[0])\n", + "stdout": "5 6\n" + }, + { + "id": "inplace-truediv", + "stage": "O2", + "source": "class A:\n def __itruediv__(self,x): return [x+3]\na=A()\na/=4\nprint(a[0])\n", + "stdout": "7\n" + }, + { + "id": "arithmetic-floordiv", + "stage": "O2", + "source": "class A:\n def __floordiv__(self,x): return [x+1]\nclass B:\n def __rfloordiv__(self,x): return [x+2]\nprint((A()//4)[0],(4//B())[0])\n", + "stdout": "5 6\n" + }, + { + "id": "inplace-floordiv", + "stage": "O2", + "source": "class A:\n def __ifloordiv__(self,x): return [x+3]\na=A()\na//=4\nprint(a[0])\n", + "stdout": "7\n" + }, + { + "id": "arithmetic-mod", + "stage": "O2", + "source": "class A:\n def __mod__(self,x): return [x+1]\nclass B:\n def __rmod__(self,x): return [x+2]\nprint((A()%4)[0],(4%B())[0])\n", + "stdout": "5 6\n" + }, + { + "id": "inplace-mod", + "stage": "O2", + "source": "class A:\n def __imod__(self,x): return [x+3]\na=A()\na%=4\nprint(a[0])\n", + "stdout": "7\n" + }, + { + "id": "arithmetic-pow", + "stage": "O2", + "source": "class A:\n def __pow__(self,x): return [x+1]\nclass B:\n def __rpow__(self,x): return [x+2]\nprint((A()**4)[0],(4**B())[0])\n", + "stdout": "5 6\n" + }, + { + "id": "inplace-pow", + "stage": "O2", + "source": "class A:\n def __ipow__(self,x): return [x+3]\na=A()\na**=4\nprint(a[0])\n", + "stdout": "7\n" + }, + { + "id": "arithmetic-lshift", + "stage": "O2", + "source": "class A:\n def __lshift__(self,x): return [x+1]\nclass B:\n def __rlshift__(self,x): return [x+2]\nprint((A()<<4)[0],(4<>4)[0],(4>>B())[0])\n", + "stdout": "5 6\n" + }, + { + "id": "inplace-rshift", + "stage": "O2", + "source": "class A:\n def __irshift__(self,x): return [x+3]\na=A()\na>>=4\nprint(a[0])\n", + "stdout": "7\n" + }, + { + "id": "arithmetic-and", + "stage": "O2", + "source": "class A:\n def __and__(self,x): return [x+1]\nclass B:\n def __rand__(self,x): return [x+2]\nprint((A()&4)[0],(4&B())[0])\n", + "stdout": "5 6\n" + }, + { + "id": "inplace-and", + "stage": "O2", + "source": "class A:\n def __iand__(self,x): return [x+3]\na=A()\na&=4\nprint(a[0])\n", + "stdout": "7\n" + }, + { + "id": "arithmetic-or", + "stage": "O2", + "source": "class A:\n def __or__(self,x): return [x+1]\nclass B:\n def __ror__(self,x): return [x+2]\nprint((A()|4)[0],(4|B())[0])\n", + "stdout": "5 6\n" + }, + { + "id": "inplace-or", + "stage": "O2", + "source": "class A:\n def __ior__(self,x): return [x+3]\na=A()\na|=4\nprint(a[0])\n", + "stdout": "7\n" + }, + { + "id": "arithmetic-xor", + "stage": "O2", + "source": "class A:\n def __xor__(self,x): return [x+1]\nclass B:\n def __rxor__(self,x): return [x+2]\nprint((A()^4)[0],(4^B())[0])\n", + "stdout": "5 6\n" + }, + { + "id": "inplace-xor", + "stage": "O2", + "source": "class A:\n def __ixor__(self,x): return [x+3]\na=A()\na^=4\nprint(a[0])\n", + "stdout": "7\n" + }, + { + "id": "unary-results", + "stage": "O2", + "source": "class A:\n def __neg__(self): return [1]\n def __pos__(self): return [2]\n def __invert__(self): return [3]\nprint((-A())[0],(+A())[0],(~A())[0])\n", + "stdout": "1 2 3\n" + }, + { + "id": "inherited-reflection", + "stage": "O2", + "source": "class A:\n def __add__(self,other):\n print(\"left\")\n return NotImplemented\n def __radd__(self,other):\n print(\"right\")\n return 9\nclass B(A): pass\nprint(A()+B())\n", + "stdout": "left\nright\n9\n" + }, + { + "id": "arithmetic-property", + "stage": "O2", + "source": "class A:\n @property\n def __add__(self):\n print(\"bind\")\n def use(other): return other+3\n return use\nprint(A()+4)\n", + "stdout": "bind\n7\n" + }, + { + "id": "arithmetic-lazy-result", + "stage": "O2", + "source": "class A:\n def __add__(self,other):\n print(\"body\")\n yield other+3\ng=A()+4\nprint(\"made\")\nprint(next(g))\n", + "stdout": "made\nbody\n7\n" + }, + { + "id": "repeat-index-alias", + "stage": "O2", + "source": "class I:\n def __index__(self): return 2\na=[7]\nb=a\na*=I()\nprint(a is b,len(b),len(b\"x\"*I()))\n", + "stdout": "True 2 2\n" + }, + { + "id": "reflected-ternary-power", + "stage": "O2", + "source": "class A:\n def __rpow__(self,other,mod=None): return other+mod\nprint(pow(2,A(),5))\n", + "stdout": "7\n" + }, + { + "id": "reflection-unusual-keys", + "stage": "O1", + "source": "class A: pass\na=A()\nsetattr(a,\"two words\",7)\nsetattr(a,\"\",8)\nprint(getattr(a,\"two words\"),getattr(a,\"\"))\n", + "stdout": "7 8\n" } ] }