From 4302430b91f92bbde4c218c955f5e8a55a59f111 Mon Sep 17 00:00:00 2001 From: MaxFreedomPollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:19:49 -0400 Subject: [PATCH] Fix bundle() on Python 3.13 by snapshotting the caller's locals Fixes #43. bundle() captures the calling frame's locals so that a trainable operator can be re-executed with them. Python 3.13 changed frame.f_locals to return a live FrameLocalsProxy rather than a dict (PEP 667), so the captured value is no longer a dict and FunModule's isinstance check rejects it: AssertionError: _ldict must be a dictionary. or None Every bundle() call fails on 3.13 as a result, which is what #43 reports. Taking dict(...) of the proxy at the capture site restores the pre-3.13 behaviour exactly -- a plain dict snapshot of the caller's locals -- on every supported version, and needs no change to the isinstance check. tests/unit_tests/test_bundle.py already exercises the failing path, since it applies @bundle inside a function; it simply never ran on 3.13. This adds an explicit assertion that the captured locals are a dict holding the caller's variables, and extends the CI matrix to 3.9 and 3.13 so the regression is covered from now on. --- .github/workflows/python-app.yml | 8 ++++++-- opto/trace/bundle.py | 4 +++- tests/unit_tests/test_bundle.py | 20 +++++++++++++++++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index bda57a97..51707ce7 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -16,13 +16,17 @@ jobs: build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.13"] steps: - uses: actions/checkout@v4 - - name: Set up Python 3.9 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: - python-version: "3.9" + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/opto/trace/bundle.py b/opto/trace/bundle.py index 57b9901a..66c30309 100644 --- a/opto/trace/bundle.py +++ b/opto/trace/bundle.py @@ -51,7 +51,9 @@ def bundle( Returns: FunModule: The wrapped function that returns node objects. """ - prev_f_locals = inspect.stack()[1].frame.f_locals + # Python 3.13+ returns a live FrameLocalsProxy here rather than a dict (PEP 667); + # snapshot it so the captured locals stay a plain dict on every version. + prev_f_locals = dict(inspect.stack()[1].frame.f_locals) def decorator(fun): fun_module = FunModule( diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index ea00c6d3..4a37575f 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -425,4 +425,22 @@ def modify_global_list(): print("Running tests with trainable=False") run(trainable=False) print("Running tests with trainable=True") -run(trainable=True) \ No newline at end of file +run(trainable=True) + +def test_caller_locals_are_captured_as_dict(): + # Regression test for #43. bundle() captures the calling frame's locals so a + # trainable operator can be re-executed with them. On Python 3.13+ frame.f_locals + # is a live FrameLocalsProxy rather than a dict (PEP 667), which used to trip the + # isinstance check in FunModule and made bundle unusable on 3.13. + nonlocal_marker = node("This is a nonlocal marker") + + @trace.bundle() + def uses_marker(x): + return x + + assert isinstance(uses_marker._ldict, dict) + assert uses_marker._ldict["nonlocal_marker"] is nonlocal_marker + + +print("Running caller-locals capture test") +test_caller_locals_are_captured_as_dict()