From 59fb788eac0e71284a39eafa70461052eaa28acc Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Wed, 11 Mar 2026 20:04:35 -0700 Subject: [PATCH 1/5] [Fix] Add eval_str=True to inspect.signature() calls for stringified annotations Support `from __future__ import annotations` by resolving stringified type annotations at signature inspection time. Catch NameError from invalid string annotations and raise a clear QuadrantsSyntaxError. --- python/quadrants/lang/_func_base.py | 7 +++++-- python/quadrants/lang/_kernel_impl_dataclass.py | 2 +- python/quadrants/lang/_perf_dispatch.py | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/python/quadrants/lang/_func_base.py b/python/quadrants/lang/_func_base.py index dd5cdbac8..f5d334458 100644 --- a/python/quadrants/lang/_func_base.py +++ b/python/quadrants/lang/_func_base.py @@ -97,7 +97,10 @@ def check_parameter_annotations(self) -> None: Note: NOT in the hot path. Just run once, on function registration """ - sig = inspect.signature(self.func) + try: + sig = inspect.signature(self.func, eval_str=True) + except NameError as e: + raise QuadrantsSyntaxError(f"Invalid type annotation of Taichi kernel: {e}") from e if hasattr(self.func, "__wrapped__"): raise_exception( QuadrantsSyntaxError, @@ -189,7 +192,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 = inspect.signature(fn, eval_str=True).parameters for i, (parameter_name, parameter) in enumerate(parameters.items()): if is_dataclass(parameter.annotation): _kernel_impl_dataclass.populate_global_vars_from_dataclass( diff --git a/python/quadrants/lang/_kernel_impl_dataclass.py b/python/quadrants/lang/_kernel_impl_dataclass.py index c5d7bd530..531878249 100644 --- a/python/quadrants/lang/_kernel_impl_dataclass.py +++ b/python/quadrants/lang/_kernel_impl_dataclass.py @@ -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 = inspect.signature(ctx.func.func, eval_str=True) parameters = sig.parameters for param_name, parameter in parameters.items(): if dataclasses.is_dataclass(parameter.annotation): diff --git a/python/quadrants/lang/_perf_dispatch.py b/python/quadrants/lang/_perf_dispatch.py index 4bc21844a..ec29bc136 100644 --- a/python/quadrants/lang/_perf_dispatch.py +++ b/python/quadrants/lang/_perf_dispatch.py @@ -58,7 +58,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 = inspect.signature(fn, eval_str=True) self._param_types: dict[str, Any] = {} for param_name, param in sig.parameters.items(): self._param_types[param_name] = param.annotation From 544bbc981bda671d43c50f73801511574cb52892 Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Wed, 11 Mar 2026 21:31:46 -0700 Subject: [PATCH 2/5] [Fix] Add eval_str=True to perf_dispatch register decorator The register decorator's inspect.signature() call was missing eval_str=True, inconsistent with all other call sites. --- python/quadrants/lang/_perf_dispatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/quadrants/lang/_perf_dispatch.py b/python/quadrants/lang/_perf_dispatch.py index ec29bc136..feb1f83fa 100644 --- a/python/quadrants/lang/_perf_dispatch.py +++ b/python/quadrants/lang/_perf_dispatch.py @@ -99,7 +99,7 @@ def register( dispatch_impl_set = self._dispatch_impl_set def decorator(func: Callable | QuadrantsCallable) -> DispatchImpl: - sig = inspect.signature(func) + sig = inspect.signature(func, eval_str=True) log_str = f"perf_dispatch registering {func.__name__}" # type: ignore _logging.debug(log_str) if QD_PERFDISPATCH_PRINT_DEBUG: From 77fccda38e5b8b82d3322527341486eaddb21e55 Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Wed, 11 Mar 2026 21:32:56 -0700 Subject: [PATCH 3/5] [Fix] Add consistent error handling at all eval_str=True call sites Wrap all inspect.signature(eval_str=True) calls with try/except that catches both NameError and AttributeError, re-raising as QuadrantsSyntaxError for clear user-facing errors. Previously only check_parameter_annotations had error handling and it only caught NameError. --- python/quadrants/lang/_func_base.py | 7 +++++-- python/quadrants/lang/_kernel_impl_dataclass.py | 7 ++++++- python/quadrants/lang/_perf_dispatch.py | 10 ++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/python/quadrants/lang/_func_base.py b/python/quadrants/lang/_func_base.py index f5d334458..16f33b66d 100644 --- a/python/quadrants/lang/_func_base.py +++ b/python/quadrants/lang/_func_base.py @@ -99,7 +99,7 @@ def check_parameter_annotations(self) -> None: """ try: sig = inspect.signature(self.func, eval_str=True) - except NameError as e: + except (NameError, AttributeError) as e: raise QuadrantsSyntaxError(f"Invalid type annotation of Taichi kernel: {e}") from e if hasattr(self.func, "__wrapped__"): raise_exception( @@ -192,7 +192,10 @@ 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, eval_str=True).parameters + try: + parameters = inspect.signature(fn, eval_str=True).parameters + except (NameError, AttributeError) as e: + raise QuadrantsSyntaxError(f"Invalid type annotation of Taichi kernel: {e}") from e for i, (parameter_name, parameter) in enumerate(parameters.items()): if is_dataclass(parameter.annotation): _kernel_impl_dataclass.populate_global_vars_from_dataclass( diff --git a/python/quadrants/lang/_kernel_impl_dataclass.py b/python/quadrants/lang/_kernel_impl_dataclass.py index 531878249..f220e03c3 100644 --- a/python/quadrants/lang/_kernel_impl_dataclass.py +++ b/python/quadrants/lang/_kernel_impl_dataclass.py @@ -73,7 +73,12 @@ 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, eval_str=True) + try: + sig = inspect.signature(ctx.func.func, eval_str=True) + except (NameError, AttributeError) as e: + from quadrants.lang.exception import QuadrantsSyntaxError + + raise QuadrantsSyntaxError(f"Invalid type annotation of Taichi kernel: {e}") from e parameters = sig.parameters for param_name, parameter in parameters.items(): if dataclasses.is_dataclass(parameter.annotation): diff --git a/python/quadrants/lang/_perf_dispatch.py b/python/quadrants/lang/_perf_dispatch.py index feb1f83fa..cf99b9763 100644 --- a/python/quadrants/lang/_perf_dispatch.py +++ b/python/quadrants/lang/_perf_dispatch.py @@ -58,7 +58,10 @@ 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, eval_str=True) + try: + sig = inspect.signature(fn, eval_str=True) + except (NameError, AttributeError) as e: + raise QuadrantsSyntaxError(f"Invalid type annotation: {e}") from e self._param_types: dict[str, Any] = {} for param_name, param in sig.parameters.items(): self._param_types[param_name] = param.annotation @@ -99,7 +102,10 @@ def register( dispatch_impl_set = self._dispatch_impl_set def decorator(func: Callable | QuadrantsCallable) -> DispatchImpl: - sig = inspect.signature(func, eval_str=True) + try: + sig = inspect.signature(func, eval_str=True) + except (NameError, AttributeError) as e: + raise QuadrantsSyntaxError(f"Invalid type annotation: {e}") from e log_str = f"perf_dispatch registering {func.__name__}" # type: ignore _logging.debug(log_str) if QD_PERFDISPATCH_PRINT_DEBUG: From 849fe2dae24ab8f7fbae1341c86f780b06c60259 Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Wed, 11 Mar 2026 21:37:02 -0700 Subject: [PATCH 4/5] [Refactor] Extract get_func_signature helper for eval_str=True calls Centralize the inspect.signature(eval_str=True) + error handling pattern into a shared get_func_signature() helper in exception.py, replacing 5 inline try/except blocks across _func_base.py, _kernel_impl_dataclass.py, and _perf_dispatch.py. --- python/quadrants/lang/_func_base.py | 11 +++-------- python/quadrants/lang/_kernel_impl_dataclass.py | 8 ++------ python/quadrants/lang/_perf_dispatch.py | 13 +++---------- python/quadrants/lang/exception.py | 10 ++++++++++ 4 files changed, 18 insertions(+), 24 deletions(-) diff --git a/python/quadrants/lang/_func_base.py b/python/quadrants/lang/_func_base.py index 16f33b66d..078e921f5 100644 --- a/python/quadrants/lang/_func_base.py +++ b/python/quadrants/lang/_func_base.py @@ -29,6 +29,7 @@ QuadrantsRuntimeError, QuadrantsRuntimeTypeError, QuadrantsSyntaxError, + get_func_signature, ) from quadrants.lang.kernel_arguments import ArgMetadata from quadrants.lang.matrix import MatrixType @@ -97,10 +98,7 @@ def check_parameter_annotations(self) -> None: Note: NOT in the hot path. Just run once, on function registration """ - try: - sig = inspect.signature(self.func, eval_str=True) - except (NameError, AttributeError) as e: - raise QuadrantsSyntaxError(f"Invalid type annotation of Taichi kernel: {e}") from e + sig = get_func_signature(self.func) if hasattr(self.func, "__wrapped__"): raise_exception( QuadrantsSyntaxError, @@ -192,10 +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] - try: - parameters = inspect.signature(fn, eval_str=True).parameters - except (NameError, AttributeError) as e: - raise QuadrantsSyntaxError(f"Invalid type annotation of Taichi kernel: {e}") from e + 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( diff --git a/python/quadrants/lang/_kernel_impl_dataclass.py b/python/quadrants/lang/_kernel_impl_dataclass.py index f220e03c3..ebd298dff 100644 --- a/python/quadrants/lang/_kernel_impl_dataclass.py +++ b/python/quadrants/lang/_kernel_impl_dataclass.py @@ -1,6 +1,5 @@ import ast import dataclasses -import inspect from typing import Any from quadrants.lang import util @@ -73,12 +72,9 @@ def extract_struct_locals_from_context(ctx: ASTTransformerFuncContext) -> set[st """ struct_locals = set() assert ctx.func is not None - try: - sig = inspect.signature(ctx.func.func, eval_str=True) - except (NameError, AttributeError) as e: - from quadrants.lang.exception import QuadrantsSyntaxError + from quadrants.lang.exception import get_func_signature - raise QuadrantsSyntaxError(f"Invalid type annotation of Taichi kernel: {e}") from e + sig = get_func_signature(ctx.func.func) parameters = sig.parameters for param_name, parameter in parameters.items(): if dataclasses.is_dataclass(parameter.annotation): diff --git a/python/quadrants/lang/_perf_dispatch.py b/python/quadrants/lang/_perf_dispatch.py index cf99b9763..a1999199d 100644 --- a/python/quadrants/lang/_perf_dispatch.py +++ b/python/quadrants/lang/_perf_dispatch.py @@ -1,4 +1,3 @@ -import inspect import os import time from collections import defaultdict @@ -8,7 +7,7 @@ from . import impl from ._exceptions import raise_exception from ._quadrants_callable import QuadrantsCallable -from .exception import QuadrantsRuntimeError, QuadrantsSyntaxError +from .exception import QuadrantsRuntimeError, QuadrantsSyntaxError, get_func_signature NUM_WARMUP: int = 3 NUM_ACTIVE: int = 1 @@ -58,10 +57,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 - try: - sig = inspect.signature(fn, eval_str=True) - except (NameError, AttributeError) as e: - raise QuadrantsSyntaxError(f"Invalid type annotation: {e}") from e + 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 @@ -102,10 +98,7 @@ def register( dispatch_impl_set = self._dispatch_impl_set def decorator(func: Callable | QuadrantsCallable) -> DispatchImpl: - try: - sig = inspect.signature(func, eval_str=True) - except (NameError, AttributeError) as e: - raise QuadrantsSyntaxError(f"Invalid type annotation: {e}") from e + sig = get_func_signature(func) log_str = f"perf_dispatch registering {func.__name__}" # type: ignore _logging.debug(log_str) if QD_PERFDISPATCH_PRINT_DEBUG: diff --git a/python/quadrants/lang/exception.py b/python/quadrants/lang/exception.py index 771dd56b0..beaf2eeb0 100644 --- a/python/quadrants/lang/exception.py +++ b/python/quadrants/lang/exception.py @@ -57,6 +57,16 @@ def get_ret(needed, provided): return QuadrantsRuntimeTypeError(f"Return (type={provided}) cannot be converted into required type {needed}") +def get_func_signature(func): + """Call inspect.signature with eval_str=True, converting annotation errors to QuadrantsSyntaxError.""" + import inspect + + try: + return inspect.signature(func, eval_str=True) + except (NameError, AttributeError) as e: + raise QuadrantsSyntaxError(f"Invalid type annotation of Taichi kernel: {e}") from e + + def handle_exception_from_cpp(exc): if isinstance(exc, core.QuadrantsTypeError): return QuadrantsTypeError(str(exc)) From 46db1c700737e6f7d8cacc373f897395595097c5 Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Wed, 11 Mar 2026 21:37:39 -0700 Subject: [PATCH 5/5] [Test] Add test for kernels with from __future__ import annotations Verify that kernel parameter annotations are correctly resolved when the module uses PEP 563 stringified annotations. --- tests/python/test_future_annotations.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/python/test_future_annotations.py diff --git a/tests/python/test_future_annotations.py b/tests/python/test_future_annotations.py new file mode 100644 index 000000000..c359679f7 --- /dev/null +++ b/tests/python/test_future_annotations.py @@ -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