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..e58efe58 --- /dev/null +++ b/src/autoqasm/_frame_filtering.py @@ -0,0 +1,171 @@ +# 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 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 frames are hidden from tracebacks +------------------------------------- + +* A module (or subpackage) that should be hidden sets + ``__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_MODULES_TO_FILTER` — + 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 +import sys +from types import FrameType, TracebackType + +__filter_from_traceback__ = True + +FILTER_FROM_TRACEBACK: str = "__filter_from_traceback__" + +VERBOSE_ERRORS_ENV_VAR: str = "AUTOQASM_VERBOSE_ERRORS" + +_THIRD_PARTY_MODULES_TO_FILTER: tuple[str, ...] = ("malt",) + +_AUTOGRAPH_GENERATED_PREFIX: str = "__autograph_generated_file" + +_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 _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:`FILTER_FROM_TRACEBACK` to True.""" + name = module_name + while name: + module = sys.modules.get(name) + if module is not None and getattr(module, FILTER_FROM_TRACEBACK, False): + return True + if "." not in name: + break + name = name.rsplit(".", 1)[0] + return False + + +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_MODULES_TO_FILTER`.""" + return any( + module_name == ext or module_name.startswith(ext + ".") + for ext in _THIRD_PARTY_MODULES_TO_FILTER + ) + + +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 _should_filter_third_party_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 + 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. + + Returns: + BaseException: The same exception with its ``__traceback__`` rewritten. + """ + if verbose_errors_enabled(): + return exc + + tb = exc.__traceback__ + if tb is None: + return exc + + kept: list[TracebackType] = [] + cursor: TracebackType | None = tb + while cursor is not None: + if not _should_filter_from_traceback(cursor.tb_frame): + kept.append(cursor) + cursor = cursor.tb_next + + new_tb: TracebackType | None = None + for node in reversed(kept): + new_tb = TracebackType( + tb_next=new_tb, + tb_frame=node.tb_frame, + tb_lasti=node.tb_lasti, + tb_lineno=node.tb_lineno, + ) + exc.__traceback__ = new_tb + return exc + + +__filter_from_traceback__ = True diff --git a/src/autoqasm/api.py b/src/autoqasm/api.py index 3f7cf62b..0e2f5f2f 100644 --- a/src/autoqasm/api.py +++ b/src/autoqasm/api.py @@ -15,6 +15,8 @@ from __future__ import annotations +__filter_from_traceback__ = True + import copy import functools import inspect diff --git a/src/autoqasm/converters/__init__.py b/src/autoqasm/converters/__init__.py index 85d6df76..0e712aeb 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. """ + +__filter_from_traceback__ = True 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/operators/__init__.py b/src/autoqasm/operators/__init__.py index 171aa8ba..e01dd13b 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. """ +__filter_from_traceback__ = 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 196602e4..a416c865 100644 --- a/src/autoqasm/program/program.py +++ b/src/autoqasm/program/program.py @@ -15,6 +15,8 @@ from __future__ import annotations +__filter_from_traceback__ = True + import contextlib import copy import threading @@ -31,7 +33,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 +119,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, @@ -916,12 +923,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 @@ -943,10 +949,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/__init__.py b/src/autoqasm/transpiler/__init__.py index cdc44dd8..1517e17b 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.""" +__filter_from_traceback__ = True + from .transpiler import PyToOqpy, converted_call # noqa: F401 diff --git a/src/autoqasm/transpiler/transpiler.py b/src/autoqasm/transpiler/transpiler.py index c9ead5c0..bacf855b 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 # Snapshot malt's AutoGraph verbosity once at import time so the hot path # can skip ``logging.log`` calls (each of which otherwise does an @@ -303,7 +305,21 @@ def _try_convert_actual( program_ctx = converter.ProgramContext(options=options) converted_f = _convert_actual(target_entity, program_ctx) if not _AG_LOGGING_DISABLED and logging.has_verbosity(2): - _log_callargs(converted_f, effective_args, kwargs) # pragma: no cover + _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 if not _AG_LOGGING_DISABLED: logging.log( 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..60e298aa --- /dev/null +++ b/test/unit_tests/autoqasm/test_error_ux.py @@ -0,0 +1,227 @@ +# 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 sys +import traceback +from types import TracebackType + +import pytest + +import autoqasm as aq +from autoqasm import _frame_filtering, errors +from autoqasm.instructions import h, measure + + +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._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.FILTER_FROM_TRACEBACK, 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`` with actionable guidance.""" + 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_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 + + 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: + """``AUTOQASM_VERBOSE_ERRORS`` enables verbose mode when no explicit + override is set.""" + _frame_filtering._verbose_errors_override = None + + 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_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: + program.build() + _assert_no_internal_frames(exc_info.value.__traceback__) + + +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: + 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_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: + program.build() + frames = traceback.extract_tb(exc_info.value.__traceback__) + 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: + """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: + program.build() + + msg = str(exc_info.value) + assert "dynamic_program" in msg + assert "interactive Python session" in msg + assert exc_info.value.__cause__ is not None + + +def test_filter_traceback_with_no_traceback_is_noop() -> None: + """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) + assert result is exc + 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 + 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__) + + +def test_new_internal_module_is_hidden(monkeypatch: pytest.MonkeyPatch) -> None: + """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.FILTER_FROM_TRACEBACK] = 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") 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() 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)