Skip to content
1 change: 1 addition & 0 deletions src/autoqasm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
171 changes: 171 additions & 0 deletions src/autoqasm/_frame_filtering.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions src/autoqasm/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

from __future__ import annotations

__filter_from_traceback__ = True

import copy
import functools
import inspect
Expand Down
2 changes: 2 additions & 0 deletions src/autoqasm/converters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
44 changes: 44 additions & 0 deletions src/autoqasm/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
2 changes: 2 additions & 0 deletions src/autoqasm/operators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 22 additions & 11 deletions src/autoqasm/program/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

from __future__ import annotations

__filter_from_traceback__ = True

import contextlib
import copy
import threading
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
2 changes: 2 additions & 0 deletions src/autoqasm/transpiler/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 17 additions & 1 deletion src/autoqasm/transpiler/transpiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading