Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions python/quadrants/lang/_func_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from quadrants.lang import _kernel_impl_dataclass, impl
from quadrants.lang._dataclass_util import create_flat_name
from quadrants.lang._ndarray import Ndarray
from quadrants.lang._signature import get_func_signature
from quadrants.lang._wrap_inspect import get_source_info_and_src
from quadrants.lang.ast import ASTTransformerFuncContext
from quadrants.lang.exception import (
Expand Down Expand Up @@ -97,7 +98,7 @@ def check_parameter_annotations(self) -> None:

Note: NOT in the hot path. Just run once, on function registration
"""
sig = inspect.signature(self.func)
sig = get_func_signature(self.func)
if hasattr(self.func, "__wrapped__"):
raise_exception(
QuadrantsSyntaxError,
Expand Down Expand Up @@ -189,7 +190,7 @@ def _populate_global_vars_for_templates(
for i in template_slot_locations:
template_var_name = argument_metas[i].name
global_vars[template_var_name] = py_args[i]
parameters = inspect.signature(fn).parameters
parameters = get_func_signature(fn).parameters
for i, (parameter_name, parameter) in enumerate(parameters.items()):
if is_dataclass(parameter.annotation):
_kernel_impl_dataclass.populate_global_vars_from_dataclass(
Expand Down
4 changes: 2 additions & 2 deletions python/quadrants/lang/_kernel_impl_dataclass.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import ast
import dataclasses
import inspect
from typing import Any

from quadrants.lang import util
from quadrants.lang._dataclass_util import create_flat_name
from quadrants.lang._signature import get_func_signature
from quadrants.lang.ast import (
ASTTransformerFuncContext,
)
Expand Down Expand Up @@ -73,7 +73,7 @@ def extract_struct_locals_from_context(ctx: ASTTransformerFuncContext) -> set[st
"""
struct_locals = set()
assert ctx.func is not None
sig = inspect.signature(ctx.func.func)
sig = get_func_signature(ctx.func.func)
parameters = sig.parameters
for param_name, parameter in parameters.items():
if dataclasses.is_dataclass(parameter.annotation):
Expand Down
6 changes: 3 additions & 3 deletions python/quadrants/lang/_perf_dispatch.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import inspect
import os
import time
from collections import defaultdict
Expand All @@ -8,6 +7,7 @@
from . import impl
from ._exceptions import raise_exception
from ._quadrants_callable import QuadrantsCallable
from ._signature import get_func_signature
from .exception import QuadrantsRuntimeError, QuadrantsSyntaxError

NUM_FIRST_WARMUP: int = 1
Expand Down Expand Up @@ -84,7 +84,7 @@ def __init__(
self.num_active = num_active if num_active is not None else NUM_ACTIVE
self.repeat_after_count = repeat_after_count if repeat_after_count is not None else REPEAT_AFTER_COUNT
self.repeat_after_seconds = repeat_after_seconds if repeat_after_seconds is not None else REPEAT_AFTER_SECONDS
sig = inspect.signature(fn)
sig = get_func_signature(fn)
self._param_types: dict[str, Any] = {}
for param_name, param in sig.parameters.items():
self._param_types[param_name] = param.annotation
Expand Down Expand Up @@ -130,7 +130,7 @@ def register(
"""

def decorator(func: Callable | QuadrantsCallable) -> DispatchImpl:
sig = inspect.signature(func)
sig = get_func_signature(func)
log_str = f"perf_dispatch '{self._name}': registered '{func.__name__}'" # type: ignore
_logging.debug(log_str)
if QD_PERFDISPATCH_PRINT_DEBUG or _ANY_FORCE_ACTIVE:
Expand Down
29 changes: 29 additions & 0 deletions python/quadrants/lang/_signature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import inspect
from typing import Callable

from quadrants.lang.exception import QuadrantsSyntaxError


def get_func_signature(func: Callable) -> inspect.Signature:
"""Call ``inspect.signature`` with ``eval_str=True``.

``eval_str=True`` resolves stringified annotations (PEP 563 /
``from __future__ import annotations``) to real type objects so downstream
code can introspect them (e.g. ``dataclasses.is_dataclass``).

Annotation-evaluation failures (``NameError`` / ``AttributeError`` for
unresolved references, ``SyntaxError`` for malformed string annotations
such as ``"NDArray["``) are re-raised as :class:`QuadrantsSyntaxError`
with the offending function's qualified name, so users get a
Quadrants-flavored error rather than a raw ``inspect`` traceback.

Note: ``TypeError`` is intentionally not caught here, since
``inspect.signature`` itself raises ``TypeError`` for non-introspectable
objects -- wrapping that as "invalid type annotation" would be
misleading.
"""
try:
return inspect.signature(func, eval_str=True)
except (NameError, AttributeError, SyntaxError) as e:
qualname = getattr(func, "__qualname__", repr(func))
raise QuadrantsSyntaxError(f"Invalid type annotation in `{qualname}`: {e}") from e
Comment on lines +25 to +29
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The except clause in get_func_signature only catches (NameError, AttributeError), but inspect.signature(func, eval_str=True) can also raise SyntaxError for syntactically invalid string annotations (e.g. a: 'NDArray['). Such errors propagate as raw Python tracebacks instead of QuadrantsSyntaxError, violating the docstring's promise to convert annotation-evaluation failures into user-friendly errors. Fix by adding SyntaxError (and optionally TypeError) to the except tuple.

Extended reasoning...

What the bug is and how it manifests

The new get_func_signature helper in python/quadrants/lang/_signature.py wraps inspect.signature(func, eval_str=True) to resolve stringified annotations. Its docstring explicitly promises: "Annotation-evaluation failures (NameError, AttributeError) are re-raised as QuadrantsSyntaxError". However, the except clause only covers those two exception types, leaving SyntaxError (and others like TypeError) unhandled.

The specific code path that triggers it

At _signature.py lines 19–23`:

try:
    return inspect.signature(func, eval_str=True)
except (NameError, AttributeError) as e:
    qualname = getattr(func, "__qualname__", repr(func))
    raise QuadrantsSyntaxError(f"Invalid type annotation in `{qualname}`: {e}") from e

When a user writes a kernel with a syntactically broken string annotation (e.g. a: 'NDArray['), Python's annotation evaluation calls eval() on the string, which raises SyntaxError: '[' was never closed. This exception is not in the except tuple and propagates directly to the caller as a raw Python exception.

Why existing code doesn't prevent it

There is no broader try/except in the callers (_func_base.py, _kernel_impl_dataclass.py, _perf_dispatch.py) that would intercept this before it reaches the user. The entire point of the helper was to be that single chokepoint for clean error translation — but it has a gap.

What the impact would be

Any user who writes a kernel or @perf_dispatch function with a syntactically invalid string annotation will receive a raw SyntaxError traceback originating from deep inside inspect rather than a QuadrantsSyntaxError with a readable message. This is a documented contract violation. The PR author themselves acknowledged this as a known weakness in the "Bad / weak points" section of the PR description.

How to fix it

Add SyntaxError (and optionally TypeError) to the except tuple:

except (NameError, AttributeError, SyntaxError, TypeError) as e:

Step-by-step proof

  1. User defines a kernel with an invalid string annotation:
    @qd.kernel
    def bad_kernel(a: 'NDArray['): pass
  2. During kernel registration, FuncBase.__init__ calls self.check_parameter_annotations().
  3. check_parameter_annotations calls get_func_signature(self.func) at _signature.py:19.
  4. inspect.signature(bad_kernel, eval_str=True) internally calls eval("NDArray["), which raises SyntaxError: '[' was never closed.
  5. The except (NameError, AttributeError) clause does NOT match SyntaxError.
  6. The SyntaxError propagates unhandled up the call stack to the user, showing a raw traceback from inside inspect instead of a QuadrantsSyntaxError.

Empirically verified: python3 -c "def f(a: 'NDArray['): pass; import inspect; inspect.signature(f, eval_str=True)" raises SyntaxError, confirming the gap.

25 changes: 25 additions & 0 deletions tests/python/test_future_annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Test that kernels work with `from __future__ import annotations` (PEP 563)."""

from __future__ import annotations

import quadrants as qd

from tests import test_utils


@qd.kernel
def add_kernel(a: qd.types.NDArray[qd.i32, 1], b: qd.types.NDArray[qd.i32, 1]) -> None:
for i in a:
a[i] = a[i] + b[i]


@test_utils.test()
def test_future_annotations_kernel():
a = qd.ndarray(qd.i32, (4,))
b = qd.ndarray(qd.i32, (4,))
for i in range(4):
a[i] = i
b[i] = 10
add_kernel(a, b)
for i in range(4):
assert a[i] == i + 10
Loading