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()