From 837f135dee3dc08305c858bd4fb9c97c98197c4c Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:33:44 -0700 Subject: [PATCH 1/6] docs(INT-GEN-01A): define generator and object protocol contracts --- docs/interpreter-generators.md | 35 +++ docs/interpreter-object-protocols.md | 26 ++ tests/fixtures/interpreter/generators.json | 283 ++++++++++++++++++ .../interpreter/object-protocols.json | 99 ++++++ tests/test_generator_contract.py | 23 ++ 5 files changed, 466 insertions(+) create mode 100644 docs/interpreter-generators.md create mode 100644 docs/interpreter-object-protocols.md create mode 100644 tests/fixtures/interpreter/generators.json create mode 100644 tests/fixtures/interpreter/object-protocols.json create mode 100644 tests/test_generator_contract.py diff --git a/docs/interpreter-generators.md b/docs/interpreter-generators.md new file mode 100644 index 0000000..79f4f1f --- /dev/null +++ b/docs/interpreter-generators.md @@ -0,0 +1,35 @@ +# Generators and comprehensions + +INT-GEN-01 uses the pinned CPython 3.14.7 executable reference. The authored +`generators.json` corpus separates reference-only evidence from independently +compiled source. G1 and G2 require Node and Chromium, both profiles and both +draining and instruction-boundary execution before acceptance. + +| Stage | Finite contract | +| --- | --- | +| G1 | Generator functions, bare/value yield, yield from, iter/next, send(value), throw(exception or exception type), close(). Argument binding happens on construction; body effects start on resume. Return values travel in StopIteration.value and close() returns a caught GeneratorExit handler's return value. | +| G2 | List, dictionary, set and generator comprehensions, nested synchronous for/if clauses and unpacking targets. Iteration bindings have a separate lexical scope; the leftmost iterable and iterator acquisition run immediately in the enclosing scope. Dictionary keys precede their values. | +| Future | Async generators/comprehensions, generator frame/code/traceback reflection, deprecated multi-argument throw, assignment expressions inside comprehensions, and interpreter shutdown finalizers require separate contracts. | + +Yield preserves the exact block cursor, operand stacks, loop/exception/finally +controls, local cells, closures and active exception. Suspended frames are owned +arena graphs rather than permanently active roots. Resumption uses ordinary +frames on the existing execution controller; it never recursively drains a new +execution or resets instruction, work, allocation or recursion budgets. Running +generators reject reentry with ValueError. An escaping StopIteration becomes +RuntimeError. A yielded value during close raises RuntimeError; the suspended +generator can subsequently be resumed or closed again. + +Yield-from relays yielded values, non-None sends, thrown exceptions and close to +adopted delegates; only advancement/delegation exhaustion consumes StopIteration. +User delegates use their ordinary attribute/iteration callbacks. Other callback +failures retain Python frames and source locations. Explicit close unwinds +finally; hard quota/cancellation/session teardown releases ownership without +executing arbitrary Python finalizers, as with existing hard interruption. +Garbage collection does not promise CPython reference-count finalization timing. + +Generator aliases and cycles survive collection at any instruction boundary. +Repeated close and session teardown must release logical objects, bytes, frames,+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). diff --git a/docs/interpreter-object-protocols.md b/docs/interpreter-object-protocols.md new file mode 100644 index 0000000..c8909a4 --- /dev/null +++ b/docs/interpreter-object-protocols.md @@ -0,0 +1,26 @@ +# Object reflection and arithmetic + +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. Classes remain explicit in +classes-source/1; core-source/1 keeps primitive arithmetic and generator support. + +| Stage | Finite contract | +| --- | --- | +| 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. | + +Implicit operations consult type/MRO slots and descriptor binding, ignoring +instance shadow attributes. Signature and callback failures propagate; a method +body's AttributeError is not interpreted as a missing arithmetic method. +Descriptor callbacks and operators run on ordinary owned frames. All candidates, +operands, intermediate results and outer destinations remain rooted across +collection, stepping, cancellation and allocation failure. Attribute mutation +retains the existing rollback and bounded namespace rules. Hard teardown executes +no new user finalizers and releases all logical owners. + +This delivery does not complete INT-OBJECT-01, INT-09, native extensions or async +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). diff --git a/tests/fixtures/interpreter/generators.json b/tests/fixtures/interpreter/generators.json new file mode 100644 index 0000000..d5f1169 --- /dev/null +++ b/tests/fixtures/interpreter/generators.json @@ -0,0 +1,283 @@ +{ + "schema_version": 1, + "suite": "interpreter-generators/1", + "profile": "classes-source/1", + "reference_version": "3.14.7", + "implemented_stages": [], + "cases": [ + { + "id": "lazy-binding", + "stage": "G1", + "source": "def g(x):\n print('body')\n yield x\na=g(4)\nprint('made',iter(a) is a)\nprint(next(a))\nprint(next(a,9))\n", + "stdout": "made True\nbody\n4\n9\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "binding-before-body", + "stage": "G1", + "source": "def g(x):\n print('bad')\n yield x\ng()\n", + "error_type": "TypeError", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "send-return", + "stage": "G1", + "source": "def g():\n x=yield 2\n yield x+1\n return 7\na=g()\nprint(a.send(None),a.send(4))\ntry: next(a)\nexcept StopIteration as e: print(e.value)\nprint(next(a,99))\n", + "stdout": "2 5\n7\n99\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "send-new", + "stage": "G1", + "source": "def g():\n yield 1\ng().send(3)\n", + "error_type": "TypeError", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "throw-catch-finally", + "stage": "G1", + "source": "def g():\n try:\n yield 1\n except ValueError as e:\n yield str(e)\n finally:\n print('final')\na=g()\nprint(next(a))\nprint(a.throw(ValueError('caught')))\nprint(a.close(),a.close())\n", + "stdout": "1\ncaught\nfinal\nNone None\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "throw-new-closed", + "stage": "G1", + "source": "def g():\n print('bad')\n yield 1\na=g()\ntry: a.throw(KeyError)\nexcept KeyError: print('new')\ntry: a.throw(ValueError('closed'))\nexcept ValueError: print('closed')\nprint(next(a,8))\n", + "stdout": "new\nclosed\n8\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "close-return", + "stage": "G1", + "source": "def g():\n try: yield 1\n except GeneratorExit: return 42\na=g()\nprint(next(a),a.close(),a.close())\n", + "stdout": "1 42 None\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "close-ignored", + "stage": "G1", + "source": "def g():\n try: yield 1\n except GeneratorExit: yield 2\n yield 3\na=g()\nprint(next(a))\ntry: a.close()\nexcept RuntimeError: print('ignored')\nprint(next(a),a.close())\n", + "stdout": "1\nignored\n3 None\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "escaping-stop", + "stage": "G1", + "source": "def g():\n yield 1\n raise StopIteration('bad')\na=g()\nprint(next(a))\nnext(a)\n", + "stdout": "1\n", + "error_type": "RuntimeError", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "nested-finally-yields", + "stage": "G1", + "source": "def g():\n try:\n for i in range(2):\n try: yield i\n finally: yield i+10\n finally: print('done')\na=list(g())\nprint(a[0],a[1],a[2],a[3])\n", + "stdout": "done\n0 10 1 11\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "delegation-return-send", + "stage": "G1", + "source": "def child():\n x=yield 1\n yield x\n return 9\ndef parent():\n r=yield from child()\n yield r\na=parent()\nprint(next(a),a.send(4),next(a),next(a,0))\n", + "stdout": "1 4 9 0\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "delegation-throw-close", + "stage": "G1", + "source": "def child():\n try:\n yield 1\n except ValueError:\n yield 2\n finally: print('child')\ndef parent():\n try: yield from child()\n finally: print('parent')\na=parent()\nprint(next(a),a.throw(ValueError))\nprint(a.close())\n", + "stdout": "1 2\nchild\nparent\nNone\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "delegation-primitive", + "stage": "G1", + "source": "def g():\n r=yield from [2,3]\n yield r\na=g()\nprint(next(a),a.send(None),next(a),next(a,7))\n", + "stdout": "2 3 None 7\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "delegation-missing-send", + "stage": "G1", + "source": "def g():\n yield from [1,2]\na=g()\nprint(next(a))\na.send(3)\n", + "stdout": "1\n", + "error_type": "AttributeError", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "reentry", + "stage": "G1", + "source": "def g():\n try: next(a)\n except ValueError: print('running')\n yield 3\na=g()\nprint(next(a))\n", + "stdout": "running\n3\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "except-state", + "stage": "G1", + "source": "def g():\n try: raise ValueError('inside')\n except ValueError:\n yield 1\n raise\na=g()\nprint(next(a))\ntry: next(a)\nexcept ValueError as e: print(str(e))\n", + "stdout": "1\ninside\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "class-generator", + "stage": "G1", + "source": "class A:\n def values(self):\n yield self.x\na=A()\na.x=5\nprint(next(a.values()))\n", + "stdout": "5\n" + }, + { + "id": "list-nested-filters", + "stage": "G2", + "source": "x=90\na=[x*10+y for x in range(4) if x%2 for y in range(3) if y!=1]\nprint(x,len(a),a[0],a[3])\n", + "stdout": "90 4 10 32\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "dict-set", + "stage": "G2", + "source": "def f(x):\n print(x)\n return x\na={f(x):f(x+10) for x in range(2)}\nb={x%2 for x in range(4)}\nprint(a[1],len(b),1 in b)\n", + "stdout": "0\n10\n1\n11\n11 2 True\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "generator-outer-eager", + "stage": "G2", + "source": "def outer():\n print('outer')\n return [1,2]\ndef item(x):\n print('item',x)\n return x+4\na=(item(x) for x in outer())\nprint('made')\nprint(next(a),next(a))\n", + "stdout": "outer\nmade\nitem 1\nitem 2\n5 6\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "comprehension-closures", + "stage": "G2", + "source": "a=[lambda: x for x in range(3)]\nprint(a[0](),a[2]())\nx=9\nb=(lambda: x for x in range(2))\nf=next(b)\nprint(f())\nnext(b)\nprint(f(),x)\n", + "stdout": "2 2\n0\n1 9\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "comprehension-unpack", + "stage": "G2", + "source": "a=[x+y+len(z) for x,*z,y in [(1,2,3,4),(5,6)]]\nprint(a[0],a[1])\n", + "stdout": "7 11\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "comprehension-class-scope", + "stage": "G2", + "source": "x=8\nclass A:\n x=[2,3]\n y=[x for i in x]\nprint(A.y[0],A.y[1])\n", + "stdout": "8 8\n" + }, + { + "id": "comprehension-stop", + "stage": "G2", + "source": "def f(): raise StopIteration\nx=[f() for i in [1]]\n", + "error_type": "StopIteration", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "genexpr-stop", + "stage": "G2", + "source": "def f(): raise StopIteration\nnext(f() for i in [1])\n", + "error_type": "RuntimeError", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "async-generator", + "stage": "Future", + "source": "async def g():\n yield 1\nprint(callable(g))\n", + "stdout": "True\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "generator-reflection", + "stage": "Future", + "source": "def g(): yield 1\na=g()\nprint(a.gi_running)\n", + "stdout": "False\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "comprehension-walrus", + "stage": "Future", + "source": "a=[(y:=x) for x in range(2)]\nprint(y)\n", + "stdout": "1\n", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + } + ] +} diff --git a/tests/fixtures/interpreter/object-protocols.json b/tests/fixtures/interpreter/object-protocols.json new file mode 100644 index 0000000..b679de0 --- /dev/null +++ b/tests/fixtures/interpreter/object-protocols.json @@ -0,0 +1,99 @@ +{ + "schema_version": 1, + "suite": "interpreter-object-protocols/1", + "profile": "classes-source/1", + "reference_version": "3.14.7", + "implemented_stages": [], + "cases": [ + { + "id": "attribute-defaults", + "stage": "O1", + "source": "class A: pass\na=A()\nsetattr(a,'x',7)\nprint(getattr(a,'x'),hasattr(a,'x'),getattr(a,'missing',9))\ndelattr(a,'x')\nprint(hasattr(a,'x'))\n", + "stdout": "7 True 9\nFalse\n" + }, + { + "id": "attribute-property", + "stage": "O1", + "source": "class A:\n @property\n def x(self):\n print('get')\n return 4\n @x.setter\n def x(self,value): self.saved=value\n @x.deleter\n def x(self): print('delete')\na=A()\nsetattr(a,'x',8)\nprint(getattr(a,'x'),a.saved)\ndelattr(a,'x')\n", + "stdout": "get\n4 8\ndelete\n" + }, + { + "id": "attribute-suppress", + "stage": "O1", + "source": "class A:\n @property\n def x(self): raise AttributeError('missing')\na=A()\nprint(getattr(a,'x',8),hasattr(a,'x'))\n", + "stdout": "8 False\n" + }, + { + "id": "attribute-propagate", + "stage": "O1", + "source": "class A:\n @property\n def x(self): raise ValueError('bad')\nprint(hasattr(A(),'x'))\n", + "error_type": "ValueError" + }, + { + "id": "attribute-string", + "stage": "O1", + "source": "class A: pass\ngetattr(A(),3)\n", + "error_type": "TypeError" + }, + { + "id": "attribute-no-mangling", + "stage": "O1", + "source": "class A:\n def __init__(self): self.__x=1\na=A()\nsetattr(a,'__x',2)\nprint(getattr(a,'__x'),getattr(a,'_A__x'))\n", + "stdout": "2 1\n" + }, + { + "id": "arithmetic-reflected", + "stage": "O2", + "source": "class A:\n def __add__(self,other):\n print('left')\n return NotImplemented\nclass B:\n def __radd__(self,other):\n print('right')\n return [7]\nprint((A()+B())[0])\n", + "stdout": "left\nright\n7\n" + }, + { + "id": "subclass-priority", + "stage": "O2", + "source": "class A:\n def __add__(self,other):\n print('left')\n return 1\n def __radd__(self,other):\n print('base-right')\n return 2\nclass B(A):\n def __radd__(self,other):\n print('sub-right')\n return 3\nprint(A()+B())\n", + "stdout": "sub-right\n3\n" + }, + { + "id": "inplace-fallback", + "stage": "O2", + "source": "class A:\n def __iadd__(self,other): return NotImplemented\n def __add__(self,other): return other+4\na=A()\na+=3\nprint(a)\n", + "stdout": "7\n" + }, + { + "id": "same-type-no-reflect", + "stage": "O2", + "source": "class A:\n def __add__(self,other): return NotImplemented\n def __radd__(self,other):\n print('bad')\n return 1\nA()+A()\n", + "error_type": "TypeError" + }, + { + "id": "primitive-reflect", + "stage": "O2", + "source": "class A:\n def __rsub__(self,other): return other+2\n def __neg__(self): return 9\nprint(3-A(),-A())\n", + "stdout": "5 9\n" + }, + { + "id": "power-divmod", + "stage": "O2", + "source": "class A:\n def __pow__(self,other,mod=None): return other if mod is None else mod\n def __rpow__(self,other,mod=None): return other+1\n def __divmod__(self,other): return (other,3)\na=A()\nb=divmod(a,7)\nprint(pow(a,4),pow(a,4,9),pow(2,a),b[0],b[1])\n", + "stdout": "4 9 3 7 3\n" + }, + { + "id": "arithmetic-body-error", + "stage": "O2", + "source": "class A:\n def __add__(self,other): raise AttributeError('body')\nA()+1\n", + "error_type": "AttributeError" + }, + { + "id": "new-future", + "stage": "Future", + "source": "class A:\n def __new__(cls): return object.__new__(cls)\nprint(type(A()) is A)\n", + "stdout": "True\n" + }, + { + "id": "hooks-future", + "stage": "Future", + "source": "class A:\n def __getattr__(self,name): return 2\nprint(A().x)\n", + "stdout": "2\n" + } + ] +} diff --git a/tests/test_generator_contract.py b/tests/test_generator_contract.py new file mode 100644 index 0000000..27980c6 --- /dev/null +++ b/tests/test_generator_contract.py @@ -0,0 +1,23 @@ +import json +from pathlib import Path +import subprocess +import sys +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class GeneratorObjectContractTests(unittest.TestCase): + def test_pinned_reference_contracts(self): + for name in ("generators", "object-protocols"): + with self.subTest(corpus=name): + path = ROOT / "tests/fixtures/interpreter" / (name + ".json") + corpus = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual(corpus["reference_version"], "3.14.7") + self.assertEqual(len(corpus["cases"]), len({c["id"] for c in corpus["cases"]})) + process = subprocess.run( + [sys.executable, "runtime/check_classes.py", "--reference-only", "--corpus", str(path)], + cwd=ROOT, capture_output=True, text=True, encoding="utf-8", timeout=30, + ) + self.assertEqual(process.returncode, 0, process.stdout + process.stderr) From c2e0f3c55c77cba6d607580cfc13b8cfd9c18eda Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:41:47 -0700 Subject: [PATCH 2/6] feat(INT-GEN-01B): execute owned suspended generators --- .github/workflows/ci.yml | 8 + docs/interpreter-compatibility.json | 11 +- docs/interpreter-compatibility.md | 2 +- docs/interpreter-generators.md | 5 +- runtime/function-bytecode.mjs | 6 +- runtime/function-objects.mjs | 14 +- runtime/functions.mjs | 143 ++++++++++++++++-- runtime/generators.test.mjs | 67 ++++++++ runtime/operation-runtime.mjs | 35 ++++- runtime/operations.mjs | 5 + runtime/source-compiler.mjs | 1 + runtime/source-parser.mjs | 8 +- runtime/values.mjs | 9 +- src/pocket_python/_interpreter_lock.py | 19 +-- tests/fixtures/interpreter/compatibility.json | 8 +- tests/fixtures/interpreter/generators.json | 30 +++- 16 files changed, 323 insertions(+), 48 deletions(-) create mode 100644 runtime/generators.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 769938f..bf11946 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,10 @@ jobs: - run: uv run --no-sync python runtime/check_classes.py --target browser --corpus tests/fixtures/interpreter/sequences.json --profile core-source/1 --stepped --report build/sequences-core-browser-stepped-report.json - run: uv run --no-sync python runtime/check_compatibility.py --target browser --report build/compatibility-browser-report.json - run: uv run --no-sync python runtime/check_library_profiles.py --target browser --report build/library-profiles-browser-report.json + - run: uv run --no-sync python runtime/check_classes.py --target browser --corpus tests/fixtures/interpreter/generators.json --report build/generators-browser-report.json + - run: uv run --no-sync python runtime/check_classes.py --target browser --corpus tests/fixtures/interpreter/generators.json --stepped --report build/generators-browser-stepped-report.json + - run: uv run --no-sync python runtime/check_classes.py --target browser --corpus tests/fixtures/interpreter/generators.json --profile core-source/1 --report build/generators-core-browser-report.json + - run: uv run --no-sync python runtime/check_classes.py --target browser --corpus tests/fixtures/interpreter/generators.json --profile core-source/1 --stepped --report build/generators-core-browser-stepped-report.json - run: uv run --no-sync python runtime/check_classes.py --target browser --report build/classes-browser-report.json - run: uv run --no-sync python runtime/check_classes.py --target browser --corpus tests/fixtures/interpreter/value-families.json --report build/value-families-browser-report.json - run: uv run --no-sync python runtime/check_classes.py --target browser --corpus tests/fixtures/interpreter/value-families.json --stepped --report build/value-families-browser-stepped-report.json @@ -187,6 +191,10 @@ jobs: - run: python runtime/check_classes.py --reference-only --corpus tests/fixtures/interpreter/length-truth.json --report build/length-truth-reference-report.json - run: python runtime/check_classes.py --reference-only --corpus tests/fixtures/interpreter/execution.json --report build/execution-reference-report.json - run: python runtime/check_classes.py --reference-only --corpus tests/fixtures/interpreter/formatting.json --report build/formatting-reference-report.json + - run: python runtime/check_classes.py --target node --corpus tests/fixtures/interpreter/generators.json --report build/generators-node-report.json + - run: python runtime/check_classes.py --target node --corpus tests/fixtures/interpreter/generators.json --stepped --report build/generators-node-stepped-report.json + - run: python runtime/check_classes.py --target node --corpus tests/fixtures/interpreter/generators.json --profile core-source/1 --report build/generators-core-node-report.json + - run: python runtime/check_classes.py --target node --corpus tests/fixtures/interpreter/generators.json --profile core-source/1 --stepped --report build/generators-core-node-stepped-report.json - run: python runtime/check_classes.py --target node --report build/classes-node-report.json - run: python runtime/check_classes.py --target node --corpus tests/fixtures/interpreter/value-families.json --report build/value-families-node-report.json - run: python runtime/check_classes.py --target node --corpus tests/fixtures/interpreter/value-families.json --stepped --report build/value-families-node-stepped-report.json diff --git a/docs/interpreter-compatibility.json b/docs/interpreter-compatibility.json index 2517263..d98004c 100644 --- a/docs/interpreter-compatibility.json +++ b/docs/interpreter-compatibility.json @@ -485,9 +485,9 @@ }, { "id": "generators-comprehensions", - "status": "deferred", - "scope": "yield/yield from, generator methods, list/dict/set/generator comprehensions and suspended ownership.", - "gaps": "No admitted generator or comprehension syntax.", + "status": "partial", + "scope": "Generator functions, yield/yield from, next/send/one-argument throw/close with owned suspended frames and shared budgets.", + "gaps": "Comprehensions, async generators, reflective frames and multi-argument throw remain separate stages.", "reference_version": "3.14.7", "targets": [ "node", @@ -500,7 +500,10 @@ "generator", "comprehension" ], - "evidence": [] + "evidence": [ + "tests/fixtures/interpreter/generators.json:send-return", + "tests/fixtures/interpreter/generators.json:delegation-throw-close" + ] }, { "id": "context-patterns", diff --git a/docs/interpreter-compatibility.md b/docs/interpreter-compatibility.md index 857bfcf..cfba5b8 100644 --- a/docs/interpreter-compatibility.md +++ b/docs/interpreter-compatibility.md @@ -70,7 +70,7 @@ issue families; availability alone never certifies an entire type or module. | 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. | | 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 | deferred | yield/yield from, generator methods, list/dict/set/generator comprehensions and suspended ownership. No admitted generator or comprehension syntax. | +| generators-comprehensions | partial | Owned generator functions, yield/yield from, next/send/throw/close and shared budgets. Comprehensions and async/reflection remain separate stages. | | context-patterns | deferred | with and synchronous context manager protocols; match/case patterns and guards. No admitted source execution. Async context managers are a separate async gate. | | 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. | diff --git a/docs/interpreter-generators.md b/docs/interpreter-generators.md index 79f4f1f..4db50db 100644 --- a/docs/interpreter-generators.md +++ b/docs/interpreter-generators.md @@ -2,7 +2,7 @@ INT-GEN-01 uses the pinned CPython 3.14.7 executable reference. The authored `generators.json` corpus separates reference-only evidence from independently -compiled source. G1 and G2 require Node and Chromium, both profiles and both +compiled source. G1 is implemented; G2 remains the next stage. Both require Node and Chromium, both profiles and both draining and instruction-boundary execution before acceptance. | Stage | Finite contract | @@ -29,7 +29,8 @@ executing arbitrary Python finalizers, as with existing hard interruption. Garbage collection does not promise CPython reference-count finalization timing. Generator aliases and cycles survive collection at any instruction boundary. -Repeated close and session teardown must release logical objects, bytes, frames,+roots and external leases. These logical observations make no host RSS claim. +Repeated close and session teardown must release logical objects, bytes, frames, +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). diff --git a/runtime/function-bytecode.mjs b/runtime/function-bytecode.mjs index c5f834b..95149a2 100644 --- a/runtime/function-bytecode.mjs +++ b/runtime/function-bytecode.mjs @@ -130,7 +130,9 @@ export function verifyFunctions(program) { length = 3; if (!integer(arg, 128) || extra !== null && (!integer(extra, 128) || arg + extra + 1 > 128)) fail("Invalid unpacking counts."); } - else if (modern && ["list-push", "list-extend", "list-to-tuple", "dict-merge", "dict-insert", "set-insert", "set-extend"].includes(op)) {} + else if (modern && ["yield", "yield-from", "list-push", "list-extend", "list-to-tuple", "dict-merge", "dict-insert", "set-insert", "set-extend"].includes(op)) { + if (["yield", "yield-from"].includes(op) && (functionIndex === 0 || kind === "class")) fail("Yield requires a function body."); + } else if (modern && op === "assertion-error") { length = 2; if (typeof arg !== "boolean") fail("Invalid assertion message flag."); } else if (modern && op === "call-keyword") { length = 2; if (!validName(arg)) fail("Invalid assembled keyword name."); } else if (modern && ["call-start", "call-argument", "call-star", "call-mapping", "call-expanded"].includes(op)) {} @@ -167,7 +169,7 @@ export function verifyFunctions(program) { const at = pending[cursor], [op, arg, extra] = instructions[at], before = heights.get(at); const needs = op === "dict-insert" ? 3 : ["list-push", "list-extend", "dict-merge", "set-insert", "set-extend"].includes(op) ? 2 : ["unpack", "list-to-tuple"].includes(op) ? 1 : op === "assertion-error" ? Number(arg) : ["call-argument", "call-star", "call-mapping", "call-keyword"].includes(op) ? 2 : ["call-start", "call-expanded"].includes(op) ? 1 : op === "function-defaults" ? extra + instructions[at][3].length : op === "class" ? extra : op === "attribute-set" ? 2 : BINARY.has(op) || op === "swap" ? 2 : ["setitem", "build-slice"].includes(op) ? 3 : op === "slice" ? 4 : op === "call" ? 1 + arg + extra.length : CONSTRUCT.has(op) ? arg * (op === "build-dict" ? 2 : 1) : - UNARY.has(op) || ["attribute-get", "attribute-delete", "module-get", "import-from", "store", "pop", "dup", "if", "jump-if-false", "return", "raise"].includes(op) ? 1 : 0; + UNARY.has(op) || ["yield", "yield-from", "attribute-get", "attribute-delete", "module-get", "import-from", "store", "pop", "dup", "if", "jump-if-false", "return", "raise"].includes(op) ? 1 : 0; if (before < needs) fail("Reachable function stack underflow."); if (["end", "return", "raise", "reraise", "break", "continue"].includes(op)) { if (before !== (op === "end" ? Number(block.kind === "expression") : needs)) fail("Block completion requires an exact stack height."); diff --git a/runtime/function-objects.mjs b/runtime/function-objects.mjs index 78dfcfb..36976e4 100644 --- a/runtime/function-objects.mjs +++ b/runtime/function-objects.mjs @@ -1,4 +1,5 @@ import {DESCRIPTOR_HOOKS} from "./class-policy.mjs"; +import {controlRoots} from "./operations.mjs"; // VM-owned edges are explicit so closed-over cells and recursive graphs are rooted. export class Cell { constructor() { this.bound = false; this.value = null; } @@ -21,6 +22,17 @@ export class FunctionValue { this.defaults = defaults === null ? null : Object.freeze([...defaults]); } } +export class GeneratorValue { + constructor(callable, bindings) { + this.callable = callable; this.bindings = bindings; this.frame = null; + this.status = "created"; + } +} +export function generatorRoots(value) { + const frame = value.frame; + return [value.callable, ...value.bindings.values(), ...(frame ? + [...frame.locals.values(), frame.activeException, ...frame.tasks.flatMap(controlRoots)] : [])]; +} export class CallArguments { constructor(callable) { this.callable = callable; this.args = []; this.keywords = []; this.keywordValues = []; this.keywordMap = null; } } @@ -70,7 +82,7 @@ export class ExceptionValue { } } export const EXCEPTIONS = Object.freeze({ - BaseException: null, Exception: "BaseException", AssertionError: "Exception", ArithmeticError: "Exception", + BaseException: null, GeneratorExit: "BaseException", Exception: "BaseException", AssertionError: "Exception", ArithmeticError: "Exception", ZeroDivisionError: "ArithmeticError", OverflowError: "ArithmeticError", LookupError: "Exception", IndexError: "LookupError", KeyError: "LookupError", ValueError: "Exception", TypeError: "Exception", UnicodeError: "ValueError", UnicodeEncodeError: "UnicodeError", UnicodeDecodeError: "UnicodeError", UnicodeTranslateError: "UnicodeError", diff --git a/runtime/functions.mjs b/runtime/functions.mjs index d52f024..ae3a62c 100644 --- a/runtime/functions.mjs +++ b/runtime/functions.mjs @@ -1,5 +1,5 @@ import {verifyFunctions} from "./function-bytecode.mjs"; -import {Cell, Environment, CodePool, ModuleValue, FunctionValue, CallArguments, BuiltinValue, AppendValue, ValueMethod, ExceptionValue, EXCEPTIONS, matchesException, ClassValue, InstanceValue, BoundMethodValue, PropertyValue, MethodWrapperValue, ProtocolIteratorValue, NotImplementedValue} from "./function-objects.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"; import {ValueRuntime, valueLayout, IteratorValue} from "./values.mjs"; import {MemoryArena} from "./memory.mjs"; @@ -174,7 +174,7 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = tasks: [], activeException: state.frames.at(-1)?.activeException || inheritedException, callsite, completion, namespaceOrder: fn.kind === "class" ? new Set() : null, returnInstruction: state.current?.instruction ?? null}; frame.leave = memory.enter(() => [callable, ...frame.locals.values(), ...(frame.activeException ? [frame.activeException] : []), - ...frame.tasks.flatMap(controlRoots), ...completionRoots(completion, true)], {charge, fail: hard}); + ...frame.tasks.flatMap(controlRoots), ...(frame.generator ? [frame.generator] : []), ...completionRoots(frame.completion, true)], {charge, fail: hard}); state.frames.push(frame); for (const key of fn.locals) { let cell = frame.locals.get(key); @@ -187,7 +187,97 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = block(frame, fn.body); }; const {dispatch, call, deliver, notifyNext, recover, argumentOwner, appendArgument, appendKeyword, expandArguments, keywordArgument, expandMapping, finishArguments, expandDisplay, expandUnpack} = operationRuntime({state, memory, values, classes, - moduleContext, maxDepth, allocate, mutate, python, hard, charge, enter, bind, block, store, onOutput}); + moduleContext, maxDepth, allocate, mutate, python, hard, charge, enter, bind, block, store, onOutput, + resumeGenerator: (...args) => resumeGenerator(...args), yieldGenerator: value => yieldGenerator(value), + delegateResult: (...args) => delegateResult(...args)}); + const leaveGenerator = (frame, status) => { + const generator = frame.generator, completion = frame.completion; + state.temporaries.push(generator, ...completionRoots(completion)); + frame.completion = null; frame.callsite = null; + if (!frame.ownsException) frame.activeException = null; + for (const task of frame.tasks) if (task.type === "try" && !task.previousOwned) task.previous = null; + generator.status = status; generator.frame = frame; + frame.leave(); state.frames.pop(); + return completion; + }; + const yieldGenerator = value => { + const frame = state.frames.at(-1); + if (!frame.generator) hard("BYTECODE", "BytecodeError", "Yield requires a generator frame."); + state.temporaries.push(value); + const mode = frame.generatorMode, site = frame.callsite; + const completion = leaveGenerator(frame, "suspended"); + if (mode === "close") python("RuntimeError", "generator ignored GeneratorExit"); + dispatch(deliver(value, completion, site)); + }; + const delegateMethod = (iterator, name) => { + if (iterator instanceof GeneratorValue) return {kind: "value", value: allocate(ValueMethod, iterator, name)}; + if (iterator instanceof InstanceValue) return classes.read(iterator, name); + python("AttributeError", "Delegate has no attribute '" + name + "'"); + }; + const delegateAdvance = (mode, value) => { + const frame = state.frames.at(-1), task = frame.tasks.at(-1), site = task.site; + if (mode === "throw" && value.name === "GeneratorExit") mode = "close"; + if (mode === "close") task.closing = value instanceof ExceptionValue ? value : allocate(ExceptionValue, "GeneratorExit", [], ""); + const finish = continuation(mode === "close" ? "delegate-close" : "delegate-next", {receiver: frame.generator}); + if (mode === "send" && value === null) { dispatch(unaryTask("next", task.iterator, site, finish)); return; } + let plan; + try { plan = delegateMethod(task.iterator, mode); } + catch (error) { + if (!(error instanceof Raised) || error.value.name !== "AttributeError" || mode === "send") throw error; + if (mode === "close") { handle(new Raised(task.closing)); return; } + handle(new Raised(value)); return; + } + const args = mode === "close" ? [] : [value]; + if (plan.kind === "value") call(plan.value, args, [], [], site, finish); + else call(plan.callable, plan.args, [], [], site, mode === "send" ? + continuation("invoke", {args, keywords: [], keywordValues: [], after: finish, depth: 0}) : + continuation("delegate-bind", {receiver: mode === "close" ? task.closing : value, args, after: finish})); + }; + const delegateResult = (kind, value, site) => { + const frame = state.frames.at(-1), task = frame.tasks.at(-1); + if (kind === "delegate-iter") { task.iterator = value; delegateAdvance("send", null); } + else if (kind === "delegate-next") yieldGenerator(value); + else if (kind === "delegate-close") handle(new Raised(task.closing)); + else { frame.tasks.pop(); frame.tasks.at(-1).stack.push(value); } + return null; + }; + const resumeGenerator = (generator, mode, value, site, completion) => { + if (generator.status === "running") python("ValueError", "generator already executing"); + if (mode === "throw") { + if (value instanceof BuiltinValue && Object.hasOwn(EXCEPTIONS, value.name)) value = allocate(ExceptionValue, value.name, [], ""); + if (!(value instanceof ExceptionValue)) python("TypeError", "exceptions must derive from BaseException"); + } + const fresh = generator.status === "created"; + if (fresh && mode === "send" && value !== null) python("TypeError", "can't send non-None value to a just-started generator"); + if (generator.status === "closed" || fresh && mode !== "send") { + generator.status = "closed"; generator.callable = null; generator.bindings.clear(); + if (mode === "close") { dispatch(deliver(null, completion, site)); return; } + if (mode === "throw") throw new Raised(value); + python("StopIteration", ""); + } + state.temporaries.push(generator, value, ...completionRoots(completion)); + if (fresh) { + enter(generator.callable, generator.bindings, site, completion); + generator.frame = state.frames.at(-1); generator.frame.generator = generator; + generator.bindings.clear(); + } else { + if (state.frames.length >= Math.min(maxDepth, memory.limits.maxFrames - 1)) python("RecursionError", "maximum recursion depth exceeded"); + const frame = generator.frame; + const inherited = state.frames.at(-1)?.activeException || inheritedException; + if (!frame.ownsException) frame.activeException = inherited; + for (const task of frame.tasks) if (task.type === "try" && !task.previousOwned) task.previous = inherited; + frame.completion = completion; frame.callsite = site; frame.returnInstruction = state.current?.instruction ?? null; + frame.leave = memory.enter(() => [generator, frame.callable, ...frame.locals.values(), frame.activeException, + ...frame.tasks.flatMap(controlRoots), ...completionRoots(frame.completion, true)], {charge, fail: hard}); + state.frames.push(frame); + } + generator.status = "running"; + const frame = generator.frame; frame.generatorMode = mode; + if (frame.tasks.at(-1).type === "delegate") delegateAdvance(mode, value); + else if (mode === "close") handle(new Raised(allocate(ExceptionValue, "GeneratorExit", [], ""))); + else if (mode === "throw") handle(new Raised(value)); + else if (!fresh) frame.tasks.at(-1).stack.push(value); + }; const diagnostic = value => { const error = new ExecutionError("PYTHON", value.name, value.message, state.current?.instruction ?? null, null); // Copy diagnostics without exporting unleased VM objects or frame locals. @@ -206,6 +296,7 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = if (signal.kind === "normal") return; frame.tasks.pop(); continue; } + if (task.type === "delegate") { frame.tasks.pop(); continue; } if (task.type === "while" || task.type === "for") { if (signal.kind === "break") { frame.tasks.pop(); signal = {kind: "normal"}; continue; } if (!["normal", "continue"].includes(signal.kind)) { frame.tasks.pop(); continue; } @@ -216,7 +307,7 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = } task.phase = "test"; block(frame, task.test); return; } - if (task.phase === "iterable" && (signal.value instanceof InstanceValue || signal.value instanceof ProtocolIteratorValue)) { + if (task.phase === "iterable" && (signal.value instanceof GeneratorValue || signal.value instanceof InstanceValue || signal.value instanceof ProtocolIteratorValue)) { task.owned = true; task.phase = "acquire"; dispatch(unaryTask("iter", signal.value, task.site, continuation("for-iter"))); return; } @@ -234,7 +325,7 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = const [, body, handlers, otherwise, final] = task.instruction; void body; if (task.phase === "body" && signal.kind === "raise") { - task.phase = "matching"; task.caught = signal.value; frame.activeException = signal.value; + task.phase = "matching"; task.caught = signal.value; frame.activeException = signal.value; frame.ownsException = true; const clause = handlers.find(([types, , , span]) => { state.current = {...state.current, location: task.site ? {filename: task.site.filename, span} : null}; const resolved = types.map(type => { @@ -257,23 +348,40 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = } if (task.phase === "handler") { if (task.alias) store(frame, task.alias, null, false); - frame.activeException = task.previous; + frame.activeException = task.previous; frame.ownsException = task.previousOwned; } if (task.phase === "finally") { if (signal.kind === "normal") signal = task.pending; - frame.activeException = task.previous; frame.tasks.pop(); continue; + frame.activeException = task.previous; frame.ownsException = task.previousOwned; frame.tasks.pop(); continue; } if (final !== null) { task.pending = signal; task.phase = "finally"; frame.activeException = signal.kind === "raise" ? signal.value : task.previous; + frame.ownsException = signal.kind === "raise" || task.previousOwned; block(frame, final); return; } - frame.activeException = task.previous; frame.tasks.pop(); continue; + frame.activeException = task.previous; frame.ownsException = task.previousOwned; frame.tasks.pop(); continue; } hard("BYTECODE", "BytecodeError", "Unknown control record."); } if (["break", "continue"].includes(signal.kind)) hard("BYTECODE", "BytecodeError", "Loop control outside a loop."); - const returned = signal.kind === "normal" ? null : signal.value; + let returned = signal.kind === "normal" ? null : signal.value; + if (frame.generator) { + const generator = frame.generator, mode = frame.generatorMode; + generator.status = "closed"; generator.frame = null; generator.callable = null; generator.bindings.clear(); + if (signal.kind === "raise" && returned.name === "StopIteration") { + const original = returned; + returned = exception("RuntimeError", "generator raised StopIteration"); + returned.context = original; + signal = {kind: "raise", value: returned}; + } + if (signal.kind !== "raise" && mode !== "close") { + returned = exception("StopIteration", "", returned === null ? [] : [returned]); + signal = {kind: "raise", value: returned}; + } else if (signal.kind === "raise" && mode === "close" && ["GeneratorExit", "StopIteration"].includes(returned.name)) { + returned = null; signal = {kind: "return", value: null}; + } + } state.temporaries = [returned, frame.callable]; const parent = state.frames.at(-2); if (!parent) { @@ -352,6 +460,11 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = store(state.frames.at(-1), "__module__", values.string(Array.from(name, char => char.codePointAt(0)))); store(state.frames.at(-1), "__qualname__", values.string(Array.from(owner.code.functions[arg].qualname, char => char.codePointAt(0)))); } + } else if (op === "yield") yieldGenerator(stack.pop()); + else if (op === "yield-from") { + const iterable = stack.pop(); + frame.tasks.push({type: "delegate", iterator: null, site: summary(frame, cursor)}); + dispatch(unaryTask("iter", iterable, summary(frame, cursor), continuation("delegate-iter", {receiver: frame.generator}))); } else if (op === "assertion-error") { const args = arg ? [stack.pop()] : []; const callable = moduleContext.builtin("AssertionError", () => allocate(BuiltinValue, "AssertionError")); @@ -383,7 +496,11 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = inheritedException: frame.activeException}; else { const receiver = stack.pop(); - if (values.isComplex(receiver)) stack.push(values.complexAttribute(receiver, arg)); + if (receiver instanceof GeneratorValue) { + if (!["send", "throw", "close", "__iter__", "__next__"].includes(arg)) hard("UNSUPPORTED", "UnsupportedOperationError", "Generator reflection requires a separate stage."); + stack.push(allocate(ValueMethod, receiver, arg)); + } else if (receiver instanceof ExceptionValue && arg === "value" && receiver.name === "StopIteration") stack.push(receiver.args[0] ?? null); + else if (values.isComplex(receiver)) stack.push(values.complexAttribute(receiver, arg)); else if (values.isRange(receiver)) stack.push(values.rangeAttribute(receiver, arg)); else if (values.isSet(receiver)) { values.setMethod(receiver, arg); stack.push(allocate(ValueMethod, receiver, arg)); } else if (values.isView(receiver)) stack.push(values.viewMethod(arg) ? allocate(ValueMethod, receiver, arg) : values.viewAttribute(receiver, arg)); @@ -406,7 +523,7 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = else if (op === "while") { frame.tasks.push({type: "while", phase: "test", test: arg, body: extra, otherwise: fourth, site: summary(frame, cursor)}); block(frame, arg); } else if (op === "for") { frame.tasks.push({type: "for", phase: "iterable", target: arg, body: fourth, otherwise: fifth, iterator: null, site: summary(frame, cursor)}); block(frame, extra); } else if (op === "try") { - frame.tasks.push({type: "try", phase: "body", instruction: cursor.block.instructions[cursor.pc - 1], previous: frame.activeException, + frame.tasks.push({type: "try", phase: "body", instruction: cursor.block.instructions[cursor.pc - 1], previous: frame.activeException, previousOwned: frame.ownsException === true, caught: null, pending: null, alias: null, site: state.current.location}); block(frame, arg); } else if (["end", "return", "break", "continue"].includes(op)) { settle({kind: op === "end" ? "normal" : op, value: op === "return" || cursor.block.kind === "expression" ? stack.pop() : null}); @@ -482,8 +599,8 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = } else if (["truth", "not", "len", "negative", "positive", "invert", "iter", "next"].includes(op)) dispatch(unaryTask(op, stack.pop(), summary(frame, cursor))); - else if (op === "contains" && frame.callable.pool.code.version === 5 && - (stack.at(-1) instanceof InstanceValue || stack.at(-1) instanceof IteratorValue || stack.at(-1) instanceof ProtocolIteratorValue || + else if (op === "contains" && (frame.callable.pool.code.version === 5 || stack.at(-1) instanceof GeneratorValue) && + (stack.at(-1) instanceof GeneratorValue || stack.at(-1) instanceof InstanceValue || stack.at(-1) instanceof IteratorValue || stack.at(-1) instanceof ProtocolIteratorValue || values.hasUserEquality(stack.at(-1)) || values.hasUserEquality(stack.at(-2)) || values.dictionaryNeedsProtocol(stack.at(-1), stack.at(-2)))) { const receiver = stack.pop(); dispatch(containsTask(receiver, stack.pop(), summary(frame, cursor))); diff --git a/runtime/generators.test.mjs b/runtime/generators.test.mjs new file mode 100644 index 0000000..f115a53 --- /dev/null +++ b/runtime/generators.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {compileSource} from './source-compiler.mjs'; +import {compileSourceModules} from './source-modules.mjs'; +import {ModuleSession} from './module-session.mjs'; +import {Execution} from './functions.mjs'; +import {runSourceRequest} from './source-request.mjs'; +import {GeneratorValue} from './function-objects.mjs'; +import {valueLayout} from './values.mjs'; + +const profile = {languageProfile: 'classes-source/1'}; +const unit = source => ({name: 'app', package: false, imports: [], source, notice: 'Synthetic generator fixture'}); +const run = (source, options = {}, memoryLimits) => runSourceRequest({modules: [unit(source)], compileOptions: profile, options, memoryLimits}); +const closed = stats => { for (const key of ['bytes', 'objects', 'roots', 'frames', 'external']) assert.equal(stats[key], 0, key); }; + +test('yield syntax is bound to its own function and verified before effects', () => { + for (const source of ['yield 1\n', 'class A:\n yield 1\n']) assert.throws(() => compileSource(source, profile), {code: 'SYNTAX'}); + const code = structuredClone(compileSource('def g(): yield 1\n').program); + code.functions[0].blocks[0].instructions[0] = ['yield']; + assert.throws(() => new Execution(code), /Yield requires a function/); +}); + +test('generator iterables compose with consumers, displays, unpacking and calls', async () => { + const result = await run('def g():\n yield 1\n yield 2\ndef f(a,b): return a+b\na=[*g()]\nb,c=g()\nprint(a[0],b,c,f(*g()),all(g()),any(g()),len(set(g())),bytes(g())[1],2 in g())\n'); + assert.equal(result.error, undefined, JSON.stringify(result.error)); + assert.equal(result.stdout, '1 1 2 3 True True 2 2 True\n'); closed(result.closed); +}); + +test('suspended aliases and cycles retain cells and controls across every collection boundary', async () => { + const source = 'def inner(n):\n try:\n x=[n]\n yield x\n yield x\n finally: print("final")\ndef outer():\n r=yield from inner(7)\n return r\na=outer()\nb=next(a)\nb.append(a)\nprint(next(a)[0])\nprint(a.close())\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, parked = false; + session.context.begin({shouldCancel: () => cancel}, text => { output += text; }); + const execution = Execution.modules(session.context); + try { + while (execution.state !== 'completed') { + const snapshot = execution.advance(); session.memory.collect([], {cleanup: true}); + const generator = session.context.module('app')?.environment.cells.get('a')?.value; + if (generator instanceof GeneratorValue && generator.status === 'suspended') { + parked = true; assert.ok(valueLayout(generator).children.length > 1); + assert.ok(snapshot.memory.frames < 5); + if (mode === 'cancel') { cancel = true; assert.throws(() => execution.advance(), {code: 'CANCELLED'}); break; } + if (mode === 'close') break; + } + } + assert.ok(parked); assert.equal(output, mode === 'finish' ? '7\nfinal\nNone\n' : ''); + } finally { execution.close(); execution.close(); session.context.finish(); session.close(); } + closed(session.stats()); + } } finally { registry.close(); } +}); + +test('generator resumes share total work/instruction quotas and suspended allocation bounds', async () => { + const prefix = 'def g():\n while True: yield [7]\n'; + for (const [source, options, limits, code] of [ + [prefix+'all(g())\n', {maxSteps: 300}, undefined, 'BUDGET'], + [prefix+'a=[]\nfor x in range(300):\n b=g()\n next(b)\n a.append(b)\n', {maxSteps: 50000}, {maxBytes: 18000}, 'OOM'], + ]) { + const result = await run(source, options, limits); assert.equal(result.error.code, code); closed(result.closed); + } +}); + +test('delegation to user iterators forwards send, throw, close and return values', async () => { + const result = await run('class D:\n def __iter__(self): return self\n def __next__(self): return 1\n def send(self,x): return x+2\n def throw(self,e): raise StopIteration(8)\n def close(self): print("close")\ndef g():\n try:\n r=yield from D()\n yield r\n finally: print("final")\na=g()\nprint(next(a),a.send(3),a.throw(ValueError))\nprint(a.close())\nb=g()\nprint(next(b),b.close())\n'); + assert.equal(result.error, undefined, JSON.stringify(result.error)); + assert.equal(result.stdout, '1 5 8\nfinal\nNone\nclose\nfinal\n1 None\n'); closed(result.closed); +}); diff --git a/runtime/operation-runtime.mjs b/runtime/operation-runtime.mjs index 2a3ff09..94acc90 100644 --- a/runtime/operation-runtime.mjs +++ b/runtime/operation-runtime.mjs @@ -1,4 +1,4 @@ -import {FunctionValue, CallArguments, BuiltinValue, AppendValue, ValueMethod, ExceptionValue, EXCEPTIONS, matchesException, ClassValue, InstanceValue, BoundMethodValue, PropertyValue, MethodWrapperValue, PropertyAccessorValue, ProtocolIteratorValue, NotImplementedValue} from "./function-objects.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"; export class Raised { constructor(value) { this.value = value; } } @@ -6,7 +6,7 @@ export class Raised { constructor(value) { this.value = value; } } // Fixed phases of the existing machine. Python callbacks enter its ordinary // frames; destinations use its block stack and single unwinder. export function operationRuntime({state, memory, values, classes, moduleContext, maxDepth, - allocate, mutate, python, hard, charge, enter, bind, block, store, onOutput}) { + allocate, mutate, python, hard, charge, enter, bind, block, store, onOutput, resumeGenerator, yieldGenerator, delegateResult}) { const count = tag => { state.transitions[tag] = (state.transitions[tag] || 0) + 1; }; const display = value => { if (value instanceof NotImplementedValue) return "NotImplemented"; @@ -286,6 +286,7 @@ export function operationRuntime({state, memory, values, classes, moduleContext, const binding = bindingRecovery(value, completion, site); if (binding) return binding; for (let current = completion; current; current = current.after) { + if (current.kind === "delegate-bind" && value.name === "AttributeError") return unaryTask("delegate-raise", current.receiver, site); if (current.kind.startsWith("call-map-") && (value.name === "AttributeError" || value.name === "KeyError" && current.mode !== "display")) return value.name === "KeyError" ? duplicateKeyword(current.receiver, value.args[0] ?? current.key, site, current.after) : unaryTask("call-mapping-required", current.receiver, site, current.after); @@ -295,6 +296,7 @@ export function operationRuntime({state, memory, values, classes, moduleContext, return unaryTask("iterator-stop", current.receiver, site, current.after); } if (value.name !== "StopIteration") continue; + if (current.kind === "delegate-next") return valueTask(value.args[0] ?? null, site, continuation("delegate-return", {receiver: current.receiver})); if (current.kind === "next-default") return valueTask(current.default, site, current.after); if (current.kind === "for-next") return valueTask(null, site, continuation("for-exhausted")); if (current.kind === "search-next") return valueTask(false, site, current.after); @@ -331,6 +333,8 @@ export function operationRuntime({state, memory, values, classes, moduleContext, } completion = completion.after; } + if (completion?.kind === "delegate-bind") return callTask(value, completion.args, [], [], site, completion.after); + if (completion?.kind.startsWith("delegate-")) return delegateResult(completion.kind, value, site); count("deliver:" + (completion?.kind ?? "stack")); if (completion?.kind === "display-insert") return valueTask(completion.receiver, site, completion.after); if (completion?.kind === "display-iter") return unaryTask("next", value, site, @@ -650,6 +654,7 @@ export function operationRuntime({state, memory, values, classes, moduleContext, count("dispatch:" + task.kind); let {callable, args, keywords, keywordValues, site, completion, depth} = task; try { + if (task.kind === "unary" && task.operation === "delegate-raise") throw new Raised(task.value); if (task.kind === "unary" && task.operation === "unpack-short") { state.temporaries = operationRoots(task); python("ValueError", "Not enough values to unpack"); } @@ -883,6 +888,10 @@ export function operationRuntime({state, memory, values, classes, moduleContext, } if (["iter", "iter-indexed", "next", "iterator-stop"].includes(operation)) { if (operation === "iterator-stop") python("StopIteration", ""); + if (value instanceof GeneratorValue) { + if (operation === "iter") { task = deliver(value, completion, site); continue; } + resumeGenerator(value, "send", null, site, completion); return; + } if (value instanceof ProtocolIteratorValue) { if (operation === "iter") { task = deliver(value, completion, site); continue; } if (value.exhausted) python("StopIteration", ""); @@ -1016,8 +1025,22 @@ export function operationRuntime({state, memory, values, classes, moduleContext, task = callTask(method, args, keywords, keywordValues, site, continuation("init", {instance, after: completion}), depth); continue; } - if (callable instanceof FunctionValue) { enter(callable, bind(callable, args, keywords, keywordValues), site, completion); return; } + if (callable instanceof FunctionValue) { + const bindings = bind(callable, args, keywords, keywordValues); + if (callable.pool.code.functions[callable.index].blocks.some(block => block.instructions.some(([op]) => ["yield", "yield-from"].includes(op)))) { + task = deliver(allocate(GeneratorValue, callable, bindings), completion, site); continue; + } + enter(callable, bindings, site, completion); return; + } if (callable instanceof BuiltinValue) { + if (callable instanceof ValueMethod && callable.receiver instanceof GeneratorValue) { + if (keywords.length || args.length !== (callable.name === "close" || callable.name === "__iter__" || callable.name === "__next__" ? 0 : 1)) { + if (callable.name === "throw" && args.length > 1) hard("UNSUPPORTED", "UnsupportedOperationError", "Multi-argument generator.throw requires a separate stage."); + python("TypeError", "Invalid generator method arguments"); + } + if (callable.name === "__iter__") { task = deliver(callable.receiver, completion, site); continue; } + resumeGenerator(callable.receiver, callable.name === "__next__" ? "send" : callable.name, args[0] ?? null, site, completion); return; + } if (callable instanceof ValueMethod && values.isView(callable.receiver)) { if (args.length || keywords.length) { if (callable.name === "tobytes" && args.length <= 1 && keywords.every(name => name === "order")) @@ -1093,7 +1116,7 @@ export function operationRuntime({state, memory, values, classes, moduleContext, count("builtin:" + callable.name); if (args.length > 1 || keywords.length) python("TypeError", callable.name + "() accepts at most one positional argument"); const source = args[0]; - if (args.length && (source instanceof InstanceValue || source instanceof ProtocolIteratorValue)) { + if (args.length && (source instanceof GeneratorValue || source instanceof InstanceValue || source instanceof ProtocolIteratorValue)) { task = consumeTask(callable.name, source, site, completion, depth); continue; } if (callable.name === "tuple" && values.isSequenceType(source, "tuple")) { task = valueTask(source, site, completion); continue; } @@ -1128,8 +1151,10 @@ export function operationRuntime({state, memory, values, classes, moduleContext, } else task = unaryTask("hash", args[0], site, completion, depth); continue; } - if (["iter", "next"].includes(callable.name) && state.frames.at(-1).callable.pool.code.version === 5) { + if (["iter", "next"].includes(callable.name) && state.frames.at(-1).callable.pool.code.version >= 4) { count("builtin:" + callable.name); + if (state.frames.at(-1).callable.pool.code.version === 4 && args.length === 2 && !(callable.name === "next" && args[0] instanceof GeneratorValue)) + hard("UNSUPPORTED", "UnsupportedOperationError", "This two-argument primitive iteration form requires classes-source/1."); if (args.length < 1 || args.length > 2 || keywords.length) python("TypeError", callable.name + "() requires one or two positional arguments"); if (callable.name === "iter" && args.length === 2) { if (!classes.callable(args[0])) python("TypeError", "iter() first argument must be callable"); diff --git a/runtime/operations.mjs b/runtime/operations.mjs index f5a4ef3..4b72b5e 100644 --- a/runtime/operations.mjs +++ b/runtime/operations.mjs @@ -66,6 +66,7 @@ export function completion(kind, fields = {}) { if (!Number.isSafeInteger(record.version) || record.version < 0 || !Number.isSafeInteger(record.otherVersion) || record.otherVersion < 0) throw new TypeError("Invalid dictionary comparison versions."); break; + case "delegate-iter": case "delegate-next": case "delegate-return": case "delegate-close": case "iter-result": case "indexed-result": case "callable-result": record = {kind, receiver: fields.receiver, after}; break; case "next-default": record = {kind, receiver: fields.receiver, default: fields.default, after}; break; case "binary-result": case "binary-count": case "binary-member": @@ -121,6 +122,7 @@ 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 "delegate-bind": record = {kind, receiver: fields.receiver, args: operands(fields.args), after}; break; case "invoke": record = {kind, args: operands(fields.args), keywords: operands(fields.keywords), keywordValues: operands(fields.keywordValues), depth: fields.depth, fallback: fields.fallback ?? null, after}; break; case "notify": record = {kind, type: fields.type, entries: Object.freeze(fields.entries.map(entry => Object.freeze([...entry]))), index: fields.index}; @@ -148,6 +150,7 @@ function visit(record) { ...(record.operation === "set" ? [record.item] : []), ...completionRoots(record.after)]; case "dict-build": return [record.receiver, ...record.parts, ...completionRoots(record.after)]; case "dict-pair": case "dict-value": case "dict-value-truth": return [record.left, record.right, record.value, ...completionRoots(record.after)]; + case "delegate-iter": case "delegate-next": case "delegate-return": case "delegate-close": case "iter-result": case "indexed-result": case "callable-result": return [record.receiver, ...completionRoots(record.after)]; case "next-default": return [record.receiver, record.default, ...completionRoots(record.after)]; case "binary-result": case "binary-count": case "binary-member": return [record.receiver, ...completionRoots(record.after)]; @@ -167,6 +170,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 "delegate-bind": return [record.receiver, ...record.args, ...completionRoots(record.after)]; case "invoke": return [...record.args, ...record.keywordValues, ...completionRoots(record.after)]; case "notify": return [record.type, ...record.entries.map(entry => entry[1])]; default: throw new TypeError("Unknown operation root owner."); @@ -260,6 +264,7 @@ export function operationRoots(task) { export function controlRoots(task) { switch (task.type) { case "block": return task.stack; + case "delegate": return [task.iterator, task.closing ?? null]; case "while": case "for": case "try": return [task.iterator ?? null, task.pending?.value ?? null, task.caught ?? null, task.previous ?? null]; default: throw new TypeError("Unknown control root owner."); } diff --git a/runtime/source-compiler.mjs b/runtime/source-compiler.mjs index 679565c..95902e8 100644 --- a/runtime/source-compiler.mjs +++ b/runtime/source-compiler.mjs @@ -232,6 +232,7 @@ class Compiler { const expr = value => { this.charge(value); if (value.kind === "Literal") emit(value, "const", this.constant(value.literal, value)); + else if (value.kind === "Yield") { value.value ? expr(value.value) : pushNone(value); emit(value, value.delegate ? "yield-from" : "yield"); } else if (value.kind === "NamedExpression") { expr(value.value); emit(value, "dup"); emit(value, "store", value.name); } else if (value.kind === "Lambda") makeFunction(value); else if (value.kind === "Name") value.name === "__debug__" ? emit(value, "const", this.constant({type: "bool", value: true}, value)) : emit(value, "load", value.name); diff --git a/runtime/source-parser.mjs b/runtime/source-parser.mjs index 32c84c8..3667eca 100644 --- a/runtime/source-parser.mjs +++ b/runtime/source-parser.mjs @@ -139,7 +139,6 @@ class Parser { const test = this.expression(), message = this.eat(",") ? this.expression() : null; return this.node("Assert", start, {test, message}); } - if (this.at("yield")) this.unsupported("Generators are not adopted."); if (this.eat("pass")) return this.node("Pass", start); if (this.at("break") || this.at("continue")) { const op = this.take().value; @@ -404,6 +403,13 @@ class Parser { } expression() { return this.guard(() => { + if (this.at("yield")) { + const start = this.take(); + if (!this.functionDepth) this.fail("Yield outside a function.", start); + const delegate = !!this.eat("from"); + const value = !delegate && (this.ending() || this.at(")")) ? null : this.expressionList(); + return this.node("Yield", start, {value, delegate}); + } if (this.at("lambda")) return this.lambda(); const body = this.disjunction(); if (!this.eat("if")) return body; diff --git a/runtime/values.mjs b/runtime/values.mjs index 67be752..7230957 100644 --- a/runtime/values.mjs +++ b/runtime/values.mjs @@ -1,5 +1,5 @@ // Independent Python values. Native Number is used only inside explicit floats. -import {Cell, Environment, CodePool, ModuleValue, FunctionValue, CallArguments, BuiltinValue, AppendValue, ValueMethod, ExceptionValue, ClassValue, InstanceValue, BoundMethodValue, PropertyValue, MethodWrapperValue, PropertyAccessorValue, ProtocolIteratorValue, NotImplementedValue} from "./function-objects.mjs"; +import {GeneratorValue, generatorRoots, Cell, Environment, CodePool, ModuleValue, FunctionValue, CallArguments, BuiltinValue, AppendValue, ValueMethod, ExceptionValue, ClassValue, InstanceValue, BoundMethodValue, PropertyValue, MethodWrapperValue, PropertyAccessorValue, ProtocolIteratorValue, NotImplementedValue} from "./function-objects.mjs"; import {isPrintable} from "./string-printability.mjs"; import {divideIntegers, powerIntegers, powerReals, modularPower, roundInteger, roundReal, parseInteger, parseReal, parseComplex, complexArithmetic, complexPower} from "./numeric.mjs"; export const VALUE_LIMITS = Object.freeze({digits: 4096, text: 65536, items: 1024, depth: 32}); @@ -529,7 +529,7 @@ export class ValueRuntime { if (sequence(value)) return elements(value).length !== 0; if (value instanceof DictValue) return value.entries.length !== 0; if (value instanceof SetValue) return value.storage.entries.length !== 0; - if (value instanceof IteratorValue || value instanceof ProtocolIteratorValue || value instanceof SliceValue) return true; + if (value instanceof GeneratorValue || value instanceof IteratorValue || value instanceof ProtocolIteratorValue || value instanceof SliceValue) return true; if (value instanceof FunctionValue || value instanceof BuiltinValue || value instanceof ExceptionValue || value instanceof ModuleValue || value instanceof ClassValue || value instanceof InstanceValue || value instanceof BoundMethodValue || value instanceof PropertyValue || value instanceof MethodWrapperValue || value instanceof PropertyAccessorValue) return true; this.fail("TypeError", "Truthiness is outside the adopted values."); @@ -750,7 +750,7 @@ export class ValueRuntime { const pending = [value], seen = new Set(); while (pending.length) { const item = pending.pop(); - if (item instanceof InstanceValue || item instanceof ClassValue || item instanceof FunctionValue || item instanceof BuiltinValue || + if (item instanceof GeneratorValue || item instanceof InstanceValue || item instanceof ClassValue || item instanceof FunctionValue || item instanceof BuiltinValue || item instanceof BoundMethodValue || item instanceof PropertyValue || item instanceof MethodWrapperValue || item instanceof NotImplementedValue || item instanceof ModuleValue || item instanceof ProtocolIteratorValue || item instanceof IteratorValue || item instanceof ExceptionValue || item instanceof FloatValue && Number.isNaN(item.value) || @@ -961,7 +961,7 @@ export class ValueRuntime { if (!sequence(value) && !(value instanceof DictValue || value instanceof SetValue || value instanceof MemoryViewValue)) this.fail("TypeError", "Value is not iterable in this stage."); this.charge(1); return this.make(64, [value], () => new IteratorValue(value)); } - isIterator(value) { this.charge(1); return value instanceof IteratorValue || value instanceof ProtocolIteratorValue; } + isIterator(value) { this.charge(1); return value instanceof GeneratorValue || value instanceof IteratorValue || value instanceof ProtocolIteratorValue; } next(iterator) { if (!(iterator instanceof IteratorValue)) this.fail("TypeError", "next requires an iterator."); const value = iterator.value; this.charge(1); @@ -1245,6 +1245,7 @@ export function valueLayout(value) { children: value.entries.flatMap(entry => [entry.key, entry.value])}; if (value instanceof SetValue) return {bytes: 64, children: [value.storage]}; if (value instanceof IteratorValue) return value.value instanceof RangeValue ? {bytes: 80, children: [value.value, value.index]} : {bytes: 64, children: [value.value]}; + if (value instanceof GeneratorValue) return {bytes: 128 + 48 * (value.frame?.tasks.length ?? 0) + 32 * (value.frame?.locals.size ?? value.bindings.size) + 8 * (value.frame?.tasks.reduce((sum, task) => sum + (task.stack?.length ?? 0), 0) ?? 0), children: generatorRoots(value)}; if (value instanceof ProtocolIteratorValue) return {bytes: 88, children: [value.source, value.sentinel, value.index]}; if (value instanceof NotImplementedValue) return {bytes: 48, children: []}; if (value instanceof SliceValue) return {bytes: 88, children: [value.start, value.stop, value.step]}; diff --git a/src/pocket_python/_interpreter_lock.py b/src/pocket_python/_interpreter_lock.py index d6f56ba..53d4133 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 = '28f03f4ae5b86d4d1747348966dea1649758b2f42a27a4f67d21717e828e2f3f' +ENGINE_FINGERPRINT = '7a56a550bc9da97e4fbc89ae923111e52b76e67fe1895db316b459014c2989e6' ENGINE_FILES = ( ('browser-protocol.mjs', 10589, '920e8d07c7070995636dff40fdf4333afc9bd4143483db5d8dff82ec1f28ff76'), ('bytecode.mjs', 6836, 'e76fabeea879bc1da8016283f7e74df5974479666b100199c2d3c31a53b8ecf4'), ('class-policy.mjs', 1362, '586d8800afd0e5d5ec7d4d3e758dbf56942f7d74207c111c0b32fb4483b0d1f6'), ('class-values.mjs', 15322, 'f4f682f07b74c4f8d36d6e30ba7cfbacfde1e81ba4b5b1ebec0a36c378901b43'), - ('function-bytecode.mjs', 20064, 'd5b06d94e1de04c00a05c90918090a669a476b7b57268c41f0f37b99e69dd4d7'), - ('function-objects.mjs', 3785, '70be5471355a99e067588fd3f43511b56a11ada9b2b9f158dd3ce0574d814cf5'), - ('functions.mjs', 48091, '9a804521dbbccec4565981115db7da5c4197f5a750942b6c23ee5424421de184'), + ('function-bytecode.mjs', 20257, 'dd2fda9d38ec8d2ebd8a977e9162cdb9b2eebca80e78cf47298e6e87f0f5b1b4'), + ('function-objects.mjs', 4277, '06af0ca936de1dd336cb7a2ac13f24efae960ea3fe448c93fb95a2e92213c4e2'), + ('functions.mjs', 56096, 'ccf21800f5a5f2983222b272a559bb37eb32fa497e49dab95476bb944f519f82'), ('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', 82724, '0968b48bd900b06690922ea3d04a9902c88678d71ab68e8564ffe75340160b05'), - ('operations.mjs', 21094, '2809708b38b6bdbf15c0456ad92a1df34e895260bab6e4125db668e530cbe1ad'), - ('source-compiler.mjs', 32056, '8a0a1e790cb25c602b54da2d081d0b731dc864c85c050ef3a6a890eea2f284d9'), + ('operation-runtime.mjs', 85085, '7767bd13309e4a326cf807c387dffff8ddd31b52edd7cf591e762162fe848c8c'), + ('operations.mjs', 21566, 'a05c7535ed1312c42d2961d2cae3ac2d60c7f65133f86b90f8fb215fa066433b'), + ('source-compiler.mjs', 32206, 'b473356305c41f215f9764f6936f848e0d56623841225a72a948d15111b008a4'), ('source-identifiers.mjs', 25949, '1e4e6a90728a2d9393eb4b2055d4bd759542f4768d1870b508d629c6888e8914'), ('source-modules.mjs', 5115, '3f824a8cf79a9ab78c1b817ed1597d114d851748a6b0894a4ac6b4bdbe01cfef'), - ('source-parser.mjs', 31585, 'c44ac7385ce24fb9a5308c9a3d14c538c5b3cb7b6711d2135fba3fb2ecfb8c38'), + ('source-parser.mjs', 31868, 'cd0407b57ad8c0237c96f07d7b39ebb04aa37069d9de2c6be99120fa3bc9d30a'), ('source-tokens.mjs', 15061, 'e044276920224f8ed788f4e3445bee926698444e6cf0b9e8573bd15fec94ad54'), ('string-printability.mjs', 13287, '355a086a142e06822eff821dd0ac6a2ca70c4e06f4ec659236dc3cea1ddd639f'), - ('values.mjs', 84712, '1155dc6bdbccb67a26e1241a5cf5665d90abd0900a75362b667339595b9aea32'), + ('values.mjs', 85128, '07d52caaef9774c1132a02f17420674eb27961676000990d6f60271fd3310d6b'), ('vm.mjs', 6080, '796eaac2848ee7aa2d740797eb2afbdb3e577918866a88a9f25e5adbc55e985d'), ) ENGINE_IMPORTS = ( @@ -42,6 +42,7 @@ ('function-bytecode.mjs', 'function-objects.mjs'), ('function-bytecode.mjs', 'source-identifiers.mjs'), ('function-objects.mjs', 'class-policy.mjs'), + ('function-objects.mjs', 'operations.mjs'), ('functions.mjs', 'class-values.mjs'), ('functions.mjs', 'function-bytecode.mjs'), ('functions.mjs', 'function-objects.mjs'), diff --git a/tests/fixtures/interpreter/compatibility.json b/tests/fixtures/interpreter/compatibility.json index d8613b8..15e9d6d 100644 --- a/tests/fixtures/interpreter/compatibility.json +++ b/tests/fixtures/interpreter/compatibility.json @@ -453,16 +453,14 @@ { "id": "generator", "source": "def f():\n yield 7\nprint(next(f()))\n", - "classification": "deferred", + "classification": "supported", "reference": { "stdout": "7\n", "error_type": null }, "engine": { - "stdout": "", - "error_type": null, - "code": "UNSUPPORTED", - "phase": "parse" + "stdout": "7\n", + "error_type": null } }, { diff --git a/tests/fixtures/interpreter/generators.json b/tests/fixtures/interpreter/generators.json index d5f1169..6deeaca 100644 --- a/tests/fixtures/interpreter/generators.json +++ b/tests/fixtures/interpreter/generators.json @@ -3,7 +3,9 @@ "suite": "interpreter-generators/1", "profile": "classes-source/1", "reference_version": "3.14.7", - "implemented_stages": [], + "implemented_stages": [ + "G1" + ], "cases": [ { "id": "lazy-binding", @@ -278,6 +280,32 @@ "classes-source/1", "core-source/1" ] + }, + { + "id": "caller-exception-isolation", + "source": "def g():\n yield 1\n raise\na=g()\ntry: raise ValueError(\"caller\")\nexcept ValueError: print(next(a))\ntry: next(a)\nexcept RuntimeError: print(\"isolated\")\n", + "stdout": "1\nisolated\n", + "stage": "G1", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "delegation-generatorexit", + "source": "def child():\n try: yield 1\n except GeneratorExit: yield 2\ndef parent(): yield from child()\na=parent()\nprint(next(a))\ntry: a.throw(GeneratorExit(\"exit\"))\nexcept RuntimeError: print(\"ignored\")\n", + "stdout": "1\nignored\n", + "stage": "G1", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "delegate-missing-binding", + "source": "class D:\n def __iter__(self): return self\n def __next__(self): return 1\n @property\n def throw(self): raise AttributeError(\"missing\")\ndef g(): yield from D()\na=g()\nprint(next(a))\ntry: a.throw(ValueError(\"original\"))\nexcept ValueError as e: print(str(e))\n", + "stdout": "1\noriginal\n", + "stage": "G1" } ] } From 551705eed231ff1018e3091af8f4a540ffb8c9ec Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:45:48 -0700 Subject: [PATCH 3/6] feat(INT-GEN-01C): compile scoped comprehensions --- docs/interpreter-compatibility.json | 7 +-- docs/interpreter-compatibility.md | 2 +- docs/interpreter-generators.md | 2 +- runtime/function-bytecode.mjs | 10 ++-- runtime/functions.mjs | 6 ++- runtime/generators.test.mjs | 35 +++++++++++++ runtime/source-compiler.mjs | 49 +++++++++++++++++-- runtime/source-parser.mjs | 36 ++++++++++++-- runtime/source.test.mjs | 2 +- src/pocket_python/_interpreter_lock.py | 10 ++-- tests/fixtures/interpreter/compatibility.json | 8 ++- tests/fixtures/interpreter/generators.json | 3 +- .../fixtures/interpreter/ordinary-syntax.json | 2 +- tests/fixtures/interpreter/source.json | 10 ++-- .../fixtures/interpreter/value-families.json | 2 +- 15 files changed, 147 insertions(+), 37 deletions(-) diff --git a/docs/interpreter-compatibility.json b/docs/interpreter-compatibility.json index d98004c..0e72196 100644 --- a/docs/interpreter-compatibility.json +++ b/docs/interpreter-compatibility.json @@ -486,8 +486,8 @@ { "id": "generators-comprehensions", "status": "partial", - "scope": "Generator functions, yield/yield from, next/send/one-argument throw/close with owned suspended frames and shared budgets.", - "gaps": "Comprehensions, async generators, reflective frames and multi-argument throw remain separate stages.", + "scope": "Generator functions, yield/yield from, next/send/one-argument throw/close with owned suspended frames and shared budgets. Scoped list/dict/set/generator comprehensions with eager outer iterator acquisition.", + "gaps": "Async generators, reflective frames, multi-argument throw and comprehension assignment expressions remain separate stages.", "reference_version": "3.14.7", "targets": [ "node", @@ -502,7 +502,8 @@ ], "evidence": [ "tests/fixtures/interpreter/generators.json:send-return", - "tests/fixtures/interpreter/generators.json:delegation-throw-close" + "tests/fixtures/interpreter/generators.json:delegation-throw-close", + "tests/fixtures/interpreter/generators.json:comprehension-class-scope" ] }, { diff --git a/docs/interpreter-compatibility.md b/docs/interpreter-compatibility.md index cfba5b8..b2c3e7c 100644 --- a/docs/interpreter-compatibility.md +++ b/docs/interpreter-compatibility.md @@ -70,7 +70,7 @@ issue families; availability alone never certifies an entire type or module. | 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. | | 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. Comprehensions and async/reflection remain separate stages. | +| 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. | | context-patterns | deferred | with and synchronous context manager protocols; match/case patterns and guards. No admitted source execution. Async context managers are a separate async gate. | | 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. | diff --git a/docs/interpreter-generators.md b/docs/interpreter-generators.md index 4db50db..ad0f2b8 100644 --- a/docs/interpreter-generators.md +++ b/docs/interpreter-generators.md @@ -2,7 +2,7 @@ INT-GEN-01 uses the pinned CPython 3.14.7 executable reference. The authored `generators.json` corpus separates reference-only evidence from independently -compiled source. G1 is implemented; G2 remains the next stage. Both require Node and Chromium, both profiles and both +compiled source. G1 and G2 are implemented. Both require Node and Chromium, both profiles and both draining and instruction-boundary execution before acceptance. | Stage | Finite contract | diff --git a/runtime/function-bytecode.mjs b/runtime/function-bytecode.mjs index 95149a2..30d2f9c 100644 --- a/runtime/function-bytecode.mjs +++ b/runtime/function-bytecode.mjs @@ -47,11 +47,11 @@ export function verifyFunctions(program) { fields(fn, ["name", "qualname", "params", "locals", "free", "body", "blocks", ...(classes ? ["kind"] : []), ...scopeFields]); const kind = classes ? fn.kind : functionIndex === 0 ? "module" : "function"; if (!["module", "function", "class"].includes(kind) || (functionIndex === 0) !== (kind === "module")) fail("Invalid function/class body kind."); - if (!(fn.name === "" && functionIndex === 0) && !(modern && fn.name === "" && kind === "function") && !validName(fn.name)) fail("Invalid function name."); + if (!(fn.name === "" && functionIndex === 0) && !(modern && ["", "", "", "", ""].includes(fn.name) && kind === "function") && !validName(fn.name)) fail("Invalid function name."); if (typeof fn.qualname !== "string" || !fn.qualname || fn.qualname.length > 256) fail("Invalid qualified function name."); const variadic = modern ? ["vararg", "kwarg"].filter(key => Object.hasOwn(fn.params, key)) : []; fields(fn.params, ["posonly", "positional", "kwonly", "defaults", "kwdefaults", ...variadic]); - const params = Object.fromEntries(["posonly", "positional", "kwonly"].map(key => [key, names(fn.params[key], validName)])); + const params = Object.fromEntries(["posonly", "positional", "kwonly"].map(key => [key, names(fn.params[key], key === "posonly" && modern ? storageName : validName)])); for (const key of variadic) { if (!validName(fn.params[key])) fail("Invalid variadic parameter name."); params[key] = fn.params[key]; @@ -142,7 +142,7 @@ export function verifyFunctions(program) { else if (["jump", "jump-if-false"].includes(op)) { length = 2; if (!integer(arg, block.instructions.length - 1)) fail("Jump outside current block."); } else if (op === "if") { length = 3; blockRef(arg, "statement"); blockRef(extra, "statement"); } else if (op === "while") { length = 4; blockRef(arg, "expression"); blockRef(extra, "statement"); blockRef(instruction[3], "statement"); } - else if (op === "for") { + else if (op === "for" || modern && op === "for-iterator") { length = 5; if (!writable(arg)) fail("For target requires a writable binding."); blockRef(extra, "expression"); blockRef(instruction[3], "statement"); blockRef(instruction[4], "statement"); } else if (op === "try") { @@ -159,7 +159,7 @@ export function verifyFunctions(program) { if (instruction[4] !== null) blockRef(instruction[4], "statement"); } else if (!(BINARY.has(op) || UNARY.has(op) || (classes && op === "build-slice") || ["pop", "dup", "swap", "setitem", "slice", "end", "return", "raise", "reraise", "break", "continue"].includes(op))) fail("Unknown function opcode."); if (instruction.length !== length || (kind === "class" && op === "return") || - (block.kind === "expression" && ["return", "break", "continue", "if", "while", "for", "try"].includes(op))) fail("Invalid operands or expression control flow."); + (block.kind === "expression" && ["return", "break", "continue", "if", "while", "for", "for-iterator", "try"].includes(op))) fail("Invalid operands or expression control flow."); return Object.freeze(instruction.map((value, index) => op === "try" && index === 2 ? Object.freeze(value.map(handler => Object.freeze([Object.freeze([...handler[0]]), handler[1], handler[2], sourceSpan(handler[3])]))) : Array.isArray(value) ? Object.freeze([...value]) : value)); @@ -213,7 +213,7 @@ export function verifyFunctions(program) { if (!loop && ["break", "continue"].includes(op)) fail("Loop control requires a containing loop body."); if (op === "if") { control(a, loop); control(b, loop); } if (op === "while") { control(a, loop); control(b, true); control(c, loop); } - if (op === "for") { control(b, loop); control(c, true); control(d, loop); } + if (["for", "for-iterator"].includes(op)) { control(b, loop); control(c, true); control(d, loop); } if (op === "try") { control(a, loop); for (const handler of b) control(handler[2], loop); if (c !== null) control(c, loop); if (d !== null) control(d, loop); diff --git a/runtime/functions.mjs b/runtime/functions.mjs index ae3a62c..77548e6 100644 --- a/runtime/functions.mjs +++ b/runtime/functions.mjs @@ -307,6 +307,10 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = } task.phase = "test"; block(frame, task.test); return; } + if (task.phase === "iterable" && task.acquired) { + task.owned = true; task.phase = "body"; task.iterator = signal.value; + dispatch(unaryTask("next", task.iterator, task.site, continuation("for-next"))); return; + } if (task.phase === "iterable" && (signal.value instanceof GeneratorValue || signal.value instanceof InstanceValue || signal.value instanceof ProtocolIteratorValue)) { task.owned = true; task.phase = "acquire"; dispatch(unaryTask("iter", signal.value, task.site, continuation("for-iter"))); return; @@ -521,7 +525,7 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = } else if (op === "if") dispatch(unaryTask("truth", stack.pop(), summary(frame, cursor), continuation("condition", {control: "if", body: arg, otherwise: extra}))); else if (op === "while") { frame.tasks.push({type: "while", phase: "test", test: arg, body: extra, otherwise: fourth, site: summary(frame, cursor)}); block(frame, arg); } - else if (op === "for") { frame.tasks.push({type: "for", phase: "iterable", target: arg, body: fourth, otherwise: fifth, iterator: null, site: summary(frame, cursor)}); block(frame, extra); } + else if (["for", "for-iterator"].includes(op)) { frame.tasks.push({type: "for", acquired: op === "for-iterator", phase: "iterable", target: arg, body: fourth, otherwise: fifth, iterator: null, site: summary(frame, cursor)}); block(frame, extra); } else if (op === "try") { frame.tasks.push({type: "try", phase: "body", instruction: cursor.block.instructions[cursor.pc - 1], previous: frame.activeException, previousOwned: frame.ownsException === true, caught: null, pending: null, alias: null, site: state.current.location}); block(frame, arg); diff --git a/runtime/generators.test.mjs b/runtime/generators.test.mjs index f115a53..6c5c482 100644 --- a/runtime/generators.test.mjs +++ b/runtime/generators.test.mjs @@ -65,3 +65,38 @@ test('delegation to user iterators forwards send, throw, close and return values assert.equal(result.error, undefined, JSON.stringify(result.error)); assert.equal(result.stdout, '1 5 8\nfinal\nNone\nclose\nfinal\n1 None\n'); closed(result.closed); }); + +test('comprehensions acquire the outer iterator once, eagerly, without resolving a shadowed iter', async () => { + const source = 'class I:\n def __iter__(self):\n print("acquire")\n return self\n def __next__(self):\n self.n+=1\n if self.n>2: raise StopIteration\n return self.n\ndef iter(x): raise ValueError("shadow")\na=I()\na.n=0\ng=(x+3 for x in a)\nprint("made")\nb=list(g)\nprint(b[0],b[1],a.n)\n'; + const result = await run(source); + assert.equal(result.error, undefined, JSON.stringify(result.error)); + assert.equal(result.stdout, 'acquire\nmade\n4 5 3\n'); closed(result.closed); +}); + +test('nested comprehensions preserve lexical cells, private names and isolated targets', async () => { + const result = await run('class A:\n def __init__(self): self.__values=[2,3]\n def rows(self): return [[self.__values[x]+y for y in range(2)] for x in range(2)]\na=A().rows()\nb=[list(x for x in range(i)) for i in range(3)]\nprint(a[1][1],len(b[2]))\ntry: print(x)\nexcept NameError: print("isolated")\n'); + assert.equal(result.error, undefined, JSON.stringify(result.error)); + assert.equal(result.stdout, '4 2\nisolated\n'); closed(result.closed); +}); + +test('comprehension partial results survive collection, cancellation and repeated close', async () => { + const source = 'class V:\n def __hash__(self):\n print("hash")\n return 7\n def __eq__(self,other): return True\na={V() for i in range(2)}\nprint(len(a))\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, entered = 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 === 'hash\n') { + entered = true; + if (mode === 'cancel') { cancel = true; assert.throws(() => execution.advance(), {code: 'CANCELLED'}); break; } + if (mode === 'close') break; + } + } + assert.ok(entered); if (mode === 'finish') assert.equal(output, 'hash\nhash\n1\n'); + } finally { execution.close(); execution.close(); session.context.finish(); session.close(); } + closed(session.stats()); + } } finally { registry.close(); } +}); diff --git a/runtime/source-compiler.mjs b/runtime/source-compiler.mjs index 95902e8..1a42a07 100644 --- a/runtime/source-compiler.mjs +++ b/runtime/source-compiler.mjs @@ -124,8 +124,44 @@ class Compiler { }; node.body.forEach(visit); return scope; } + comprehensions(root) { + const lower = value => { + if (!value || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(lower); + if (value.kind) this.charge(value); + const node = Object.fromEntries(Object.entries(value).map(([key, child]) => [key, key === "span" ? child : lower(child)])); + if (node.kind !== "Comprehension") return node; + const forbidden = child => { + if (!child || typeof child !== "object") return; + if (child.kind === "Lambda") return; + if (["NamedExpression", "Yield"].includes(child.kind)) this.fail("Yield and assignment expressions in comprehensions require a separate scope stage.", child); + Object.values(child).forEach(forbidden); + }; + forbidden(node); + const make = (kind, fields = {}) => ({kind, span: node.span, ...fields}); + const name = key => make("Name", {name: key}); + const parameter = "@source:" + this.tempSerial++, result = "@source:" + this.tempSerial++; + let body; + if (node.form === "generator") body = [make("Expr", {value: make("Yield", {value: node.element, delegate: false})})]; + else if (node.form === "dict") body = [make("ComprehensionInsert", {receiver: name(result), key: node.key, value: node.element})]; + else body = [make("ComprehensionInsert", {receiver: name(result), key: null, value: node.element, form: node.form})]; + for (let index = node.clauses.length - 1; index >= 0; index--) { + const clause = node.clauses[index]; + for (const test of clause.filters.toReversed()) body = [make("If", {test, body, otherwise: []})]; + body = [make("For", {target: clause.target, iterable: index ? clause.iterable : name(parameter), + acquired: index === 0, body, otherwise: []})]; + } + if (node.form !== "generator") body = [make("Assign", {targets: [name(result)], value: make(node.form === "dict" ? "Dict" : node.form === "set" ? "Set" : "List", node.form === "dict" ? {entries: []} : {items: []})}), + ...body, make("Return", {value: name(result)})]; + const callable = make("Lambda", {name: {list: "", dict: "", set: "", generator: ""}[node.form], + params: {posonly: [{name: parameter, value: null, span: node.span}], positional: [], kwonly: [], vararg: [], kwarg: []}, body}); + return make("ComprehensionCall", {callable, iterable: node.clauses[0].iterable}); + }; + return lower(root); + } resolve() { - this.bind(this.classes ? this.privateNames(this.parsed.module) : this.parsed.module); + const root = this.comprehensions(this.parsed.module); + this.bind(this.classes ? this.privateNames(root) : root); for (const scope of this.scopes) { if (scope.locals.has("__debug__") || scope.assigned.has("__debug__")) this.fail("Cannot assign to or delete __debug__.", scope.node, "SYNTAX", "bind"); if ([...scope.locals, ...scope.uses].some(name => ["None", "True", "False"].includes(name))) @@ -235,6 +271,7 @@ class Compiler { else if (value.kind === "Yield") { value.value ? expr(value.value) : pushNone(value); emit(value, value.delegate ? "yield-from" : "yield"); } else if (value.kind === "NamedExpression") { expr(value.value); emit(value, "dup"); emit(value, "store", value.name); } else if (value.kind === "Lambda") makeFunction(value); + else if (value.kind === "ComprehensionCall") { makeFunction(value.callable); expr(value.iterable); emit(value, "iter"); emit(value, "call", 1, []); } else if (value.kind === "Name") value.name === "__debug__" ? emit(value, "const", this.constant({type: "bool", value: true}, value)) : emit(value, "load", value.name); else if (["List", "Tuple"].includes(value.kind)) { if (value.items.length > 1024) this.fail("Container item budget exceeded.", value, "COMPILE_BUDGET"); @@ -407,18 +444,24 @@ class Compiler { } emit(value, "store", value.bindingName ?? value.name); } + else if (value.kind === "ComprehensionInsert") { + expr(value.receiver); + if (value.key) expr(value.key); + expr(value.value); + emit(value, value.key ? "dict-insert" : value.form === "set" ? "set-insert" : "list-push"); emit(value, "pop"); + } else if (value.kind === "Return") { value.value ? expr(value.value) : pushNone(value); emit(value, "return"); } else if (value.kind === "Raise") { if (value.value) { expr(value.value); emit(value, "raise"); } else emit(value, "reraise"); } else if (value.kind === "Break" || value.kind === "Continue") emit(value, value.kind.toLowerCase()); else if (value.kind === "If") { expr(value.test); emit(value, "if", makeBlock(value.body), makeBlock(value.otherwise)); } else if (value.kind === "While") emit(value, "while", makeBlock([value.test], true), makeBlock(value.body), makeBlock(value.otherwise)); else if (value.kind === "For") { - if (value.target.kind === "Name") emit(value, "for", value.target.name, makeBlock([value.iterable], true), makeBlock(value.body), makeBlock(value.otherwise)); + if (value.target.kind === "Name") emit(value, value.acquired ? "for-iterator" : "for", value.target.name, makeBlock([value.iterable], true), makeBlock(value.body), makeBlock(value.otherwise)); else { const item = this.temporary(scope, value.target), span = value.target.span; const assignItem = {kind: "Assign", span, targets: [value.target], value: {kind: "Name", span, name: item}}; const clearItem = {kind: "Delete", span, targets: [{kind: "Name", span, name: item}]}; - emit(value, "for", item, makeBlock([value.iterable], true), makeBlock([assignItem, clearItem, ...value.body]), makeBlock(value.otherwise)); + emit(value, value.acquired ? "for-iterator" : "for", item, makeBlock([value.iterable], true), makeBlock([assignItem, clearItem, ...value.body]), makeBlock(value.otherwise)); } } else if (value.kind === "Try") { diff --git a/runtime/source-parser.mjs b/runtime/source-parser.mjs index 3667eca..b3b6cd1 100644 --- a/runtime/source-parser.mjs +++ b/runtime/source-parser.mjs @@ -501,7 +501,11 @@ class Parser { if (keywords.length) this.fail("A positional argument follows a keyword argument."); args.push(this.namedExpression()); } - if (this.at("for")) this.unsupported("Generator/comprehension expressions are not adopted."); + if (this.at("for")) { + if (args.length !== 1 || keywords.length || args[0].kind === "StarredArgument") this.fail("A generator expression must be the sole unparenthesized argument."); + args[0] = this.comprehension(args[0], "generator", args[0]); + if (!this.at(")")) this.fail("A generator expression requires parentheses in this call."); + } if (!this.eat(",")) break; } this.expect(")"); value = this.node("Call", value, {callable: value, args, keywords}); @@ -522,6 +526,18 @@ class Parser { const step = this.eat(":") ? [",", "]"].some(value => this.at(value)) ? null : this.expression() : null; return this.node("Slice", start, {start: index, stop, step}); } + comprehension(start, form, element, key = null) { + if (element.kind === "Starred") this.fail("Comprehensions cannot unpack their element.", element); + const clauses = []; + while (this.eat("for")) { + const target = this.expressionList(false, true); this.target(target); this.expect("in"); + const iterable = this.disjunction(), filters = []; + while (this.eat("if")) filters.push(this.disjunction()); + clauses.push({target, iterable, filters}); + } + if (this.at("async")) this.unsupported("Async comprehensions require a separate stage."); + return this.node("Comprehension", start, {form, element, key, clauses}); + } atom() { const start = this.peek(); if (this.kind("name")) { this.take(); return this.node("Name", start, {name: start.value}); } @@ -541,15 +557,21 @@ class Parser { } if (this.eat("(")) { if (this.eat(")")) return this.node("Tuple", start, {items: []}); - const value = this.expressionList(true); - if (this.at("for")) this.unsupported("Generator expressions are not adopted."); + let value = this.expressionList(true); + if (this.at("for")) { + if (value.kind === "Tuple" && !this.grouped.has(value)) this.fail("A comprehension element cannot be an unparenthesized tuple.", value); + value = this.comprehension(start, "generator", value); + } this.expect(")"); this.grouped.add(value); return value.kind === "Tuple" ? this.node("Tuple", start, {items: value.items}) : value; } if (this.eat("[")) { const items = []; while (!this.at("]")) { items.push(this.starredExpression(true)); - if (this.at("for")) this.unsupported("Comprehensions are not adopted."); + if (this.at("for")) { + if (items.length !== 1) this.fail("A comprehension requires one element."); + const result = this.comprehension(start, "list", items[0]); this.expect("]"); return result; + } if (!this.eat(",")) break; } this.expect("]"); return this.node("List", start, {items}); @@ -575,7 +597,11 @@ class Parser { form = "set"; items.push(key); } } - if (this.at("for")) this.unsupported("Comprehensions are not adopted."); + if (this.at("for")) { + if (items.length + entries.length !== 1 || entries[0]?.[0] === null) this.fail("A comprehension requires one element or pair."); + const result = this.comprehension(start, form, form === "set" ? items[0] : entries[0][1], form === "dict" ? entries[0][0] : null); + this.expect("}"); return result; + } if (!this.eat(",")) break; } this.expect("}"); return this.node(form === "set" ? "Set" : "Dict", start, form === "set" ? {items} : {entries}); diff --git a/runtime/source.test.mjs b/runtime/source.test.mjs index 1b00a29..b275831 100644 --- a/runtime/source.test.mjs +++ b/runtime/source.test.mjs @@ -57,7 +57,7 @@ test("augmented lists retain aliases and self-extension/multiplication semantics test("syntax and feature diagnostics reject complete source before application effects", async () => { for (const source of ["return 1", "break", "continue", "if True print(1)", "def f(:\n pass", "x=(1+)", "f(a=1,a=2)", "f(a=1,2)", "try:\n pass\n", "def f(a=1,b):\n pass", "def f(*):\n pass", "def f(a,a):\n pass", "None=1", "import *"]) error(source, "SYNTAX"); - for (const source of ["class C: pass", "async def f(): pass", "x=[n for n in []]", "x={n for n in []}", "x=2@3", "from x import *", "x:int=1", "def f(a:int): pass", "raise ValueError() from None", "'\\N{SNOWMAN}'"]) { + for (const source of ["class C: pass", "async def f(): pass", "x=[(n:=1) for n in []]", "x=2@3", "from x import *", "x:int=1", "def f(a:int): pass", "raise ValueError() from None", "'\\N{SNOWMAN}'"]) { error(source, "UNSUPPORTED"); } const r = await run("print('must not execute')\nclass Deferred: pass\n"); diff --git a/src/pocket_python/_interpreter_lock.py b/src/pocket_python/_interpreter_lock.py index 53d4133..ca8f5f1 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 = '7a56a550bc9da97e4fbc89ae923111e52b76e67fe1895db316b459014c2989e6' +ENGINE_FINGERPRINT = 'c7abf587469c1597dd8b941e8eae46ac8ac034a11704da24dca12d09cdd01108' ENGINE_FILES = ( ('browser-protocol.mjs', 10589, '920e8d07c7070995636dff40fdf4333afc9bd4143483db5d8dff82ec1f28ff76'), ('bytecode.mjs', 6836, 'e76fabeea879bc1da8016283f7e74df5974479666b100199c2d3c31a53b8ecf4'), ('class-policy.mjs', 1362, '586d8800afd0e5d5ec7d4d3e758dbf56942f7d74207c111c0b32fb4483b0d1f6'), ('class-values.mjs', 15322, 'f4f682f07b74c4f8d36d6e30ba7cfbacfde1e81ba4b5b1ebec0a36c378901b43'), - ('function-bytecode.mjs', 20257, 'dd2fda9d38ec8d2ebd8a977e9162cdb9b2eebca80e78cf47298e6e87f0f5b1b4'), + ('function-bytecode.mjs', 20438, 'ca6b3abd73d2be35422eea244b876a851bf98e38836e31a33fa6d18d4252ae68'), ('function-objects.mjs', 4277, '06af0ca936de1dd336cb7a2ac13f24efae960ea3fe448c93fb95a2e92213c4e2'), - ('functions.mjs', 56096, 'ccf21800f5a5f2983222b272a559bb37eb32fa497e49dab95476bb944f519f82'), + ('functions.mjs', 56408, 'c6e8c6d0bbb719db0376ec93f3417015a4225fff1d6784acc5f5f322b245708e'), ('interpreter-host.mjs', 7019, '0bb0a64faffcf77e0064258e4cfcaa7a937c4372129849f76901bee91b5a5ae2'), ('interpreter-worker.mjs', 8282, '937a97cc49d987492de0bc16021a4a804ef51e4ea5b398f79c92e5d30ac82558'), ('library-catalog.mjs', 907, '0adcaae0f6f07b4e1acb22e46493ec99f7247a5828b426c703530d8b2736032c'), @@ -22,10 +22,10 @@ ('numeric.mjs', 13954, '2431f099a7d40aa38d6b3c68b556207752e4191211e01005acde94325bd91bad'), ('operation-runtime.mjs', 85085, '7767bd13309e4a326cf807c387dffff8ddd31b52edd7cf591e762162fe848c8c'), ('operations.mjs', 21566, 'a05c7535ed1312c42d2961d2cae3ac2d60c7f65133f86b90f8fb215fa066433b'), - ('source-compiler.mjs', 32206, 'b473356305c41f215f9764f6936f848e0d56623841225a72a948d15111b008a4'), + ('source-compiler.mjs', 35239, '4201a25a0c45b8fba4777c8e98b75e37f3277888708093a77e2827dec5ff7063'), ('source-identifiers.mjs', 25949, '1e4e6a90728a2d9393eb4b2055d4bd759542f4768d1870b508d629c6888e8914'), ('source-modules.mjs', 5115, '3f824a8cf79a9ab78c1b817ed1597d114d851748a6b0894a4ac6b4bdbe01cfef'), - ('source-parser.mjs', 31868, 'cd0407b57ad8c0237c96f07d7b39ebb04aa37069d9de2c6be99120fa3bc9d30a'), + ('source-parser.mjs', 33396, '578c6663213e46049c51ba23a52427af8c301943ef67433054a6bf3fb2218ed2'), ('source-tokens.mjs', 15061, 'e044276920224f8ed788f4e3445bee926698444e6cf0b9e8573bd15fec94ad54'), ('string-printability.mjs', 13287, '355a086a142e06822eff821dd0ac6a2ca70c4e06f4ec659236dc3cea1ddd639f'), ('values.mjs', 85128, '07d52caaef9774c1132a02f17420674eb27961676000990d6f60271fd3310d6b'), diff --git a/tests/fixtures/interpreter/compatibility.json b/tests/fixtures/interpreter/compatibility.json index 15e9d6d..d6ecb1d 100644 --- a/tests/fixtures/interpreter/compatibility.json +++ b/tests/fixtures/interpreter/compatibility.json @@ -438,16 +438,14 @@ { "id": "comprehension", "source": "print(len([x+1 for x in [1,2]]))\n", - "classification": "deferred", + "classification": "supported", "reference": { "stdout": "2\n", "error_type": null }, "engine": { - "stdout": "", - "error_type": null, - "code": "UNSUPPORTED", - "phase": "parse" + "stdout": "2\n", + "error_type": null } }, { diff --git a/tests/fixtures/interpreter/generators.json b/tests/fixtures/interpreter/generators.json index 6deeaca..03f17eb 100644 --- a/tests/fixtures/interpreter/generators.json +++ b/tests/fixtures/interpreter/generators.json @@ -4,7 +4,8 @@ "profile": "classes-source/1", "reference_version": "3.14.7", "implemented_stages": [ - "G1" + "G1", + "G2" ], "cases": [ { diff --git a/tests/fixtures/interpreter/ordinary-syntax.json b/tests/fixtures/interpreter/ordinary-syntax.json index 52f450b..057dcbd 100644 --- a/tests/fixtures/interpreter/ordinary-syntax.json +++ b/tests/fixtures/interpreter/ordinary-syntax.json @@ -603,7 +603,7 @@ }, { "id": "comprehension-deferred", - "stage": "Future", + "stage": "Control", "source": "print(len([x for x in range(3)]))\n", "stdout": "3\n", "profiles": [ diff --git a/tests/fixtures/interpreter/source.json b/tests/fixtures/interpreter/source.json index 65fb282..6ae8145 100644 --- a/tests/fixtures/interpreter/source.json +++ b/tests/fixtures/interpreter/source.json @@ -586,19 +586,21 @@ }, { "id": "unsupported-comprehension", - "source": "print('must not run')\nx=[n for n in []]", + "source": "print('must not run')\nx=[(z:=n) for n in []]", "code": "UNSUPPORTED", "features": [ "source.feature-boundary" - ] + ], + "phase": "compile" }, { "id": "unsupported-set-comprehension", - "source": "print('must not run')\nx={n for n in []}", + "source": "print('must not run')\nx={(z:=n) for n in []}", "code": "UNSUPPORTED", "features": [ "source.feature-boundary" - ] + ], + "phase": "compile" }, { "id": "unsupported-import-star", diff --git a/tests/fixtures/interpreter/value-families.json b/tests/fixtures/interpreter/value-families.json index eafcf86..2267722 100644 --- a/tests/fixtures/interpreter/value-families.json +++ b/tests/fixtures/interpreter/value-families.json @@ -855,7 +855,7 @@ }, { "id": "future-set-comprehension", - "stage": "Future", + "stage": "V1", "source": "s={x for x in range(3)}\n", "profiles": [ "core-source/1", From 90d24ee64bc4bf4796b7c5e8971369a3409600f9 Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:46:39 -0700 Subject: [PATCH 4/6] test(INT-GEN-01B): adopt generator binding observation --- .github/workflows/ci.yml | 1 + tests/fixtures/interpreter/function-binding.json | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf11946..963475c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,6 +162,7 @@ jobs: - run: node --test runtime/memory.test.mjs - run: node --test runtime/syntax.test.mjs - run: node --test runtime/function-binding.test.mjs + - run: node --test runtime/generators.test.mjs - run: node --test runtime/functions.test.mjs - run: node --test runtime/execution.test.mjs - run: node --test runtime/execution-stepping.test.mjs diff --git a/tests/fixtures/interpreter/function-binding.json b/tests/fixtures/interpreter/function-binding.json index 6ad84c6..a4a3e8e 100644 --- a/tests/fixtures/interpreter/function-binding.json +++ b/tests/fixtures/interpreter/function-binding.json @@ -666,8 +666,8 @@ { "id": "star-empty-string-dict", "stage": "StarArgs", - "source": "def f(*a):\n for x in a: print(x)\nf(*[],*\"A🚀\",*{\"k\":2})\n", - "stdout": "A\n🚀\nk\n", + "source": "def f(*a):\n for x in a: print(x)\nf(*[],*\"A\ud83d\ude80\",*{\"k\":2})\n", + "stdout": "A\n\ud83d\ude80\nk\n", "profiles": [ "core-source/1", "classes-source/1" @@ -960,7 +960,7 @@ }, { "id": "function-generator-deferred", - "stage": "Future", + "stage": "Defaults", "source": "def f():\n yield 2\nprint(next(f()))\n", "stdout": "2\n", "profiles": [ @@ -1100,7 +1100,7 @@ { "id": "mapping-arbitrary-string-key", "stage": "StarKw", - "source": "def f(**kw): print(kw[\"not-an-identifier\"],kw[\"__proto__\"],kw[\"🚀\"])\nf(**{\"not-an-identifier\":1,\"__proto__\":2,\"🚀\":3})\n", + "source": "def f(**kw): print(kw[\"not-an-identifier\"],kw[\"__proto__\"],kw[\"\ud83d\ude80\"])\nf(**{\"not-an-identifier\":1,\"__proto__\":2,\"\ud83d\ude80\":3})\n", "stdout": "1 2 3\n", "profiles": [ "core-source/1", From 2f4b410f3ac39f7e76e795a100b2cd9664fd9e38 Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:50:43 -0700 Subject: [PATCH 5/6] feat(INT-OBJECT-01A): execute attribute reflection --- .github/workflows/ci.yml | 5 ++ docs/interpreter-compatibility.json | 46 ++++++++----- docs/interpreter-compatibility.md | 8 +-- docs/interpreter-object-protocols.md | 3 +- runtime/class-values.mjs | 3 +- runtime/functions.mjs | 2 +- runtime/object-protocols.test.mjs | 69 +++++++++++++++++++ runtime/operation-runtime.mjs | 24 +++++++ runtime/operations.mjs | 5 ++ src/pocket_python/_interpreter_lock.py | 10 +-- tests/fixtures/interpreter/compatibility.json | 32 ++++----- .../interpreter/object-protocols.json | 4 +- 12 files changed, 159 insertions(+), 52 deletions(-) create mode 100644 runtime/object-protocols.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 963475c..41630f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,8 @@ jobs: - run: uv run --no-sync python runtime/check_classes.py --target browser --corpus tests/fixtures/interpreter/generators.json --stepped --report build/generators-browser-stepped-report.json - run: uv run --no-sync python runtime/check_classes.py --target browser --corpus tests/fixtures/interpreter/generators.json --profile core-source/1 --report build/generators-core-browser-report.json - run: uv run --no-sync python runtime/check_classes.py --target browser --corpus tests/fixtures/interpreter/generators.json --profile core-source/1 --stepped --report build/generators-core-browser-stepped-report.json + - run: uv run --no-sync python runtime/check_classes.py --target browser --corpus tests/fixtures/interpreter/object-protocols.json --report build/object-protocols-browser-report.json + - run: uv run --no-sync python runtime/check_classes.py --target browser --corpus tests/fixtures/interpreter/object-protocols.json --stepped --report build/object-protocols-browser-stepped-report.json - run: uv run --no-sync python runtime/check_classes.py --target browser --report build/classes-browser-report.json - run: uv run --no-sync python runtime/check_classes.py --target browser --corpus tests/fixtures/interpreter/value-families.json --report build/value-families-browser-report.json - run: uv run --no-sync python runtime/check_classes.py --target browser --corpus tests/fixtures/interpreter/value-families.json --stepped --report build/value-families-browser-stepped-report.json @@ -162,6 +164,7 @@ jobs: - run: node --test runtime/memory.test.mjs - run: node --test runtime/syntax.test.mjs - run: node --test runtime/function-binding.test.mjs + - run: node --test runtime/object-protocols.test.mjs - run: node --test runtime/generators.test.mjs - run: node --test runtime/functions.test.mjs - run: node --test runtime/execution.test.mjs @@ -196,6 +199,8 @@ jobs: - run: python runtime/check_classes.py --target node --corpus tests/fixtures/interpreter/generators.json --stepped --report build/generators-node-stepped-report.json - run: python runtime/check_classes.py --target node --corpus tests/fixtures/interpreter/generators.json --profile core-source/1 --report build/generators-core-node-report.json - run: python runtime/check_classes.py --target node --corpus tests/fixtures/interpreter/generators.json --profile core-source/1 --stepped --report build/generators-core-node-stepped-report.json + - run: python runtime/check_classes.py --target node --corpus tests/fixtures/interpreter/object-protocols.json --report build/object-protocols-node-report.json + - run: python runtime/check_classes.py --target node --corpus tests/fixtures/interpreter/object-protocols.json --stepped --report build/object-protocols-node-stepped-report.json - run: python runtime/check_classes.py --target node --report build/classes-node-report.json - run: python runtime/check_classes.py --target node --corpus tests/fixtures/interpreter/value-families.json --report build/value-families-node-report.json - run: python runtime/check_classes.py --target node --corpus tests/fixtures/interpreter/value-families.json --stepped --report build/value-families-node-stepped-report.json diff --git a/docs/interpreter-compatibility.json b/docs/interpreter-compatibility.json index 0e72196..658d879 100644 --- a/docs/interpreter-compatibility.json +++ b/docs/interpreter-compatibility.json @@ -588,9 +588,9 @@ }, { "id": "dynamic-code-reflection", - "status": "deferred", - "scope": "compile/eval/exec, dynamic imports and broader globals/locals/type/attribute reflection.", - "gaps": "Source admission, namespace/identity and target authority need separate explicit contracts.", + "status": "partial", + "scope": "getattr/setattr/delattr/hasattr on adopted class attributes through ordinary owned descriptor callbacks.", + "gaps": "Dynamic source admission, namespaces, richer types and broader reflection remain deferred.", "reference_version": "3.14.7", "targets": [ "node", @@ -1065,9 +1065,9 @@ }, { "id": "delattr", - "status": "deferred", - "scope": "Not installed; resolving this builtin name raises Python NameError.", - "gaps": "No implementation/API support credit from the negative availability probe.", + "status": "partial", + "scope": "Positional attribute reflection on adopted class/instance attributes, preserving descriptor precedence, arbitrary bounded string names and AttributeError-only suppression.", + "gaps": "Custom attribute hooks, reflective namespaces and unadopted receivers/metadata remain separately gated.", "reference_version": "3.14.7", "targets": [ "node", @@ -1079,7 +1079,9 @@ "probes": [ "builtin-delattr" ], - "evidence": [] + "evidence": [ + "tests/fixtures/interpreter/object-protocols.json:attribute-defaults" + ] }, { "id": "dict", @@ -1267,9 +1269,9 @@ }, { "id": "getattr", - "status": "deferred", - "scope": "Not installed; resolving this builtin name raises Python NameError.", - "gaps": "No implementation/API support credit from the negative availability probe.", + "status": "partial", + "scope": "Positional attribute reflection on adopted class/instance attributes, preserving descriptor precedence, arbitrary bounded string names and AttributeError-only suppression.", + "gaps": "Custom attribute hooks, reflective namespaces and unadopted receivers/metadata remain separately gated.", "reference_version": "3.14.7", "targets": [ "node", @@ -1281,7 +1283,9 @@ "probes": [ "builtin-getattr" ], - "evidence": [] + "evidence": [ + "tests/fixtures/interpreter/object-protocols.json:attribute-defaults" + ] }, { "id": "globals", @@ -1303,9 +1307,9 @@ }, { "id": "hasattr", - "status": "deferred", - "scope": "Not installed; resolving this builtin name raises Python NameError.", - "gaps": "No implementation/API support credit from the negative availability probe.", + "status": "partial", + "scope": "Positional attribute reflection on adopted class/instance attributes, preserving descriptor precedence, arbitrary bounded string names and AttributeError-only suppression.", + "gaps": "Custom attribute hooks, reflective namespaces and unadopted receivers/metadata remain separately gated.", "reference_version": "3.14.7", "targets": [ "node", @@ -1317,7 +1321,9 @@ "probes": [ "builtin-hasattr" ], - "evidence": [] + "evidence": [ + "tests/fixtures/interpreter/object-protocols.json:attribute-defaults" + ] }, { "id": "hash", @@ -1862,9 +1868,9 @@ }, { "id": "setattr", - "status": "deferred", - "scope": "Not installed; resolving this builtin name raises Python NameError.", - "gaps": "No implementation/API support credit from the negative availability probe.", + "status": "partial", + "scope": "Positional attribute reflection on adopted class/instance attributes, preserving descriptor precedence, arbitrary bounded string names and AttributeError-only suppression.", + "gaps": "Custom attribute hooks, reflective namespaces and unadopted receivers/metadata remain separately gated.", "reference_version": "3.14.7", "targets": [ "node", @@ -1876,7 +1882,9 @@ "probes": [ "builtin-setattr" ], - "evidence": [] + "evidence": [ + "tests/fixtures/interpreter/object-protocols.json:attribute-defaults" + ] }, { "id": "slice", diff --git a/docs/interpreter-compatibility.md b/docs/interpreter-compatibility.md index b2c3e7c..4b28868 100644 --- a/docs/interpreter-compatibility.md +++ b/docs/interpreter-compatibility.md @@ -102,7 +102,7 @@ issue families; availability alone never certifies an entire type or module. | classmethod | partial | classmethod(callable), classes-source/1 and accessed-owner binding. | Unrestricted wrapper introspection. | | compile | deferred | Not installed; resolving this builtin name raises Python NameError. | No implementation/API support credit from the negative availability probe. | | complex | partial | Owned immutable Cartesian values, literal/text/real/complex construction, real/imag keywords and attributes, finite basic arithmetic, equality/truth/hash and bounded integer powers. Owned conversion and index fallback callbacks run in ordinary VM frames under classes-source/1. | General powers, non-finite arithmetic/recovery families, numeric methods, formatting and builtin subclasses remain later boundaries. | -| delattr | deferred | Not installed; resolving this builtin name raises Python NameError. | No implementation/API support credit from the negative availability probe. | +| delattr | partial | Positional reflection on adopted class attributes and descriptors. | Custom hooks and broader reflection remain separate. | | dict | deferred | Not installed; resolving this builtin name raises Python NameError. | No implementation/API support credit from the negative availability probe. | | dir | deferred | Not installed; resolving this builtin name raises Python NameError. | No implementation/API support credit from the negative availability probe. | | divmod | partial | Positional-only primitive int/float quotient and remainder with owned tuple results and Python sign/zero rules. | General user arithmetic/reflected slots remain INT-OBJECT-01. | @@ -113,9 +113,9 @@ issue families; availability alone never certifies an entire type or module. | float | partial | Real primitive and Unicode text construction with exact signatures, base/underscore rules and bounded input/result work in both source profiles. Owned conversion and index fallback callbacks run in ordinary VM frames under classes-source/1. | Bytes (int/float), builtin subclasses, numeric methods and formatting remain later value/object stages. | | format | deferred | Not installed; resolving this builtin name raises Python NameError. | No implementation/API support credit from the negative availability probe. | | frozenset | partial | Zero/one positional iterable construction, set displays, membership/equality/iteration, finite methods and primitive algebra; owned iterator/hash/equality callbacks in classes-source/1. | Broader methods, set-algebra callbacks, recursive user-key frozen hashing and comprehensions remain deferred. | -| getattr | deferred | Not installed; resolving this builtin name raises Python NameError. | No implementation/API support credit from the negative availability probe. | +| getattr | partial | Positional reflection on adopted class attributes and descriptors. | Custom hooks and broader reflection remain separate. | | globals | deferred | Not installed; resolving this builtin name raises Python NameError. | No implementation/API support credit from the negative availability probe. | -| hasattr | deferred | Not installed; resolving this builtin name raises Python NameError. | No implementation/API support credit from the negative availability probe. | +| hasattr | partial | Positional reflection on adopted class attributes and descriptors. | Custom hooks and broader reflection remain separate. | | hash | partial | hash(value), positional-only in classes-source/1; numeric/string/primitive tuple hashes, user __hash__ and owned default identities. | Tuple-with-instance, bound-method and slice hash families remain deferred; exact string/object hash numbers are not a cross-process guarantee. | | help | host-restricted | Not installed; availability probe only, never invoked. | Interactive/process/filesystem behavior needs an explicit target capability contract. | | hex | supported | Primitive numeric signatures in both source profiles and owned type-level callbacks in classes-source/1; see interpreter-numeric.md for the finite contract. | | @@ -145,7 +145,7 @@ issue families; availability alone never certifies an entire type or module. | reversed | deferred | Not installed; resolving this builtin name raises Python NameError. | No implementation/API support credit from the negative availability probe. | | round | supported | Primitive numeric signatures in both source profiles and owned type-level callbacks in classes-source/1; see interpreter-numeric.md for the finite contract. | | | set | partial | Zero/one positional iterable construction, set displays, membership/equality/iteration, finite methods and primitive algebra; owned iterator/hash/equality callbacks in classes-source/1. | Broader methods, set-algebra callbacks, recursive user-key frozen hashing and comprehensions remain deferred. | -| setattr | deferred | Not installed; resolving this builtin name raises Python NameError. | No implementation/API support credit from the negative availability probe. | +| setattr | partial | Positional reflection on adopted class attributes and descriptors. | Custom hooks and broader reflection remain separate. | | slice | partial | classes-source/1: slice(stop), slice(start,stop), slice(start,stop,step), positional only; immutable raw start/stop/step fields. | Core constructor, slice.indices, repr, equality/hash and other reflection are deferred. | | sorted | deferred | Not installed; resolving this builtin name raises Python NameError. | No implementation/API support credit from the negative availability probe. | | staticmethod | partial | staticmethod(value) and existing wrapper binding/direct-call rules; classes-source/1. | Unrestricted wrapper introspection. | diff --git a/docs/interpreter-object-protocols.md b/docs/interpreter-object-protocols.md index c8909a4..75a4d01 100644 --- a/docs/interpreter-object-protocols.md +++ b/docs/interpreter-object-protocols.md @@ -2,7 +2,8 @@ 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. Classes remain explicit in +Chromium, draining and instruction-boundary drivers. O1 is implemented; O2 is the next stage. 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. | Stage | Finite contract | diff --git a/runtime/class-values.mjs b/runtime/class-values.mjs index b00cc3a..4b73ab7 100644 --- a/runtime/class-values.mjs +++ b/runtime/class-values.mjs @@ -19,11 +19,12 @@ export class ClassRuntime { bounded(condition, message) { if (!condition) this.fail("ExecutionLimitError", message, "BUDGET"); } key(name) { this.charge(typeof name === "string" ? name.length + 1 : 1); - if (typeof name !== "string" || name.length > CLASS_LIMITS.name || !isPythonIdentifier(name)) throw new TypeError("Invalid class attribute name."); + if (typeof name !== "string" || name.length > CLASS_LIMITS.name) throw new TypeError("Invalid class attribute name."); } root() { return this.make(ClassValue, "object", "object", "builtins", [], [], new Map(), true); } create(name, qualname, module, bases, namespace, root) { this.key(name); + if (!isPythonIdentifier(name)) throw new TypeError("Invalid class name."); this.bounded(typeof qualname === "string" && qualname.length <= 256 && typeof module === "string" && module.length <= 192, "Class identity budget exceeded."); if (!(root instanceof ClassValue) || !root.root || !Array.isArray(bases) || !(namespace instanceof Map)) throw new TypeError("Invalid class construction record."); diff --git a/runtime/functions.mjs b/runtime/functions.mjs index 77548e6..9b4b6bd 100644 --- a/runtime/functions.mjs +++ b/runtime/functions.mjs @@ -98,7 +98,7 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = if (frame.callable.pool.code.version === 5 && key === "NotImplemented") return moduleContext.builtin(key, () => allocate(NotImplementedValue)); if (builtinNames.has(key) || (moduleContext && ["isinstance", "bool", "iter", "next"].includes(key)) || (frame.callable.pool.code.version >= 4 && ["memoryview", "bytes", "bytearray", "set", "frozenset", "all", "any", "list", "tuple", "range", "int", "float", "complex", "abs", "divmod", "pow", "round", "bin", "oct", "hex", "chr", "ord"].includes(key)) || - (frame.callable.pool.code.version === 5 && ["type", "issubclass", "property", "staticmethod", "classmethod", "callable", "slice", "hash"].includes(key))) { + (frame.callable.pool.code.version === 5 && ["getattr", "setattr", "delattr", "hasattr", "type", "issubclass", "property", "staticmethod", "classmethod", "callable", "slice", "hash"].includes(key))) { if (moduleContext) return moduleContext.builtin(key, () => allocate(BuiltinValue, key)); if (!state.builtins.has(key)) state.builtins.set(key, allocate(BuiltinValue, key)); return state.builtins.get(key); diff --git a/runtime/object-protocols.test.mjs b/runtime/object-protocols.test.mjs new file mode 100644 index 0000000..28af1d5 --- /dev/null +++ b/runtime/object-protocols.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +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'; + +const profile = {languageProfile: 'classes-source/1'}; +const unit = source => ({name: 'app', package: false, imports: [], source, notice: 'Synthetic object protocol fixture'}); +const run = (source, options = {}, memoryLimits) => runSourceRequest({modules: [unit(source)], compileOptions: profile, options, memoryLimits}); +const closed = stats => { for (const key of ['bytes', 'objects', 'roots', 'frames', 'external']) assert.equal(stats[key], 0, key); }; + +test('attribute reflection retains defaults, receiver and outer destinations', () => { + const receiver = {}, fallback = {}, outer = {}, after = completion('relay', {receiver: outer}); + assert.deepEqual(completionRoots(completion('attribute-result', {receiver, default: fallback, mode: 'getattr', after})), [receiver, fallback, outer]); + assert.deepEqual(completionRoots(completion('attribute-write', {receiver, after})), [receiver, outer]); + assert.throws(() => completion('attribute-result', {receiver, default: null, mode: 'setattr'}), TypeError); +}); + +test('dynamic attribute names preserve arbitrary text without identifier normalization or mangling', async () => { + const result = await run('class A: pass\na=A()\nsetattr(a,"",1)\nsetattr(a,"two words",2)\nsetattr(a,"K",3)\nsetattr(a,"K",4)\nprint(getattr(a,""),getattr(a,"two words"),getattr(a,"K"),getattr(a,"K"))\n'); + assert.equal(result.error, undefined, JSON.stringify(result.error)); assert.equal(result.stdout, '1 2 3 4\n'); closed(result.closed); +}); + +test('reflection suppresses only AttributeError and validates signatures before callbacks', async () => { + for (const [expression, type] of [['getattr(A(),7)', 'TypeError'], ['hasattr(A(),"x")', 'StopIteration'], ['getattr(A(),"x",9)', 'StopIteration'], ['setattr(A(),"x")', 'TypeError']]) { + const result = await run('class A:\n @property\n def x(self): raise StopIteration("body")\n'+expression+'\n'); + assert.equal(result.error.type, type); closed(result.closed); + } + const result = await run('class A:\n @property\n def x(self): raise AttributeError("body")\na=A()\nprint(getattr(a,"x",[4])[0],hasattr(a,"x"))\n'); + assert.equal(result.stdout, '4 False\n'); assert.equal(result.error, undefined); closed(result.closed); +}); + +test('descriptor getters and setters keep temporary operands across collection, cancellation and close', async () => { + const sources = [ + 'class A:\n @property\n def x(self):\n print("entered")\n raise AttributeError("missing")\nprint(getattr(A(),"x",[7])[0])\n', + 'class A:\n @property\n def x(self): return 0\n @x.setter\n def x(self,value):\n print("entered")\n self.saved=value\na=A()\nprint(setattr(a,"x",[7]),a.saved[0])\n', + ]; + for (let index = 0; index < sources.length; index++) { + const {registry} = await compileSourceModules([unit(sources[index])], 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, index ? 'entered\nNone 7\n' : 'entered\n7\n'); + } finally { execution.close(); execution.close(); session.context.finish(); session.close(); } + closed(session.stats()); + } } finally { registry.close(); } + } +}); + +test('reflection callbacks share recursion, work and memory budgets', async () => { + const recursive = await run('class A:\n @property\n def x(self): return getattr(self,"x")\ngetattr(A(),"x")\n'); + assert.equal(recursive.error.type, 'RecursionError'); closed(recursive.closed); + const budget = await run('class A:\n @property\n def x(self):\n while True: pass\ngetattr(A(),"x",9)\n', {maxSteps: 200}); + assert.equal(budget.error.code, 'BUDGET'); closed(budget.closed); + 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); +}); diff --git a/runtime/operation-runtime.mjs b/runtime/operation-runtime.mjs index 94acc90..d1d9d17 100644 --- a/runtime/operation-runtime.mjs +++ b/runtime/operation-runtime.mjs @@ -286,6 +286,7 @@ export function operationRuntime({state, memory, values, classes, moduleContext, const binding = bindingRecovery(value, completion, site); if (binding) return binding; for (let current = completion; current; current = current.after) { + if (current.kind === "attribute-result" && value.name === "AttributeError") return valueTask(current.mode === "hasattr" ? false : current.default, site, current.after); if (current.kind === "delegate-bind" && value.name === "AttributeError") return unaryTask("delegate-raise", current.receiver, site); if (current.kind.startsWith("call-map-") && (value.name === "AttributeError" || value.name === "KeyError" && current.mode !== "display")) return value.name === "KeyError" ? duplicateKeyword(current.receiver, value.args[0] ?? current.key, site, current.after) : @@ -333,6 +334,8 @@ export function operationRuntime({state, memory, values, classes, moduleContext, } completion = 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); if (completion?.kind.startsWith("delegate-")) return delegateResult(completion.kind, value, site); count("deliver:" + (completion?.kind ?? "stack")); @@ -1033,6 +1036,27 @@ export function operationRuntime({state, memory, values, classes, moduleContext, enter(callable, bindings, site, completion); return; } if (callable instanceof BuiltinValue) { + if (["getattr", "setattr", "delattr", "hasattr"].includes(callable.name)) { + const name = callable.name, writing = name === "setattr" || name === "delattr"; + if (keywords.length || args.length < 2 || args.length > (name === "getattr" || name === "setattr" ? 3 : 2) || name === "setattr" && args.length !== 3) + python("TypeError", name + "() received invalid arguments"); + const attribute = values.text(args[1]); + if (attribute === null) python("TypeError", "attribute name must be string"); + if (attribute.length > 256) hard("BUDGET", "ExecutionLimitError", "Attribute name budget exceeded."); + const receiver = args[0], optional = name === "hasattr" || name === "getattr" && args.length === 3; + const target = writing ? continuation("attribute-write", {receiver, after: completion}) : optional ? + continuation("attribute-result", {receiver, default: args[2] ?? null, mode: name, after: completion}) : completion; + let plan; + try { plan = writing ? classes.write(receiver, attribute, args[2] ?? null, name === "delattr") : classes.read(receiver, attribute); } + catch (error) { + const recovery = error instanceof Raised && recover(error.value, target, site); + if (!recovery) throw error; + task = recovery; continue; + } + task = plan.kind === "value" ? valueTask(writing ? null : name === "hasattr" ? true : plan.value, site, completion) : + callTask(plan.callable, plan.args, [], [], site, target, depth); + continue; + } if (callable instanceof ValueMethod && callable.receiver instanceof GeneratorValue) { if (keywords.length || args.length !== (callable.name === "close" || callable.name === "__iter__" || callable.name === "__next__" ? 0 : 1)) { if (callable.name === "throw" && args.length > 1) hard("UNSUPPORTED", "UnsupportedOperationError", "Multi-argument generator.throw requires a separate stage."); diff --git a/runtime/operations.mjs b/runtime/operations.mjs index 4b72b5e..107f24d 100644 --- a/runtime/operations.mjs +++ b/runtime/operations.mjs @@ -122,6 +122,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 "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; case "delegate-bind": record = {kind, receiver: fields.receiver, args: operands(fields.args), after}; break; case "invoke": record = {kind, args: operands(fields.args), keywords: operands(fields.keywords), keywordValues: operands(fields.keywordValues), depth: fields.depth, fallback: fields.fallback ?? null, after}; break; @@ -170,6 +173,8 @@ 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 "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)]; case "invoke": return [...record.args, ...record.keywordValues, ...completionRoots(record.after)]; case "notify": return [record.type, ...record.entries.map(entry => entry[1])]; diff --git a/src/pocket_python/_interpreter_lock.py b/src/pocket_python/_interpreter_lock.py index ca8f5f1..dfa6532 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 = 'c7abf587469c1597dd8b941e8eae46ac8ac034a11704da24dca12d09cdd01108' +ENGINE_FINGERPRINT = '03d4255b4396e0016f2b34d86c3968d12f5f9573781a72d37ccd95f244a36d9f' ENGINE_FILES = ( ('browser-protocol.mjs', 10589, '920e8d07c7070995636dff40fdf4333afc9bd4143483db5d8dff82ec1f28ff76'), ('bytecode.mjs', 6836, 'e76fabeea879bc1da8016283f7e74df5974479666b100199c2d3c31a53b8ecf4'), ('class-policy.mjs', 1362, '586d8800afd0e5d5ec7d4d3e758dbf56942f7d74207c111c0b32fb4483b0d1f6'), - ('class-values.mjs', 15322, 'f4f682f07b74c4f8d36d6e30ba7cfbacfde1e81ba4b5b1ebec0a36c378901b43'), + ('class-values.mjs', 15372, '67fc606a6c91f7aeb1d4309cc4a0be33778181cdccb51190e5f76d4969d9b43d'), ('function-bytecode.mjs', 20438, 'ca6b3abd73d2be35422eea244b876a851bf98e38836e31a33fa6d18d4252ae68'), ('function-objects.mjs', 4277, '06af0ca936de1dd336cb7a2ac13f24efae960ea3fe448c93fb95a2e92213c4e2'), - ('functions.mjs', 56408, 'c6e8c6d0bbb719db0376ec93f3417015a4225fff1d6784acc5f5f322b245708e'), + ('functions.mjs', 56452, '3f60a5034ff614f6bc12743194bbcd46096da549a677afb58b00fa5fed8da527'), ('interpreter-host.mjs', 7019, '0bb0a64faffcf77e0064258e4cfcaa7a937c4372129849f76901bee91b5a5ae2'), ('interpreter-worker.mjs', 8282, '937a97cc49d987492de0bc16021a4a804ef51e4ea5b398f79c92e5d30ac82558'), ('library-catalog.mjs', 907, '0adcaae0f6f07b4e1acb22e46493ec99f7247a5828b426c703530d8b2736032c'), @@ -20,8 +20,8 @@ ('module-registry.mjs', 10085, 'f23f8313c0804a052cba155e193df920b0221ef978a06f16c1a22f208dfbef91'), ('module-session.mjs', 1470, '08e4ccf16361299c15b99621fdfcef0d9ef93de50d28d738158c7bc85a7c23a3'), ('numeric.mjs', 13954, '2431f099a7d40aa38d6b3c68b556207752e4191211e01005acde94325bd91bad'), - ('operation-runtime.mjs', 85085, '7767bd13309e4a326cf807c387dffff8ddd31b52edd7cf591e762162fe848c8c'), - ('operations.mjs', 21566, 'a05c7535ed1312c42d2961d2cae3ac2d60c7f65133f86b90f8fb215fa066433b'), + ('operation-runtime.mjs', 87150, 'fab6eed0c35813348cd17b6524ab006b51af6d0bbad7405752de939e65fe354e'), + ('operations.mjs', 22088, 'cd872da96bc09f14c105ebb977ea3b5ed1441ec43f538df6d208966dc82bf014'), ('source-compiler.mjs', 35239, '4201a25a0c45b8fba4777c8e98b75e37f3277888708093a77e2827dec5ff7063'), ('source-identifiers.mjs', 25949, '1e4e6a90728a2d9393eb4b2055d4bd759542f4768d1870b508d629c6888e8914'), ('source-modules.mjs', 5115, '3f824a8cf79a9ab78c1b817ed1597d114d851748a6b0894a4ac6b4bdbe01cfef'), diff --git a/tests/fixtures/interpreter/compatibility.json b/tests/fixtures/interpreter/compatibility.json index d6ecb1d..a2e3602 100644 --- a/tests/fixtures/interpreter/compatibility.json +++ b/tests/fixtures/interpreter/compatibility.json @@ -883,16 +883,14 @@ { "id": "builtin-delattr", "source": "print(callable(delattr))\n", - "classification": "deferred", + "classification": "supported", "reference": { "stdout": "True\n", "error_type": null }, "engine": { - "stdout": "", - "error_type": "NameError", - "code": "PYTHON", - "phase": "execute" + "stdout": "True\n", + "error_type": null } }, { @@ -1042,16 +1040,14 @@ { "id": "builtin-getattr", "source": "print(callable(getattr))\n", - "classification": "deferred", + "classification": "supported", "reference": { "stdout": "True\n", "error_type": null }, "engine": { - "stdout": "", - "error_type": "NameError", - "code": "PYTHON", - "phase": "execute" + "stdout": "True\n", + "error_type": null } }, { @@ -1072,16 +1068,14 @@ { "id": "builtin-hasattr", "source": "print(callable(hasattr))\n", - "classification": "deferred", + "classification": "supported", "reference": { "stdout": "True\n", "error_type": null }, "engine": { - "stdout": "", - "error_type": "NameError", - "code": "PYTHON", - "phase": "execute" + "stdout": "True\n", + "error_type": null } }, { @@ -1480,16 +1474,14 @@ { "id": "builtin-setattr", "source": "print(callable(setattr))\n", - "classification": "deferred", + "classification": "supported", "reference": { "stdout": "True\n", "error_type": null }, "engine": { - "stdout": "", - "error_type": "NameError", - "code": "PYTHON", - "phase": "execute" + "stdout": "True\n", + "error_type": null } }, { diff --git a/tests/fixtures/interpreter/object-protocols.json b/tests/fixtures/interpreter/object-protocols.json index b679de0..31c3c10 100644 --- a/tests/fixtures/interpreter/object-protocols.json +++ b/tests/fixtures/interpreter/object-protocols.json @@ -3,7 +3,9 @@ "suite": "interpreter-object-protocols/1", "profile": "classes-source/1", "reference_version": "3.14.7", - "implemented_stages": [], + "implemented_stages": [ + "O1" + ], "cases": [ { "id": "attribute-defaults", From 09e562bfa9a92d52755516b8feff3ebb8c96bf84 Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:59:30 -0700 Subject: [PATCH 6/6] test(INT-GEN-01B): adopt iterator generator observation --- tests/fixtures/interpreter/iteration.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/interpreter/iteration.json b/tests/fixtures/interpreter/iteration.json index 8fdeb19..0566088 100644 --- a/tests/fixtures/interpreter/iteration.json +++ b/tests/fixtures/interpreter/iteration.json @@ -541,7 +541,7 @@ }, { "id": "future-generator", - "stage": "T3", + "stage": "T1", "source": "def use():\n yield 7\nprint(next(use()))\n", "stdout": "7\n" },