From fd90e6d053c8f2d4f91eda0cf14b37bbc4c53670 Mon Sep 17 00:00:00 2001 From: Ryan Shaffer <3620100+rmshaffer@users.noreply.github.com> Date: Sat, 2 May 2026 17:25:46 -0400 Subject: [PATCH 1/6] feature: hide AutoQASM and malt internal frames from build-error tracebacks --- src/autoqasm/__init__.py | 1 + src/autoqasm/_frame_filtering.py | 135 ++++++++++++++ src/autoqasm/errors.py | 44 +++++ src/autoqasm/program/program.py | 31 ++-- src/autoqasm/transpiler/transpiler.py | 16 ++ test/unit_tests/autoqasm/test_error_ux.py | 210 ++++++++++++++++++++++ test/unit_tests/autoqasm/test_program.py | 4 +- 7 files changed, 428 insertions(+), 13 deletions(-) create mode 100644 src/autoqasm/_frame_filtering.py create mode 100644 test/unit_tests/autoqasm/test_error_ux.py diff --git a/src/autoqasm/__init__.py b/src/autoqasm/__init__.py index 5fac2bde..ee61caec 100644 --- a/src/autoqasm/__init__.py +++ b/src/autoqasm/__init__.py @@ -45,6 +45,7 @@ def my_program(): """ from . import errors, instructions, operators # noqa: F401 +from ._frame_filtering import set_verbose_errors, verbose_errors_enabled # noqa: F401 from ._version import __version__ # noqa: F401 from .api import gate, gate_calibration, main, subroutine # noqa: F401 from .instructions import QubitIdentifierType as Qubit # noqa: F401 diff --git a/src/autoqasm/_frame_filtering.py b/src/autoqasm/_frame_filtering.py new file mode 100644 index 00000000..ff365550 --- /dev/null +++ b/src/autoqasm/_frame_filtering.py @@ -0,0 +1,135 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. + +"""Traceback filtering for AutoQASM. + +AutoQASM rewrites user functions with ``diastatic-malt`` (AutoGraph) before +executing them, producing a transformed Python function whose frames appear +in tracebacks alongside AutoQASM's own internals. When a user's program +has a bug, the traceback can contain more than a dozen frames that obscure +the line in user code that actually raised. + +This module rewrites ``exc.__traceback__`` to drop frames whose source +lives inside AutoQASM or malt internals. Users can opt in to the full +unfiltered traceback via :func:`set_verbose_errors` or by setting the +``AUTOQASM_VERBOSE_ERRORS`` environment variable. +""" + +from __future__ import annotations + +import os +from collections.abc import Iterable +from types import TracebackType + +VERBOSE_ERRORS_ENV_VAR = "AUTOQASM_VERBOSE_ERRORS" + +# Path segments identifying frames that are stripped from filtered +# tracebacks. AutoGraph writes the transformed user function to a +# ``__autograph_generated_file*.py`` scratch module in the OS tmp dir; +# those frames are noise as well. We deliberately keep ``oqpy`` frames +# since they can point at meaningful type errors. +_INTERNAL_PATH_MARKERS: tuple[str, ...] = ( + os.sep.join(("autoqasm", "api.py")), + os.sep.join(("autoqasm", "transpiler", "")), + os.sep.join(("autoqasm", "operators", "")), + os.sep.join(("autoqasm", "converters", "")), + os.sep.join(("autoqasm", "program", "program.py")), + os.sep.join(("autoqasm", "_frame_filtering.py")), + os.sep.join(("", "malt", "")), + "__autograph_generated_file", +) + +# Module-level override. ``None`` defers to the environment variable. +_verbose_errors_override: bool | None = None + + +def set_verbose_errors(enabled: bool) -> None: + """Enable or disable verbose tracebacks globally for this process. + + When verbose errors are enabled, AutoQASM will not strip its own + internal frames from tracebacks raised during program construction. + This is useful when debugging AutoQASM itself, or when reporting bugs. + + Args: + enabled (bool): ``True`` to show full (unfiltered) tracebacks; + ``False`` to suppress AutoQASM / malt internal frames. + """ + global _verbose_errors_override + _verbose_errors_override = bool(enabled) + + +def verbose_errors_enabled() -> bool: + """Return whether verbose (unfiltered) tracebacks are currently enabled. + + The override set by :func:`set_verbose_errors` takes precedence over the + ``AUTOQASM_VERBOSE_ERRORS`` environment variable. + + Returns: + bool: Whether verbose tracebacks are enabled. + """ + if _verbose_errors_override is not None: + return _verbose_errors_override + raw = os.environ.get(VERBOSE_ERRORS_ENV_VAR, "") + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _is_internal_frame(tb: TracebackType, markers: Iterable[str]) -> bool: + """Return True if the given traceback frame comes from AutoQASM internals.""" + filename = tb.tb_frame.f_code.co_filename + return any(marker in filename for marker in markers) + + +def filter_traceback( + exc: BaseException, + extra_markers: Iterable[str] = (), +) -> BaseException: + """Rewrite ``exc.__traceback__`` to hide AutoQASM and malt internal frames. + + No-op when :func:`verbose_errors_enabled` returns ``True``. If every frame + is internal, the traceback is set to ``None``: Python will then append + the user's call-site frames as the exception continues propagating. + + Args: + exc (BaseException): The exception whose ``__traceback__`` should be + filtered. The exception is returned for convenience. + extra_markers (Iterable[str]): Additional path-segment markers to + treat as internal. Useful for tests. + + Returns: + BaseException: The same exception with its ``__traceback__`` rewritten. + """ + if verbose_errors_enabled(): + return exc + + tb = exc.__traceback__ + if tb is None: + return exc + + markers = (*_INTERNAL_PATH_MARKERS, *extra_markers) + kept_frames: list[TracebackType] = [] + cursor: TracebackType | None = tb + while cursor is not None: + if not _is_internal_frame(cursor, markers): + kept_frames.append(cursor) + cursor = cursor.tb_next + + new_tb: TracebackType | None = None + for frame in reversed(kept_frames): + new_tb = TracebackType( + tb_next=new_tb, + tb_frame=frame.tb_frame, + tb_lasti=frame.tb_lasti, + tb_lineno=frame.tb_lineno, + ) + exc.__traceback__ = new_tb + return exc diff --git a/src/autoqasm/errors.py b/src/autoqasm/errors.py index e745dea3..877af01c 100644 --- a/src/autoqasm/errors.py +++ b/src/autoqasm/errors.py @@ -82,6 +82,50 @@ def __str__(self): return self.message +class OutsideProgramContextError(AutoQasmError): + """Raised when an AutoQASM feature is used outside of an active program + context (i.e. outside a function decorated with ``@aq.main`` / + ``@aq.subroutine`` / ``@aq.gate``). + """ + + def __init__(self, feature: str | None = None): + """ + Args: + feature (str | None): The name of the AutoQASM feature being invoked + outside of an active program context, if known. Used to produce a + slightly more pointed error message. + """ + feature_description = f"`{feature}`" if feature else "This AutoQASM feature" + self.message = f"""{feature_description} can only be used inside a function decorated \ +with `@aq.main`, `@aq.subroutine`, `@aq.gate`, or `@aq.gate_calibration`. + +For example: + + import autoqasm as aq + from autoqasm.instructions import h, cnot, measure + + @aq.main + def my_program(): + h(0) + cnot(0, 1) + return measure([0, 1]) + +If you want to build a program programmatically, use the `aq.build_program()` \ +context manager directly. +""" + + def __str__(self): + return self.message + + +class BuildError(AutoQasmError): + """Non-AutoQasmError raised during program construction, wrapped with + actionable guidance pointing back to the user's code. + + The original exception is preserved via ``__cause__``. + """ + + class InsufficientQubitCountError(AutoQasmError): """Target device does not have enough qubits for the program.""" diff --git a/src/autoqasm/program/program.py b/src/autoqasm/program/program.py index d7a6ad8c..ccf04142 100644 --- a/src/autoqasm/program/program.py +++ b/src/autoqasm/program/program.py @@ -31,7 +31,7 @@ from sympy import Symbol import autoqasm.types as aq_types -from autoqasm import constants, errors +from autoqasm import _frame_filtering, constants, errors from autoqasm.instructions.qubits import GlobalQubitRegister, _get_physical_qubit_indices, _qubit from autoqasm.program.serialization_properties import ( OpenQASMSerializationProperties, @@ -117,7 +117,12 @@ def build(self, device: Device | str | None = None) -> Program: if isinstance(device, str): device = AwsDevice(device) - return self._program_generator(device=device) + try: + return self._program_generator(device=device) + except Exception as e: + # No-op when verbose errors are enabled; see set_verbose_errors. + _frame_filtering.filter_traceback(e) + raise def to_ir( self, @@ -903,12 +908,11 @@ def build_program(user_config: UserConfig | None = None) -> None: owns_program_conversion_context = True yield _get_local().program_conversion_context except Exception as e: - if isinstance(e, errors.AutoQasmError): - raise - elif hasattr(e, "ag_error_metadata"): - raise e.ag_error_metadata.to_exception(e) - else: - raise + # Prefer the AutoGraph-reconstructed exception so the user sees + # their own source line quoted in the error message. + if not isinstance(e, errors.AutoQasmError) and hasattr(e, "ag_error_metadata"): + raise e.ag_error_metadata.to_exception(e) from None + raise finally: if owns_program_conversion_context: _get_local().program_conversion_context = None @@ -930,10 +934,15 @@ def get_program_conversion_context() -> ProgramConversionContext: Must be called inside an active program conversion context (that is, while building a program) so that there is a valid thread-local ProgramConversionContext object. + Raises: + errors.OutsideProgramContextError: If there is no active program + conversion context — typically because an AutoQASM instruction or + helper was called outside a function decorated with + ``@aq.main`` / ``@aq.subroutine`` / ``@aq.gate``. + Returns: ProgramConversionContext: The thread-local ProgramConversionContext object. """ - assert _get_local().program_conversion_context is not None, ( - "get_program_conversion_context() must be called inside build_program() block" - ) + if _get_local().program_conversion_context is None: + raise errors.OutsideProgramContextError() return _get_local().program_conversion_context diff --git a/src/autoqasm/transpiler/transpiler.py b/src/autoqasm/transpiler/transpiler.py index a363b3cb..e19f1a71 100644 --- a/src/autoqasm/transpiler/transpiler.py +++ b/src/autoqasm/transpiler/transpiler.py @@ -44,6 +44,7 @@ from malt.impl.api import _attach_error_metadata, _log_callargs, is_autograph_artifact from malt.operators import function_wrappers from malt.pyct import anno, cfg, qual_names, transpiler +from malt.pyct.errors import InaccessibleSourceCodeError from malt.pyct.static_analysis import activity, reaching_definitions from malt.utils import ag_logging as logging @@ -56,6 +57,7 @@ return_statements, typecast, ) +from autoqasm.errors import BuildError class PyToOqpy(transpiler.PyToPy): @@ -288,6 +290,20 @@ def _try_convert_actual( converted_f = _convert_actual(target_entity, program_ctx) if logging.has_verbosity(2): _log_callargs(converted_f, effective_args, kwargs) + except InaccessibleSourceCodeError as e: + # Raised by ``diastatic-malt`` when it cannot retrieve the source code + # of the user's function (e.g. when the function was defined in an + # interactive REPL). Turn the cryptic default message into an + # actionable AutoQASM error. + func_name = getattr(target_entity, "__name__", repr(target_entity)) + exc = BuildError( + f"AutoQASM could not read the source code of function `{func_name}`. " + "This usually happens when a function is defined in an interactive " + "Python session (such as the REPL or a dynamically-compiled `exec` " + "block). Please define the function in a regular Python source " + "file, a script, or a notebook cell and try again." + ) + exc.__cause__ = e except Exception as e: # noqa: BLE001 logging.log(1, "Error transforming entity %s", target_entity, exc_info=True) exc = e diff --git a/test/unit_tests/autoqasm/test_error_ux.py b/test/unit_tests/autoqasm/test_error_ux.py new file mode 100644 index 00000000..df104c65 --- /dev/null +++ b/test/unit_tests/autoqasm/test_error_ux.py @@ -0,0 +1,210 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. + +"""Tests for the traceback filtering and error UX improvements.""" + +from __future__ import annotations + +import traceback + +import pytest + +import autoqasm as aq +from autoqasm import _frame_filtering, errors +from autoqasm.instructions import h, measure + + +def test_outside_program_context_raises_autoqasm_error() -> None: + """Calling a gate outside an @aq.main function should raise + OutsideProgramContextError.""" + with pytest.raises(errors.OutsideProgramContextError) as exc_info: + h(0) + msg = str(exc_info.value) + assert "@aq.main" in msg + assert "@aq.subroutine" in msg + + +def test_outside_program_context_is_autoqasm_error() -> None: + """OutsideProgramContextError should be an AutoQasmError so `except + aq.errors.AutoQasmError` catches it uniformly.""" + assert issubclass(errors.OutsideProgramContextError, errors.AutoQasmError) + assert issubclass(errors.BuildError, errors.AutoQasmError) + + +def test_verbose_errors_toggle() -> None: + """set_verbose_errors() controls whether internal frames are kept.""" + assert aq.verbose_errors_enabled() is False # default + + aq.set_verbose_errors(True) + try: + assert aq.verbose_errors_enabled() is True + finally: + aq.set_verbose_errors(False) + assert aq.verbose_errors_enabled() is False + + +def test_verbose_errors_env_var(monkeypatch: pytest.MonkeyPatch) -> None: + """The AUTOQASM_VERBOSE_ERRORS env var enables verbose mode when the + module-level override is not set.""" + # Clear the module-level override for this test. + _frame_filtering._verbose_errors_override = None + + monkeypatch.setenv(_frame_filtering.VERBOSE_ERRORS_ENV_VAR, "") + assert aq.verbose_errors_enabled() is False + + monkeypatch.setenv(_frame_filtering.VERBOSE_ERRORS_ENV_VAR, "1") + assert aq.verbose_errors_enabled() is True + + monkeypatch.setenv(_frame_filtering.VERBOSE_ERRORS_ENV_VAR, "true") + assert aq.verbose_errors_enabled() is True + + monkeypatch.setenv(_frame_filtering.VERBOSE_ERRORS_ENV_VAR, "0") + assert aq.verbose_errors_enabled() is False + + +def test_filter_traceback_hides_autoqasm_frames() -> None: + """Errors raised through AutoQASM's build pipeline should not expose + AutoQASM / malt internal frames to the user.""" + + # A dynamically-compiled @aq.main function triggers the BuildError + # code path, which flows through the transpiler internals. + src = ( + "import autoqasm as aq\nfrom autoqasm.instructions import h\n@aq.main\ndef p():\n h(0)\n" + ) + module_globals: dict = {} + exec(compile(src, "", "exec"), module_globals) + + with pytest.raises(errors.BuildError) as exc_info: + module_globals["p"].build() + + frames = traceback.extract_tb(exc_info.value.__traceback__) + for frame in frames: + assert "/malt/" not in frame.filename, ( + f"malt internal frame leaked through filter: {frame.filename}" + ) + assert "/autoqasm/transpiler/" not in frame.filename, ( + f"transpiler internal frame leaked through filter: {frame.filename}" + ) + assert "/autoqasm/operators/" not in frame.filename, ( + f"operators internal frame leaked through filter: {frame.filename}" + ) + + +def test_filter_traceback_verbose_keeps_autoqasm_frames() -> None: + """With verbose errors enabled, internal frames should stay visible for + debugging AutoQASM itself.""" + + src = ( + "import autoqasm as aq\nfrom autoqasm.instructions import h\n@aq.main\ndef p():\n h(0)\n" + ) + module_globals: dict = {} + exec(compile(src, "", "exec"), module_globals) + + aq.set_verbose_errors(True) + try: + with pytest.raises(errors.BuildError) as exc_info: + module_globals["p"].build() + frames = traceback.extract_tb(exc_info.value.__traceback__) + # At least one frame should come from autoqasm internals (the + # transpiler, where BuildError is raised). + has_internal = any("/autoqasm/transpiler/" in f.filename for f in frames) + assert has_internal, ( + "Verbose mode should preserve at least one internal frame for debugging" + ) + finally: + aq.set_verbose_errors(False) + + +def test_filter_traceback_preserves_user_bug_frame() -> None: + """Keep the user's call-site frame for the `.build()` call.""" + + src = ( + "import autoqasm as aq\nfrom autoqasm.instructions import h\n@aq.main\ndef p():\n h(0)\n" + ) + module_globals: dict = {} + exec(compile(src, "", "exec"), module_globals) + + with pytest.raises(errors.BuildError) as exc_info: + module_globals["p"].build() # <-- this frame should survive filtering + + frames = traceback.extract_tb(exc_info.value.__traceback__) + # At least one frame should be from this test file. + assert any(f.filename == __file__ for f in frames), ( + f"Expected at least one frame from this test file; got {frames}" + ) + + +def test_inaccessible_source_raises_build_error() -> None: + """Functions defined without accessible source code should raise a + clean BuildError, not the raw InaccessibleSourceCodeError from malt.""" + + src = ( + "import autoqasm as aq\n" + "from autoqasm.instructions import h\n" + "@aq.main\n" + "def dynamic_program():\n" + " h(0)\n" + ) + module_globals: dict = {} + exec(compile(src, "", "exec"), module_globals) + + with pytest.raises(errors.BuildError) as exc_info: + module_globals["dynamic_program"].build() + + msg = str(exc_info.value) + assert "dynamic_program" in msg + assert "interactive Python session" in msg + # The original malt exception should still be available as __cause__. + assert exc_info.value.__cause__ is not None + + +def test_measure_outside_program_context_raises() -> None: + """`measure()` called outside @aq.main should produce the clean error.""" + with pytest.raises(errors.OutsideProgramContextError): + measure(0) + + +def test_filter_traceback_with_no_traceback_is_noop() -> None: + """If an exception has no traceback yet (not raised), the filter should + return it untouched.""" + exc = RuntimeError("no tb here") + assert exc.__traceback__ is None + result = _frame_filtering.filter_traceback(exc) + assert result is exc + assert exc.__traceback__ is None + + +# The following "real user bug" test uses a module-level @aq.main function so +# that malt's AutoGraph is able to locate its source code. A typo triggers +# a NameError which AutoGraph wraps via its ``ag_error_metadata`` machinery. + + +@aq.main +def _nameerror_program_for_test(): + this_name_does_not_exist() # noqa: F821 + + +def test_real_user_nameerror_filter() -> None: + with pytest.raises(NameError) as exc_info: + _nameerror_program_for_test.build() + message = str(exc_info.value) + assert "this_name_does_not_exist" in message + assert "_nameerror_program_for_test" in message + + frames = traceback.extract_tb(exc_info.value.__traceback__) + for frame in frames: + assert "/malt/" not in frame.filename + assert "/autoqasm/transpiler/" not in frame.filename + assert "/autoqasm/operators/" not in frame.filename + assert "/autoqasm/converters/" not in frame.filename + assert "__autograph_generated_file" not in frame.filename diff --git a/test/unit_tests/autoqasm/test_program.py b/test/unit_tests/autoqasm/test_program.py index c8c576ae..73a354aa 100644 --- a/test/unit_tests/autoqasm/test_program.py +++ b/test/unit_tests/autoqasm/test_program.py @@ -42,7 +42,7 @@ def test_program_conversion_context() -> None: def test_build_program() -> None: """Tests the aq.build_program function.""" - with pytest.raises(AssertionError): + with pytest.raises(aq.errors.OutsideProgramContextError): aq.program.get_program_conversion_context() with aq.build_program() as program_conversion_context: @@ -51,7 +51,7 @@ def test_build_program() -> None: assert inner_context is program_conversion_context assert aq.program.get_program_conversion_context() == inner_context - with pytest.raises(AssertionError): + with pytest.raises(aq.errors.OutsideProgramContextError): aq.program.get_program_conversion_context() From a3a15b1e68e96cf75329cfa900c0a3c7c31f603e Mon Sep 17 00:00:00 2001 From: Ryan Shaffer <3620100+rmshaffer@users.noreply.github.com> Date: Mon, 4 May 2026 13:33:50 -0400 Subject: [PATCH 2/6] fix test paths --- test/unit_tests/autoqasm/test_error_ux.py | 24 +++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/test/unit_tests/autoqasm/test_error_ux.py b/test/unit_tests/autoqasm/test_error_ux.py index df104c65..db5d4d22 100644 --- a/test/unit_tests/autoqasm/test_error_ux.py +++ b/test/unit_tests/autoqasm/test_error_ux.py @@ -15,6 +15,7 @@ from __future__ import annotations +import os import traceback import pytest @@ -23,6 +24,13 @@ from autoqasm import _frame_filtering, errors from autoqasm.instructions import h, measure +# Internal-path markers built with the OS-native separator so these tests pass on +# Windows as well as POSIX. +_MALT_MARKER = os.sep.join(("", "malt", "")) +_TRANSPILER_MARKER = os.sep.join(("autoqasm", "transpiler", "")) +_OPERATORS_MARKER = os.sep.join(("autoqasm", "operators", "")) +_CONVERTERS_MARKER = os.sep.join(("autoqasm", "converters", "")) + def test_outside_program_context_raises_autoqasm_error() -> None: """Calling a gate outside an @aq.main function should raise @@ -89,13 +97,13 @@ def test_filter_traceback_hides_autoqasm_frames() -> None: frames = traceback.extract_tb(exc_info.value.__traceback__) for frame in frames: - assert "/malt/" not in frame.filename, ( + assert _MALT_MARKER not in frame.filename, ( f"malt internal frame leaked through filter: {frame.filename}" ) - assert "/autoqasm/transpiler/" not in frame.filename, ( + assert _TRANSPILER_MARKER not in frame.filename, ( f"transpiler internal frame leaked through filter: {frame.filename}" ) - assert "/autoqasm/operators/" not in frame.filename, ( + assert _OPERATORS_MARKER not in frame.filename, ( f"operators internal frame leaked through filter: {frame.filename}" ) @@ -117,7 +125,7 @@ def test_filter_traceback_verbose_keeps_autoqasm_frames() -> None: frames = traceback.extract_tb(exc_info.value.__traceback__) # At least one frame should come from autoqasm internals (the # transpiler, where BuildError is raised). - has_internal = any("/autoqasm/transpiler/" in f.filename for f in frames) + has_internal = any(_TRANSPILER_MARKER in f.filename for f in frames) assert has_internal, ( "Verbose mode should preserve at least one internal frame for debugging" ) @@ -203,8 +211,8 @@ def test_real_user_nameerror_filter() -> None: frames = traceback.extract_tb(exc_info.value.__traceback__) for frame in frames: - assert "/malt/" not in frame.filename - assert "/autoqasm/transpiler/" not in frame.filename - assert "/autoqasm/operators/" not in frame.filename - assert "/autoqasm/converters/" not in frame.filename + assert _MALT_MARKER not in frame.filename + assert _TRANSPILER_MARKER not in frame.filename + assert _OPERATORS_MARKER not in frame.filename + assert _CONVERTERS_MARKER not in frame.filename assert "__autograph_generated_file" not in frame.filename From 07ddc3c36fd6d1e3eaf89d23942d6035102ceabd Mon Sep 17 00:00:00 2001 From: Ryan Shaffer <3620100+rmshaffer@users.noreply.github.com> Date: Tue, 5 May 2026 09:38:04 -0400 Subject: [PATCH 3/6] classify frames by module flag instead of file paths --- src/autoqasm/_frame_filtering.py | 125 +++++++---- src/autoqasm/api.py | 2 + src/autoqasm/converters/__init__.py | 2 + src/autoqasm/operators/__init__.py | 2 + src/autoqasm/program/program.py | 2 + src/autoqasm/transpiler/__init__.py | 2 + test/unit_tests/autoqasm/test_error_ux.py | 242 +++++++++++----------- 7 files changed, 213 insertions(+), 164 deletions(-) diff --git a/src/autoqasm/_frame_filtering.py b/src/autoqasm/_frame_filtering.py index ff365550..54e8383d 100644 --- a/src/autoqasm/_frame_filtering.py +++ b/src/autoqasm/_frame_filtering.py @@ -19,37 +19,47 @@ has a bug, the traceback can contain more than a dozen frames that obscure the line in user code that actually raised. -This module rewrites ``exc.__traceback__`` to drop frames whose source -lives inside AutoQASM or malt internals. Users can opt in to the full -unfiltered traceback via :func:`set_verbose_errors` or by setting the -``AUTOQASM_VERBOSE_ERRORS`` environment variable. +This module rewrites ``exc.__traceback__`` to drop internal frames. Users +can opt in to the full unfiltered traceback via :func:`set_verbose_errors` +or by setting the ``AUTOQASM_VERBOSE_ERRORS`` environment variable. + +How a frame is classified as internal +------------------------------------- + +* A module (or subpackage) that should be hidden sets + ``__autoqasm_internal__ = True`` at its top level. The filter walks a + frame's dotted module name up the import hierarchy via :data:`sys.modules` + and hides the frame if any ancestor carries the flag. Setting the flag + on a subpackage's ``__init__.py`` therefore covers every submodule + under it. + +* Frames from third-party packages we cannot modify are matched by their + dotted module name against :data:`_THIRD_PARTY_INTERNAL_MODULES` — + currently ``diastatic-malt``, which AutoQASM uses as its AutoGraph + implementation. + +* AutoGraph compiles transformed user code into a scratch file named + ``__autograph_generated_file*.py``. Those frames have no importable + module, so they are matched by filename basename — the one case where + a path check is genuinely the right tool. """ from __future__ import annotations import os -from collections.abc import Iterable -from types import TracebackType - -VERBOSE_ERRORS_ENV_VAR = "AUTOQASM_VERBOSE_ERRORS" - -# Path segments identifying frames that are stripped from filtered -# tracebacks. AutoGraph writes the transformed user function to a -# ``__autograph_generated_file*.py`` scratch module in the OS tmp dir; -# those frames are noise as well. We deliberately keep ``oqpy`` frames -# since they can point at meaningful type errors. -_INTERNAL_PATH_MARKERS: tuple[str, ...] = ( - os.sep.join(("autoqasm", "api.py")), - os.sep.join(("autoqasm", "transpiler", "")), - os.sep.join(("autoqasm", "operators", "")), - os.sep.join(("autoqasm", "converters", "")), - os.sep.join(("autoqasm", "program", "program.py")), - os.sep.join(("autoqasm", "_frame_filtering.py")), - os.sep.join(("", "malt", "")), - "__autograph_generated_file", -) - -# Module-level override. ``None`` defers to the environment variable. +import sys +from types import FrameType, TracebackType + +__autoqasm_internal__ = True + +INTERNAL_MARKER: str = "__autoqasm_internal__" + +VERBOSE_ERRORS_ENV_VAR: str = "AUTOQASM_VERBOSE_ERRORS" + +_THIRD_PARTY_INTERNAL_MODULES: tuple[str, ...] = ("malt",) + +_AUTOGRAPH_GENERATED_PREFIX: str = "__autograph_generated_file" + _verbose_errors_override: bool | None = None @@ -83,17 +93,42 @@ def verbose_errors_enabled() -> bool: return raw.strip().lower() in {"1", "true", "yes", "on"} -def _is_internal_frame(tb: TracebackType, markers: Iterable[str]) -> bool: - """Return True if the given traceback frame comes from AutoQASM internals.""" - filename = tb.tb_frame.f_code.co_filename - return any(marker in filename for marker in markers) +def _module_or_ancestor_is_flagged(module_name: str) -> bool: + """Return True if ``module_name`` or any of its ancestors in + :data:`sys.modules` sets :data:`INTERNAL_MARKER` to True.""" + name = module_name + while name: + module = sys.modules.get(name) + if module is not None and getattr(module, INTERNAL_MARKER, False): + return True + if "." not in name: + return False + name = name.rsplit(".", 1)[0] + return False -def filter_traceback( - exc: BaseException, - extra_markers: Iterable[str] = (), -) -> BaseException: - """Rewrite ``exc.__traceback__`` to hide AutoQASM and malt internal frames. +def _is_third_party_internal_module(module_name: str) -> bool: + """Return True if ``module_name`` is, or is nested under, one of the + third-party modules listed in :data:`_THIRD_PARTY_INTERNAL_MODULES`.""" + return any( + module_name == ext or module_name.startswith(ext + ".") + for ext in _THIRD_PARTY_INTERNAL_MODULES + ) + + +def _is_internal_frame(frame: FrameType) -> bool: + """Return True if ``frame`` comes from AutoQASM internals.""" + module_name = frame.f_globals.get("__name__", "") + if _module_or_ancestor_is_flagged(module_name): + return True + if _is_third_party_internal_module(module_name): + return True + basename = os.path.basename(frame.f_code.co_filename) + return basename.startswith(_AUTOGRAPH_GENERATED_PREFIX) + + +def filter_traceback(exc: BaseException) -> BaseException: + """Rewrite ``exc.__traceback__`` to hide AutoQASM-internal frames. No-op when :func:`verbose_errors_enabled` returns ``True``. If every frame is internal, the traceback is set to ``None``: Python will then append @@ -102,8 +137,6 @@ def filter_traceback( Args: exc (BaseException): The exception whose ``__traceback__`` should be filtered. The exception is returned for convenience. - extra_markers (Iterable[str]): Additional path-segment markers to - treat as internal. Useful for tests. Returns: BaseException: The same exception with its ``__traceback__`` rewritten. @@ -115,21 +148,23 @@ def filter_traceback( if tb is None: return exc - markers = (*_INTERNAL_PATH_MARKERS, *extra_markers) - kept_frames: list[TracebackType] = [] + kept: list[TracebackType] = [] cursor: TracebackType | None = tb while cursor is not None: - if not _is_internal_frame(cursor, markers): - kept_frames.append(cursor) + if not _is_internal_frame(cursor.tb_frame): + kept.append(cursor) cursor = cursor.tb_next new_tb: TracebackType | None = None - for frame in reversed(kept_frames): + for node in reversed(kept): new_tb = TracebackType( tb_next=new_tb, - tb_frame=frame.tb_frame, - tb_lasti=frame.tb_lasti, - tb_lineno=frame.tb_lineno, + tb_frame=node.tb_frame, + tb_lasti=node.tb_lasti, + tb_lineno=node.tb_lineno, ) exc.__traceback__ = new_tb return exc + + +__autoqasm_internal__ = True diff --git a/src/autoqasm/api.py b/src/autoqasm/api.py index 3f7cf62b..92aa8899 100644 --- a/src/autoqasm/api.py +++ b/src/autoqasm/api.py @@ -15,6 +15,8 @@ from __future__ import annotations +__autoqasm_internal__ = True + import copy import functools import inspect diff --git a/src/autoqasm/converters/__init__.py b/src/autoqasm/converters/__init__.py index 85d6df76..3a6365fa 100644 --- a/src/autoqasm/converters/__init__.py +++ b/src/autoqasm/converters/__init__.py @@ -17,3 +17,5 @@ transformed AST node is the output of a converter. This module implements converters that AutoQASM overloads or adds on top of AutoGraph. """ + +__autoqasm_internal__ = True diff --git a/src/autoqasm/operators/__init__.py b/src/autoqasm/operators/__init__.py index 171aa8ba..8ba5a1cd 100644 --- a/src/autoqasm/operators/__init__.py +++ b/src/autoqasm/operators/__init__.py @@ -17,6 +17,8 @@ This module implements operators that AutoQASM overloads or adds on top of AutoGraph. """ +__autoqasm_internal__ = True + # Operators below are imported directly from core autograph implementation from malt.impl.api import autograph_artifact # noqa: F401 from malt.operators.variables import Undefined, UndefinedReturnValue, ld, ldu # noqa: F401 diff --git a/src/autoqasm/program/program.py b/src/autoqasm/program/program.py index 32a0e010..b255a58f 100644 --- a/src/autoqasm/program/program.py +++ b/src/autoqasm/program/program.py @@ -15,6 +15,8 @@ from __future__ import annotations +__autoqasm_internal__ = True + import contextlib import copy import threading diff --git a/src/autoqasm/transpiler/__init__.py b/src/autoqasm/transpiler/__init__.py index cdc44dd8..095b80dc 100644 --- a/src/autoqasm/transpiler/__init__.py +++ b/src/autoqasm/transpiler/__init__.py @@ -14,4 +14,6 @@ """This module implements the AutoQASM transpiler which uses autograph to convert a decorated Python function to an oqpy program.""" +__autoqasm_internal__ = True + from .transpiler import PyToOqpy, converted_call # noqa: F401 diff --git a/test/unit_tests/autoqasm/test_error_ux.py b/test/unit_tests/autoqasm/test_error_ux.py index db5d4d22..a20c7c0a 100644 --- a/test/unit_tests/autoqasm/test_error_ux.py +++ b/test/unit_tests/autoqasm/test_error_ux.py @@ -15,8 +15,9 @@ from __future__ import annotations -import os +import sys import traceback +from types import TracebackType import pytest @@ -24,17 +25,54 @@ from autoqasm import _frame_filtering, errors from autoqasm.instructions import h, measure -# Internal-path markers built with the OS-native separator so these tests pass on -# Windows as well as POSIX. -_MALT_MARKER = os.sep.join(("", "malt", "")) -_TRANSPILER_MARKER = os.sep.join(("autoqasm", "transpiler", "")) -_OPERATORS_MARKER = os.sep.join(("autoqasm", "operators", "")) -_CONVERTERS_MARKER = os.sep.join(("autoqasm", "converters", "")) + +def _build_dynamic_program(name: str = "p") -> object: + """Compile a tiny ``@aq.main`` function from a string and return it. + + The resulting function has no source file on disk, which triggers the + ``InaccessibleSourceCodeError`` → ``BuildError`` path in the transpiler. + """ + src = ( + "import autoqasm as aq\n" + "from autoqasm.instructions import h\n" + "@aq.main\n" + f"def {name}():\n" + " h(0)\n" + ) + module_globals: dict = {} + exec(compile(src, "", "exec"), module_globals) + return module_globals[name] + + +def _frame_is_internal(frame) -> bool: + """Independent reimplementation of ``_frame_filtering._is_internal_frame``. + + Kept separate so that a bug in the production classifier cannot silently + mask itself in tests. + """ + f_globals = frame.f_globals + if f_globals.get(_frame_filtering.INTERNAL_MARKER, False): + return True + module_name = f_globals.get("__name__", "") + return module_name == "malt" or module_name.startswith("malt.") + + +def _assert_no_internal_frames(tb: TracebackType | None) -> None: + """Walk ``tb`` and fail if any frame comes from code that should be hidden.""" + cursor: TracebackType | None = tb + while cursor is not None: + frame = cursor.tb_frame + assert not _frame_is_internal(frame), ( + f"internal frame leaked through filter: " + f"module={frame.f_globals.get('__name__')!r} " + f"file={frame.f_code.co_filename!r}" + ) + cursor = cursor.tb_next def test_outside_program_context_raises_autoqasm_error() -> None: - """Calling a gate outside an @aq.main function should raise - OutsideProgramContextError.""" + """Calling a gate outside an ``@aq.main`` function should raise + ``OutsideProgramContextError`` with actionable guidance.""" with pytest.raises(errors.OutsideProgramContextError) as exc_info: h(0) msg = str(exc_info.value) @@ -42,149 +80,90 @@ def test_outside_program_context_raises_autoqasm_error() -> None: assert "@aq.subroutine" in msg -def test_outside_program_context_is_autoqasm_error() -> None: - """OutsideProgramContextError should be an AutoQasmError so `except - aq.errors.AutoQasmError` catches it uniformly.""" +def test_error_classes_are_autoqasm_errors() -> None: + """New error classes must be catchable as ``AutoQasmError``.""" assert issubclass(errors.OutsideProgramContextError, errors.AutoQasmError) assert issubclass(errors.BuildError, errors.AutoQasmError) +def test_measure_outside_program_context_raises() -> None: + """``measure()`` called outside ``@aq.main`` produces the clean error.""" + with pytest.raises(errors.OutsideProgramContextError): + measure(0) + + def test_verbose_errors_toggle() -> None: - """set_verbose_errors() controls whether internal frames are kept.""" - assert aq.verbose_errors_enabled() is False # default + """``set_verbose_errors()`` controls whether internal frames are kept.""" + assert aq.verbose_errors_enabled() is False aq.set_verbose_errors(True) try: assert aq.verbose_errors_enabled() is True finally: aq.set_verbose_errors(False) + assert aq.verbose_errors_enabled() is False def test_verbose_errors_env_var(monkeypatch: pytest.MonkeyPatch) -> None: - """The AUTOQASM_VERBOSE_ERRORS env var enables verbose mode when the - module-level override is not set.""" - # Clear the module-level override for this test. + """``AUTOQASM_VERBOSE_ERRORS`` enables verbose mode when no explicit + override is set.""" _frame_filtering._verbose_errors_override = None - monkeypatch.setenv(_frame_filtering.VERBOSE_ERRORS_ENV_VAR, "") - assert aq.verbose_errors_enabled() is False - - monkeypatch.setenv(_frame_filtering.VERBOSE_ERRORS_ENV_VAR, "1") - assert aq.verbose_errors_enabled() is True - - monkeypatch.setenv(_frame_filtering.VERBOSE_ERRORS_ENV_VAR, "true") - assert aq.verbose_errors_enabled() is True - - monkeypatch.setenv(_frame_filtering.VERBOSE_ERRORS_ENV_VAR, "0") - assert aq.verbose_errors_enabled() is False + for value, expected in [("", False), ("1", True), ("true", True), ("0", False)]: + monkeypatch.setenv(_frame_filtering.VERBOSE_ERRORS_ENV_VAR, value) + assert aq.verbose_errors_enabled() is expected, f"for env value {value!r}" -def test_filter_traceback_hides_autoqasm_frames() -> None: - """Errors raised through AutoQASM's build pipeline should not expose - AutoQASM / malt internal frames to the user.""" - - # A dynamically-compiled @aq.main function triggers the BuildError - # code path, which flows through the transpiler internals. - src = ( - "import autoqasm as aq\nfrom autoqasm.instructions import h\n@aq.main\ndef p():\n h(0)\n" - ) - module_globals: dict = {} - exec(compile(src, "", "exec"), module_globals) - +def test_filter_traceback_hides_internal_frames() -> None: + """Building a dynamically-compiled program should raise ``BuildError`` + and the surfaced traceback should contain no internal frames.""" + program = _build_dynamic_program() with pytest.raises(errors.BuildError) as exc_info: - module_globals["p"].build() - - frames = traceback.extract_tb(exc_info.value.__traceback__) - for frame in frames: - assert _MALT_MARKER not in frame.filename, ( - f"malt internal frame leaked through filter: {frame.filename}" - ) - assert _TRANSPILER_MARKER not in frame.filename, ( - f"transpiler internal frame leaked through filter: {frame.filename}" - ) - assert _OPERATORS_MARKER not in frame.filename, ( - f"operators internal frame leaked through filter: {frame.filename}" - ) - - -def test_filter_traceback_verbose_keeps_autoqasm_frames() -> None: - """With verbose errors enabled, internal frames should stay visible for - debugging AutoQASM itself.""" + program.build() + _assert_no_internal_frames(exc_info.value.__traceback__) - src = ( - "import autoqasm as aq\nfrom autoqasm.instructions import h\n@aq.main\ndef p():\n h(0)\n" - ) - module_globals: dict = {} - exec(compile(src, "", "exec"), module_globals) +def test_filter_traceback_verbose_keeps_internal_frames() -> None: + """With verbose errors on, internal frames are preserved for debugging.""" + program = _build_dynamic_program() aq.set_verbose_errors(True) try: with pytest.raises(errors.BuildError) as exc_info: - module_globals["p"].build() - frames = traceback.extract_tb(exc_info.value.__traceback__) - # At least one frame should come from autoqasm internals (the - # transpiler, where BuildError is raised). - has_internal = any(_TRANSPILER_MARKER in f.filename for f in frames) - assert has_internal, ( - "Verbose mode should preserve at least one internal frame for debugging" - ) + program.build() + cursor: TracebackType | None = exc_info.value.__traceback__ + saw_internal = any(_frame_is_internal(f) for f, _ in traceback.walk_tb(cursor)) + assert saw_internal, "verbose mode should preserve at least one internal frame" finally: aq.set_verbose_errors(False) -def test_filter_traceback_preserves_user_bug_frame() -> None: - """Keep the user's call-site frame for the `.build()` call.""" - - src = ( - "import autoqasm as aq\nfrom autoqasm.instructions import h\n@aq.main\ndef p():\n h(0)\n" - ) - module_globals: dict = {} - exec(compile(src, "", "exec"), module_globals) - +def test_filter_traceback_preserves_user_call_site() -> None: + """The user's ``.build()`` call site (this test file) must survive filtering.""" + program = _build_dynamic_program() with pytest.raises(errors.BuildError) as exc_info: - module_globals["p"].build() # <-- this frame should survive filtering - + program.build() frames = traceback.extract_tb(exc_info.value.__traceback__) - # At least one frame should be from this test file. assert any(f.filename == __file__ for f in frames), ( - f"Expected at least one frame from this test file; got {frames}" + f"expected at least one frame from this test file; got {frames}" ) def test_inaccessible_source_raises_build_error() -> None: - """Functions defined without accessible source code should raise a - clean BuildError, not the raw InaccessibleSourceCodeError from malt.""" - - src = ( - "import autoqasm as aq\n" - "from autoqasm.instructions import h\n" - "@aq.main\n" - "def dynamic_program():\n" - " h(0)\n" - ) - module_globals: dict = {} - exec(compile(src, "", "exec"), module_globals) - + """A function whose source can't be read should raise a friendly + ``BuildError``, not the raw ``InaccessibleSourceCodeError`` from malt.""" + program = _build_dynamic_program(name="dynamic_program") with pytest.raises(errors.BuildError) as exc_info: - module_globals["dynamic_program"].build() + program.build() msg = str(exc_info.value) assert "dynamic_program" in msg assert "interactive Python session" in msg - # The original malt exception should still be available as __cause__. assert exc_info.value.__cause__ is not None -def test_measure_outside_program_context_raises() -> None: - """`measure()` called outside @aq.main should produce the clean error.""" - with pytest.raises(errors.OutsideProgramContextError): - measure(0) - - def test_filter_traceback_with_no_traceback_is_noop() -> None: - """If an exception has no traceback yet (not raised), the filter should - return it untouched.""" + """Filtering an exception that hasn't been raised is a no-op.""" exc = RuntimeError("no tb here") assert exc.__traceback__ is None result = _frame_filtering.filter_traceback(exc) @@ -192,27 +171,52 @@ def test_filter_traceback_with_no_traceback_is_noop() -> None: assert exc.__traceback__ is None -# The following "real user bug" test uses a module-level @aq.main function so -# that malt's AutoGraph is able to locate its source code. A typo triggers -# a NameError which AutoGraph wraps via its ``ag_error_metadata`` machinery. - - @aq.main def _nameerror_program_for_test(): + """Module-level ``@aq.main`` fixture so AutoGraph can locate its source + and surface the ``NameError`` via its ``ag_error_metadata`` machinery.""" this_name_does_not_exist() # noqa: F821 def test_real_user_nameerror_filter() -> None: + """A ``NameError`` inside ``@aq.main`` should reach the user with both + the bad name and the program name mentioned, and no internal frames + attached.""" with pytest.raises(NameError) as exc_info: _nameerror_program_for_test.build() + message = str(exc_info.value) assert "this_name_does_not_exist" in message assert "_nameerror_program_for_test" in message + _assert_no_internal_frames(exc_info.value.__traceback__) - frames = traceback.extract_tb(exc_info.value.__traceback__) - for frame in frames: - assert _MALT_MARKER not in frame.filename - assert _TRANSPILER_MARKER not in frame.filename - assert _OPERATORS_MARKER not in frame.filename - assert _CONVERTERS_MARKER not in frame.filename - assert "__autograph_generated_file" not in frame.filename + +def test_new_internal_module_is_hidden(monkeypatch: pytest.MonkeyPatch) -> None: + """A future AutoQASM module that opts in via ``__autoqasm_internal__`` + has its frames hidden without any change to the filter.""" + import types as _types + + fake = _types.ModuleType("autoqasm._test_fake_internal") + fake.__dict__[_frame_filtering.INTERNAL_MARKER] = True + + # Compile _raiser in the fake module's namespace so the frame's module is the fake one. + exec( # noqa: S102 + compile( + "def _raiser():\n raise RuntimeError('boom')\n", + "", + "exec", + ), + fake.__dict__, + ) + monkeypatch.setitem(sys.modules, fake.__name__, fake) + + try: + fake._raiser() + except RuntimeError as e: + raw_frames = list(traceback.walk_tb(e.__traceback__)) + assert any(f.f_globals.get("__name__") == fake.__name__ for f, _ in raw_frames) + + _frame_filtering.filter_traceback(e) + _assert_no_internal_frames(e.__traceback__) + else: + pytest.fail("_raiser did not raise") From 00d9a9c9e479d100ce2a2fc1900204505f5bb4ed Mon Sep 17 00:00:00 2001 From: Ryan Shaffer <3620100+rmshaffer@users.noreply.github.com> Date: Tue, 5 May 2026 09:53:55 -0400 Subject: [PATCH 4/6] code coverage --- src/autoqasm/_frame_filtering.py | 2 +- test/unit_tests/autoqasm/test_error_ux.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/autoqasm/_frame_filtering.py b/src/autoqasm/_frame_filtering.py index 54e8383d..a7116226 100644 --- a/src/autoqasm/_frame_filtering.py +++ b/src/autoqasm/_frame_filtering.py @@ -102,7 +102,7 @@ def _module_or_ancestor_is_flagged(module_name: str) -> bool: if module is not None and getattr(module, INTERNAL_MARKER, False): return True if "." not in name: - return False + break name = name.rsplit(".", 1)[0] return False diff --git a/test/unit_tests/autoqasm/test_error_ux.py b/test/unit_tests/autoqasm/test_error_ux.py index a20c7c0a..bfa24433 100644 --- a/test/unit_tests/autoqasm/test_error_ux.py +++ b/test/unit_tests/autoqasm/test_error_ux.py @@ -171,6 +171,11 @@ def test_filter_traceback_with_no_traceback_is_noop() -> None: assert exc.__traceback__ is None +def test_module_or_ancestor_is_flagged_empty_name() -> None: + """A frame whose module has no ``__name__`` must be treated as non-internal.""" + assert _frame_filtering._module_or_ancestor_is_flagged("") is False + + @aq.main def _nameerror_program_for_test(): """Module-level ``@aq.main`` fixture so AutoGraph can locate its source From 47f6cfa7b4b981a00eb18ffaf877cb845f3d8905 Mon Sep 17 00:00:00 2001 From: Ryan Shaffer <3620100+rmshaffer@users.noreply.github.com> Date: Tue, 5 May 2026 10:11:33 -0400 Subject: [PATCH 5/6] code coverage --- test/unit_tests/autoqasm/test_transpiler.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/test/unit_tests/autoqasm/test_transpiler.py b/test/unit_tests/autoqasm/test_transpiler.py index 08167542..e2d8f3cc 100644 --- a/test/unit_tests/autoqasm/test_transpiler.py +++ b/test/unit_tests/autoqasm/test_transpiler.py @@ -117,11 +117,20 @@ def bell(cls, q0: int, q1: int): assert aq.main(num_qubits=2)(MyClass().bell).build().to_ir() == expected -def test_with_verbose_logging() -> None: - """Tests aq.main decorator application with verbose logging enabled.""" +def test_with_verbose_logging(monkeypatch: pytest.MonkeyPatch) -> None: + """AutoGraph verbosity >= 2 should drive the ``_log_callargs`` hot path + in ``_try_convert_actual``.""" + from autoqasm.transpiler import transpiler - @aq.main - def nothing(): - pass + monkeypatch.setattr(transpiler, "_AG_LOGGING_DISABLED", False) + original_verbosity = ag_logging.get_verbosity() + ag_logging.set_verbosity(2) + try: + + @aq.main + def nothing(): + pass - ag_logging.set_verbosity(10) + nothing.build() + finally: + ag_logging.set_verbosity(original_verbosity) From 8a013249ca6999eaa2040634981ec6d29ba418c2 Mon Sep 17 00:00:00 2001 From: Ryan Shaffer <3620100+rmshaffer@users.noreply.github.com> Date: Fri, 8 May 2026 08:50:20 -0400 Subject: [PATCH 6/6] rename variables for clarity --- src/autoqasm/_frame_filtering.py | 37 ++++++++++++----------- src/autoqasm/api.py | 2 +- src/autoqasm/converters/__init__.py | 2 +- src/autoqasm/operators/__init__.py | 2 +- src/autoqasm/program/program.py | 2 +- src/autoqasm/transpiler/__init__.py | 2 +- test/unit_tests/autoqasm/test_error_ux.py | 8 ++--- 7 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/autoqasm/_frame_filtering.py b/src/autoqasm/_frame_filtering.py index a7116226..e58efe58 100644 --- a/src/autoqasm/_frame_filtering.py +++ b/src/autoqasm/_frame_filtering.py @@ -19,22 +19,23 @@ has a bug, the traceback can contain more than a dozen frames that obscure the line in user code that actually raised. -This module rewrites ``exc.__traceback__`` to drop internal frames. Users -can opt in to the full unfiltered traceback via :func:`set_verbose_errors` -or by setting the ``AUTOQASM_VERBOSE_ERRORS`` environment variable. +This module rewrites ``exc.__traceback__`` to drop internal frames that are +unhelpful to users. Users can opt in to the full unfiltered traceback via +:func:`set_verbose_errors` or by setting the ``AUTOQASM_VERBOSE_ERRORS`` +environment variable. -How a frame is classified as internal +How frames are hidden from tracebacks ------------------------------------- * A module (or subpackage) that should be hidden sets - ``__autoqasm_internal__ = True`` at its top level. The filter walks a + ``__filter_from_traceback__ = True`` at its top level. The filter walks a frame's dotted module name up the import hierarchy via :data:`sys.modules` and hides the frame if any ancestor carries the flag. Setting the flag on a subpackage's ``__init__.py`` therefore covers every submodule under it. * Frames from third-party packages we cannot modify are matched by their - dotted module name against :data:`_THIRD_PARTY_INTERNAL_MODULES` — + dotted module name against :data:`_THIRD_PARTY_MODULES_TO_FILTER` — currently ``diastatic-malt``, which AutoQASM uses as its AutoGraph implementation. @@ -50,13 +51,13 @@ import sys from types import FrameType, TracebackType -__autoqasm_internal__ = True +__filter_from_traceback__ = True -INTERNAL_MARKER: str = "__autoqasm_internal__" +FILTER_FROM_TRACEBACK: str = "__filter_from_traceback__" VERBOSE_ERRORS_ENV_VAR: str = "AUTOQASM_VERBOSE_ERRORS" -_THIRD_PARTY_INTERNAL_MODULES: tuple[str, ...] = ("malt",) +_THIRD_PARTY_MODULES_TO_FILTER: tuple[str, ...] = ("malt",) _AUTOGRAPH_GENERATED_PREFIX: str = "__autograph_generated_file" @@ -95,11 +96,11 @@ def verbose_errors_enabled() -> bool: def _module_or_ancestor_is_flagged(module_name: str) -> bool: """Return True if ``module_name`` or any of its ancestors in - :data:`sys.modules` sets :data:`INTERNAL_MARKER` to True.""" + :data:`sys.modules` sets :data:`FILTER_FROM_TRACEBACK` to True.""" name = module_name while name: module = sys.modules.get(name) - if module is not None and getattr(module, INTERNAL_MARKER, False): + if module is not None and getattr(module, FILTER_FROM_TRACEBACK, False): return True if "." not in name: break @@ -107,21 +108,21 @@ def _module_or_ancestor_is_flagged(module_name: str) -> bool: return False -def _is_third_party_internal_module(module_name: str) -> bool: +def _should_filter_third_party_module(module_name: str) -> bool: """Return True if ``module_name`` is, or is nested under, one of the - third-party modules listed in :data:`_THIRD_PARTY_INTERNAL_MODULES`.""" + third-party modules listed in :data:`_THIRD_PARTY_MODULES_TO_FILTER`.""" return any( module_name == ext or module_name.startswith(ext + ".") - for ext in _THIRD_PARTY_INTERNAL_MODULES + for ext in _THIRD_PARTY_MODULES_TO_FILTER ) -def _is_internal_frame(frame: FrameType) -> bool: +def _should_filter_from_traceback(frame: FrameType) -> bool: """Return True if ``frame`` comes from AutoQASM internals.""" module_name = frame.f_globals.get("__name__", "") if _module_or_ancestor_is_flagged(module_name): return True - if _is_third_party_internal_module(module_name): + if _should_filter_third_party_module(module_name): return True basename = os.path.basename(frame.f_code.co_filename) return basename.startswith(_AUTOGRAPH_GENERATED_PREFIX) @@ -151,7 +152,7 @@ def filter_traceback(exc: BaseException) -> BaseException: kept: list[TracebackType] = [] cursor: TracebackType | None = tb while cursor is not None: - if not _is_internal_frame(cursor.tb_frame): + if not _should_filter_from_traceback(cursor.tb_frame): kept.append(cursor) cursor = cursor.tb_next @@ -167,4 +168,4 @@ def filter_traceback(exc: BaseException) -> BaseException: return exc -__autoqasm_internal__ = True +__filter_from_traceback__ = True diff --git a/src/autoqasm/api.py b/src/autoqasm/api.py index 92aa8899..0e2f5f2f 100644 --- a/src/autoqasm/api.py +++ b/src/autoqasm/api.py @@ -15,7 +15,7 @@ from __future__ import annotations -__autoqasm_internal__ = True +__filter_from_traceback__ = True import copy import functools diff --git a/src/autoqasm/converters/__init__.py b/src/autoqasm/converters/__init__.py index 3a6365fa..0e712aeb 100644 --- a/src/autoqasm/converters/__init__.py +++ b/src/autoqasm/converters/__init__.py @@ -18,4 +18,4 @@ overloads or adds on top of AutoGraph. """ -__autoqasm_internal__ = True +__filter_from_traceback__ = True diff --git a/src/autoqasm/operators/__init__.py b/src/autoqasm/operators/__init__.py index 8ba5a1cd..e01dd13b 100644 --- a/src/autoqasm/operators/__init__.py +++ b/src/autoqasm/operators/__init__.py @@ -17,7 +17,7 @@ This module implements operators that AutoQASM overloads or adds on top of AutoGraph. """ -__autoqasm_internal__ = True +__filter_from_traceback__ = True # Operators below are imported directly from core autograph implementation from malt.impl.api import autograph_artifact # noqa: F401 diff --git a/src/autoqasm/program/program.py b/src/autoqasm/program/program.py index b255a58f..a416c865 100644 --- a/src/autoqasm/program/program.py +++ b/src/autoqasm/program/program.py @@ -15,7 +15,7 @@ from __future__ import annotations -__autoqasm_internal__ = True +__filter_from_traceback__ = True import contextlib import copy diff --git a/src/autoqasm/transpiler/__init__.py b/src/autoqasm/transpiler/__init__.py index 095b80dc..1517e17b 100644 --- a/src/autoqasm/transpiler/__init__.py +++ b/src/autoqasm/transpiler/__init__.py @@ -14,6 +14,6 @@ """This module implements the AutoQASM transpiler which uses autograph to convert a decorated Python function to an oqpy program.""" -__autoqasm_internal__ = True +__filter_from_traceback__ = True from .transpiler import PyToOqpy, converted_call # noqa: F401 diff --git a/test/unit_tests/autoqasm/test_error_ux.py b/test/unit_tests/autoqasm/test_error_ux.py index bfa24433..60e298aa 100644 --- a/test/unit_tests/autoqasm/test_error_ux.py +++ b/test/unit_tests/autoqasm/test_error_ux.py @@ -45,13 +45,13 @@ def _build_dynamic_program(name: str = "p") -> object: def _frame_is_internal(frame) -> bool: - """Independent reimplementation of ``_frame_filtering._is_internal_frame``. + """Independent reimplementation of ``_frame_filtering._should_filter_from_traceback``. Kept separate so that a bug in the production classifier cannot silently mask itself in tests. """ f_globals = frame.f_globals - if f_globals.get(_frame_filtering.INTERNAL_MARKER, False): + if f_globals.get(_frame_filtering.FILTER_FROM_TRACEBACK, False): return True module_name = f_globals.get("__name__", "") return module_name == "malt" or module_name.startswith("malt.") @@ -197,12 +197,12 @@ def test_real_user_nameerror_filter() -> None: def test_new_internal_module_is_hidden(monkeypatch: pytest.MonkeyPatch) -> None: - """A future AutoQASM module that opts in via ``__autoqasm_internal__`` + """A future AutoQASM module that opts in via ``__filter_from_traceback__`` has its frames hidden without any change to the filter.""" import types as _types fake = _types.ModuleType("autoqasm._test_fake_internal") - fake.__dict__[_frame_filtering.INTERNAL_MARKER] = True + fake.__dict__[_frame_filtering.FILTER_FROM_TRACEBACK] = True # Compile _raiser in the fake module's namespace so the frame's module is the fake one. exec( # noqa: S102