From bfb1dd05e79eb2d687d4ce06ada35624f67eb244 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 7 Apr 2026 15:35:00 +0100 Subject: [PATCH 01/22] feat: add Guppy type aliases This PR adds first-class Guppy type aliases via `guppy.type_alias(...)`. It introduces: - alias definitions in the type-definition pipeline - alias resolution during type parsing and instantiation Recursive and mutually recursive aliases are rejected. The recursion check follows the same general strategy already used for recursive structs/enums: - temporarily intercept alias instantiation while parsing the alias body - detect recursive re-entry instead of recursing forever - raise a compiler error For aliases, this now produces alias-specific diagnostics with: - a dedicated `RecursiveTypeAliasError` - notes pointing at the alias definitions involved in the cycle - a help hint suggesting how to break it Authored with OpenAI Codex. Closes #1066 --- .../guppylang_internals/definition/alias.py | 204 ++++++++++++++++++ guppylang/src/guppylang/decorator.py | 15 ++ tests/error/alias_errors/__init__.py | 1 + tests/error/alias_errors/mutual_recursive.err | 20 ++ tests/error/alias_errors/mutual_recursive.py | 13 ++ tests/error/alias_errors/recursive.err | 17 ++ tests/error/alias_errors/recursive.py | 12 ++ tests/error/test_alias_errors.py | 26 +++ tests/integration/test_type_alias.py | 70 ++++++ 9 files changed, 378 insertions(+) create mode 100644 guppylang-internals/src/guppylang_internals/definition/alias.py create mode 100644 tests/error/alias_errors/__init__.py create mode 100644 tests/error/alias_errors/mutual_recursive.err create mode 100644 tests/error/alias_errors/mutual_recursive.py create mode 100644 tests/error/alias_errors/recursive.err create mode 100644 tests/error/alias_errors/recursive.py create mode 100644 tests/error/test_alias_errors.py create mode 100644 tests/integration/test_type_alias.py diff --git a/guppylang-internals/src/guppylang_internals/definition/alias.py b/guppylang-internals/src/guppylang_internals/definition/alias.py new file mode 100644 index 000000000..c2acfd9c6 --- /dev/null +++ b/guppylang-internals/src/guppylang_internals/definition/alias.py @@ -0,0 +1,204 @@ +import ast +from collections.abc import Callable, Iterator, Sequence +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import ClassVar + +from guppylang_internals.ast_util import AstNode +from guppylang_internals.checker.core import Globals +from guppylang_internals.definition.common import ( + CheckableDef, + CompiledDef, + ParsableDef, +) +from guppylang_internals.definition.ty import TypeDef +from guppylang_internals.diagnostic import Error, Help, Note +from guppylang_internals.engine import DEF_STORE +from guppylang_internals.error import GuppyError, InternalGuppyError +from guppylang_internals.span import SourceMap +from guppylang_internals.tys.arg import Argument +from guppylang_internals.tys.param import Parameter, check_all_args +from guppylang_internals.tys.parsing import TypeParsingCtx, type_from_ast +from guppylang_internals.tys.subst import Instantiator +from guppylang_internals.tys.ty import Type + +_active_alias_checks: ContextVar[tuple["ParsedTypeAliasDef", ...]] = ContextVar( + "_active_alias_checks", default=() +) + + +@dataclass(frozen=True) +class RecursiveTypeAliasError(Error): + title: ClassVar[str] = "Recursive type alias" + cycle: tuple[str, ...] + + @property + def rendered_span_label(self) -> str: + if len(self.cycle) == 2 and self.cycle[0] == self.cycle[1]: + return f"Type alias `{self.cycle[0]}` expands to itself" + return "Type alias cycle detected:\n" + " -> ".join( + f"`{alias}`" for alias in self.cycle + ) + + @dataclass(frozen=True) + class AliasNote(Note): + alias_name: str + span_label: ClassVar[str] = "Alias `{alias_name}` is part of this cycle" + + @dataclass(frozen=True) + class Fix(Help): + message: ClassVar[str] = ( + "Type aliases must eventually resolve to a non-alias type. Break the " + "cycle by inlining one alias or introducing a struct or enum wrapper." + ) + + def __post_init__(self) -> None: + self.add_sub_diagnostic(RecursiveTypeAliasError.Fix(None)) + + +@dataclass(frozen=True) +class RawTypeAliasDef(TypeDef, ParsableDef): + """A raw type alias definition that has not been parsed yet.""" + + type_ast: ast.expr + params: None = field(default=None, init=False) + description: str = field(default="type alias", init=False) + + def parse(self, globals: Globals, sources: SourceMap) -> "ParsedTypeAliasDef": + return ParsedTypeAliasDef( + self.id, + self.name, + self.defined_at, + None, + self.type_ast, + ) + + def check_instantiate( + self, args: Sequence[Argument], loc: AstNode | None = None + ) -> Type: + raise InternalGuppyError("Tried to instantiate raw type alias definition") + + +@dataclass(frozen=True) +class ParsedTypeAliasDef(TypeDef, CheckableDef): + """A type alias definition whose target type has not been checked yet.""" + + params: Sequence[Parameter] | None + type_ast: ast.expr + description: str = field(default="type alias", init=False) + + def check(self, globals: Globals) -> "CheckedTypeAliasDef": + recursion_ctx = TypeParsingCtx(globals, allow_free_vars=True) + check_not_recursive(self, recursion_ctx) + + ctx = TypeParsingCtx(globals, allow_free_vars=True) + ty = type_from_ast(self.type_ast, ctx) + params = tuple(ctx.param_var_mapping.values()) + return CheckedTypeAliasDef( + self.id, + self.name, + self.defined_at, + params, + ty, + ) + + def check_instantiate( + self, args: Sequence[Argument], loc: AstNode | None = None + ) -> Type: + globals = Globals(DEF_STORE.frames[self.id]) + checked_def = self.check(globals) + return checked_def.check_instantiate(args, loc) + + +@dataclass(frozen=True) +class CheckedTypeAliasDef(TypeDef, CompiledDef): + """A fully checked type alias definition.""" + + params: Sequence[Parameter] + ty: Type + description: str = field(default="type alias", init=False) + + def check_instantiate( + self, args: Sequence[Argument], loc: AstNode | None = None + ) -> Type: + check_all_args(self.params, args, self.name, loc) + return self.ty.transform(Instantiator(args)) + + +@contextmanager +def _patched_check_instantiate( + defn: ParsedTypeAliasDef, + replacement: Callable[[Sequence[Argument], AstNode | None], Type], +) -> Iterator[None]: + """Temporarily override `check_instantiate` for recursive-alias detection.""" + original = defn.check_instantiate + object.__setattr__(defn, "check_instantiate", replacement) + try: + yield + finally: + object.__setattr__(defn, "check_instantiate", original) + + +def check_not_recursive(defn: ParsedTypeAliasDef, ctx: TypeParsingCtx) -> None: + """Throws a user error if the given type alias is recursive. + + We do not have a separate alias-expansion pass, so we detect recursion by + temporarily swapping out this alias's `check_instantiate` method while parsing its + target type. If parsing the alias body reaches this same alias again, the patched + method fires and turns that recursive re-entry into a user-facing cycle diagnostic. + """ + token = _active_alias_checks.set((*_active_alias_checks.get(), defn)) + + def dummy_check_instantiate( + args: Sequence[Argument], + loc: AstNode | None = None, + ) -> Type: + active = _active_alias_checks.get() + start = next( + i for i, active_defn in enumerate(active) if active_defn.id == defn.id + ) + cycle_defs = (*active[start:], defn) + cycle = tuple( + _alias_name(active_defn, ctx.globals) for active_defn in cycle_defs + ) + err = RecursiveTypeAliasError(loc, cycle) + _add_alias_note(err, defn, ctx.globals) + raise GuppyError(err) + + try: + with _patched_check_instantiate(defn, dummy_check_instantiate): + type_from_ast(defn.type_ast, ctx) + except GuppyError as err: + if isinstance(err.error, RecursiveTypeAliasError): + _add_alias_note(err.error, defn, ctx.globals) + raise + finally: + _active_alias_checks.reset(token) + + +def _add_alias_note( + err: RecursiveTypeAliasError, defn: ParsedTypeAliasDef, globals: Globals +) -> None: + alias_name = _alias_name(defn, globals) + # The same recursive error is re-raised while unwinding through each alias in the + # cycle, so avoid attaching the same note more than once. + if any( + isinstance(child, RecursiveTypeAliasError.AliasNote) + and child.alias_name == alias_name + for child in err.children + ): + return + err.add_sub_diagnostic( + RecursiveTypeAliasError.AliasNote(defn.defined_at, alias_name) + ) + + +def _alias_name(defn: ParsedTypeAliasDef, globals: Globals) -> str: + from guppylang.defs import GuppyDefinition + + for namespace in (globals.f_locals, globals.f_globals): + for name, value in namespace.items(): + if isinstance(value, GuppyDefinition) and value.id == defn.id: + return name + return defn.name diff --git a/guppylang/src/guppylang/decorator.py b/guppylang/src/guppylang/decorator.py index 89a08578d..de5ad573b 100644 --- a/guppylang/src/guppylang/decorator.py +++ b/guppylang/src/guppylang/decorator.py @@ -6,6 +6,7 @@ from typing import Any, NamedTuple, ParamSpec, TypedDict, TypeVar, cast, overload from guppylang_internals.ast_util import annotate_location +from guppylang_internals.definition.alias import RawTypeAliasDef from guppylang_internals.definition.common import DefId from guppylang_internals.definition.const import RawConstDef from guppylang_internals.definition.custom import RawCustomFunctionDef @@ -365,6 +366,20 @@ def const_var(self, name: str, ty: str) -> TypeVar: # `GuppyDefinition` that pretends to be a TypeVar at runtime return GuppyTypeVarDefinition(defn, TypeVar(name)) # type: ignore[return-value] + def type_alias(self, ty: str) -> Any: + """Creates a new type alias.""" + type_ast = _parse_expr_string( + ty, f"Not a valid Guppy type: `{ty}`", DEF_STORE.sources + ) + defn = RawTypeAliasDef( + DefId.fresh(), + ty, + type_ast, + type_ast, + ) + DEF_STORE.register_def(defn, get_calling_frame()) + return GuppyDefinition(defn) + @overload def declare( self, /, **kwargs: Unpack[GuppyKwargs] diff --git a/tests/error/alias_errors/__init__.py b/tests/error/alias_errors/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/error/alias_errors/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/error/alias_errors/mutual_recursive.err b/tests/error/alias_errors/mutual_recursive.err new file mode 100644 index 000000000..90cfef863 --- /dev/null +++ b/tests/error/alias_errors/mutual_recursive.err @@ -0,0 +1,20 @@ +Error: Recursive type alias (at $FILE:5:0) + | +3 | +4 | Alias1 = guppy.type_alias("Alias2") +5 | Alias2 = guppy.type_alias("Alias1") + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type alias cycle detected: + | `Alias1` -> `Alias2` -> `Alias1` + +Notes: + | +3 | +4 | Alias1 = guppy.type_alias("Alias2") + | ----------------------------------- Alias `Alias1` is part of this cycle +5 | Alias2 = guppy.type_alias("Alias1") + | ----------------------------------- Alias `Alias2` is part of this cycle + +Help: Type aliases must eventually resolve to a non-alias type. Break the cycle +by inlining one alias or introducing a struct or enum wrapper. + +Guppy compilation failed due to 1 previous error diff --git a/tests/error/alias_errors/mutual_recursive.py b/tests/error/alias_errors/mutual_recursive.py new file mode 100644 index 000000000..c0b401feb --- /dev/null +++ b/tests/error/alias_errors/mutual_recursive.py @@ -0,0 +1,13 @@ +from guppylang import guppy + + +Alias1 = guppy.type_alias("Alias2") +Alias2 = guppy.type_alias("Alias1") + + +@guppy +def main(x: Alias1) -> Alias2: + return x + + +main.compile_function() diff --git a/tests/error/alias_errors/recursive.err b/tests/error/alias_errors/recursive.err new file mode 100644 index 000000000..aae44e0c5 --- /dev/null +++ b/tests/error/alias_errors/recursive.err @@ -0,0 +1,17 @@ +Error: Recursive type alias (at $FILE:4:0) + | +2 | +3 | +4 | MyAlias = guppy.type_alias("MyAlias") + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type alias `MyAlias` expands to itself + +Note: + | +3 | +4 | MyAlias = guppy.type_alias("MyAlias") + | ------------------------------------- Alias `MyAlias` is part of this cycle + +Help: Type aliases must eventually resolve to a non-alias type. Break the cycle +by inlining one alias or introducing a struct or enum wrapper. + +Guppy compilation failed due to 1 previous error diff --git a/tests/error/alias_errors/recursive.py b/tests/error/alias_errors/recursive.py new file mode 100644 index 000000000..ccdee3606 --- /dev/null +++ b/tests/error/alias_errors/recursive.py @@ -0,0 +1,12 @@ +from guppylang import guppy + + +MyAlias = guppy.type_alias("MyAlias") + + +@guppy +def main(x: MyAlias) -> MyAlias: + return x + + +main.compile_function() diff --git a/tests/error/test_alias_errors.py b/tests/error/test_alias_errors.py new file mode 100644 index 000000000..5f782d85e --- /dev/null +++ b/tests/error/test_alias_errors.py @@ -0,0 +1,26 @@ +import pathlib + +import pytest +from guppylang import guppy + +from tests.error.util import run_error_test + +path = pathlib.Path(__file__).parent.resolve() / "alias_errors" +files = [ + x + for x in path.iterdir() + if x.is_file() and x.suffix == ".py" and x.name != "__init__.py" +] + +# Turn paths into strings, otherwise pytest doesn't display the names +files = [str(f) for f in files] + + +@pytest.mark.parametrize("file", files) +def test_alias_errors(file, capsys, snapshot): + run_error_test(file, capsys, snapshot) + + +def test_type_alias_bad_type_syntax(): + with pytest.raises(SyntaxError, match="Not a valid Guppy type: `foo bar`"): + guppy.type_alias("foo bar") diff --git a/tests/integration/test_type_alias.py b/tests/integration/test_type_alias.py new file mode 100644 index 000000000..064a85790 --- /dev/null +++ b/tests/integration/test_type_alias.py @@ -0,0 +1,70 @@ +from typing import Generic + +from guppylang import array, guppy, qubit +from guppylang.std.builtins import owned +from guppylang.std.quantum import discard, measure, x + + +def test_alias_chain(run_int_fn): + """Type aliases can chain through other aliases for scalar types.""" + MyInt = guppy.type_alias("int") + MyOtherInt = guppy.type_alias("MyInt") + + @guppy + def main(x: MyOtherInt) -> MyInt: + return x + 1 + + run_int_fn(main, expected=42, args=[41]) + + +def test_array_alias(validate): + """Type aliases can name nested concrete array types.""" + Row = guppy.type_alias("array[int, 2]") + Matrix = guppy.type_alias("array[Row, 2]") + + @guppy + def main(xs: Matrix) -> int: + return xs[0][0] + xs[0][1] + xs[1][0] + xs[1][1] + + validate(main.compile_function()) + + +def test_qubit_array_alias(run_int_fn): + """Type aliases preserve owned linear array semantics for qubits.""" + QubitArray = guppy.type_alias("array[qubit, 2]") + + @guppy + def use_qubits(qs: QubitArray @ owned) -> int: + q1, q2 = qs + discard(q1) + x(q2) + return 1 if measure(q2) else 0 + + @guppy + def main() -> int: + qs: QubitArray = array(qubit(), qubit()) + return use_qubits(qs) + + run_int_fn(main, expected=1, num_qubits=2) + + +def test_generic_struct_alias(run_int_fn): + """Type aliases can refer to concrete instantiations of generic structs.""" + T = guppy.type_var("T") + + @guppy.struct + class Box(Generic[T]): + value: T + + IntBox = guppy.type_alias("Box[int]") + + @guppy + def increment(box: IntBox) -> IntBox: + return Box(box.value + 1) + + @guppy + def main() -> int: + box = increment(Box(41)) + return box.value + + run_int_fn(main, expected=42) From e3be9ac416bcc03bf08a4d903fd183cc6a83acf4 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 16 Jun 2026 13:30:21 +0100 Subject: [PATCH 02/22] fix: Improve cycle detection for recursive type aliases - Use DefId-based deduplication instead of name lookup to avoid false positives when multiple aliases share a name - Attach cycle notes in a single pass inside dummy_check_instantiate rather than re-attaching while unwinding the exception stack - Guard against cross-file or un-annotated spans so that notes are only emitted when the span belongs to the same file as the error - Correct the help message: inlining cannot break a cycle; replace the cyclic aliases with a concrete type instead - Remove the note for self-cycles (A -> A) since the error span label already says 'expands to itself' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../guppylang_internals/definition/alias.py | 88 ++++++++++++------- tests/error/alias_errors/mutual_recursive.err | 4 +- tests/error/alias_errors/recursive.err | 10 +-- 3 files changed, 61 insertions(+), 41 deletions(-) diff --git a/guppylang-internals/src/guppylang_internals/definition/alias.py b/guppylang-internals/src/guppylang_internals/definition/alias.py index c2acfd9c6..874e5538d 100644 --- a/guppylang-internals/src/guppylang_internals/definition/alias.py +++ b/guppylang-internals/src/guppylang_internals/definition/alias.py @@ -10,6 +10,7 @@ from guppylang_internals.definition.common import ( CheckableDef, CompiledDef, + DefId, ParsableDef, ) from guppylang_internals.definition.ty import TypeDef @@ -44,13 +45,14 @@ def rendered_span_label(self) -> str: @dataclass(frozen=True) class AliasNote(Note): alias_name: str + defn_id: DefId span_label: ClassVar[str] = "Alias `{alias_name}` is part of this cycle" @dataclass(frozen=True) class Fix(Help): message: ClassVar[str] = ( - "Type aliases must eventually resolve to a non-alias type. Break the " - "cycle by inlining one alias or introducing a struct or enum wrapper." + "Type aliases cannot be recursive. " + "Replace the cyclic aliases with a concrete type." ) def __post_init__(self) -> None: @@ -147,6 +149,10 @@ def check_not_recursive(defn: ParsedTypeAliasDef, ctx: TypeParsingCtx) -> None: temporarily swapping out this alias's `check_instantiate` method while parsing its target type. If parsing the alias body reaches this same alias again, the patched method fires and turns that recursive re-entry into a user-facing cycle diagnostic. + + All cycle notes are attached at once inside `dummy_check_instantiate` so that only + aliases that are actually part of the cycle receive notes (not outer aliases that + merely lead to a cycle). """ token = _active_alias_checks.set((*_active_alias_checks.get(), defn)) @@ -159,46 +165,66 @@ def dummy_check_instantiate( i for i, active_defn in enumerate(active) if active_defn.id == defn.id ) cycle_defs = (*active[start:], defn) - cycle = tuple( - _alias_name(active_defn, ctx.globals) for active_defn in cycle_defs - ) + cycle = tuple(d.name for d in cycle_defs) err = RecursiveTypeAliasError(loc, cycle) - _add_alias_note(err, defn, ctx.globals) + _add_alias_notes_for_cycle(err, cycle_defs) raise GuppyError(err) try: with _patched_check_instantiate(defn, dummy_check_instantiate): type_from_ast(defn.type_ast, ctx) - except GuppyError as err: - if isinstance(err.error, RecursiveTypeAliasError): - _add_alias_note(err.error, defn, ctx.globals) - raise finally: _active_alias_checks.reset(token) -def _add_alias_note( - err: RecursiveTypeAliasError, defn: ParsedTypeAliasDef, globals: Globals +def _add_alias_notes_for_cycle( + err: RecursiveTypeAliasError, + cycle_defs: tuple["ParsedTypeAliasDef", ...], ) -> None: - alias_name = _alias_name(defn, globals) - # The same recursive error is re-raised while unwinding through each alias in the - # cycle, so avoid attaching the same note more than once. - if any( - isinstance(child, RecursiveTypeAliasError.AliasNote) - and child.alias_name == alias_name - for child in err.children - ): - return - err.add_sub_diagnostic( - RecursiveTypeAliasError.AliasNote(defn.defined_at, alias_name) - ) + """Attach notes for every alias in the cycle in a single pass. + + `cycle_defs` is `(A, B, ..., A)` where the first and last element are identical. + We skip self-cycles (only one unique member) since the span label on the error + already says the alias "expands to itself". + Notes are only emitted when the alias definition has a valid, same-file span — i.e. + when the AST node was annotated with file information by `_parse_expr_string`. + Cross-file or un-annotated spans are silently skipped; the cycle chain in the main + error's span label is still fully informative on its own. + """ + import ast as _ast -def _alias_name(defn: ParsedTypeAliasDef, globals: Globals) -> str: - from guppylang.defs import GuppyDefinition + from guppylang_internals.ast_util import get_file + from guppylang_internals.span import Span - for namespace in (globals.f_locals, globals.f_globals): - for name, value in namespace.items(): - if isinstance(value, GuppyDefinition) and value.id == defn.id: - return name - return defn.name + def _span_file(node: _ast.AST | Span | None) -> str | None: + """Return the filename for either a Span or an annotated AST node.""" + if node is None: + return None + if isinstance(node, Span): + return node.file + return get_file(node) + + unique_defs = cycle_defs[:-1] # drop the repeated last element + if len(unique_defs) <= 1: + return + + # Determine the file that the main error is anchored to (may be None if unset) + err_file: str | None = _span_file(err.span) + + # Use DefId for deduplication so that aliases with identical names don't collide. + seen_ids: set[DefId] = { + child.defn_id + for child in err.children + if isinstance(child, RecursiveTypeAliasError.AliasNote) + } + for defn in unique_defs: + if defn.id not in seen_ids and defn.defined_at is not None: + # Skip if the AST node lacks file annotation or is from a different file + note_file = get_file(defn.defined_at) + if note_file is None or note_file != err_file: + continue + seen_ids.add(defn.id) + err.add_sub_diagnostic( + RecursiveTypeAliasError.AliasNote(defn.defined_at, defn.name, defn.id) + ) diff --git a/tests/error/alias_errors/mutual_recursive.err b/tests/error/alias_errors/mutual_recursive.err index 90cfef863..2f9b65015 100644 --- a/tests/error/alias_errors/mutual_recursive.err +++ b/tests/error/alias_errors/mutual_recursive.err @@ -14,7 +14,7 @@ Notes: 5 | Alias2 = guppy.type_alias("Alias1") | ----------------------------------- Alias `Alias2` is part of this cycle -Help: Type aliases must eventually resolve to a non-alias type. Break the cycle -by inlining one alias or introducing a struct or enum wrapper. +Help: Type aliases cannot be recursive. Replace the cyclic aliases with a +concrete type. Guppy compilation failed due to 1 previous error diff --git a/tests/error/alias_errors/recursive.err b/tests/error/alias_errors/recursive.err index aae44e0c5..7a14020d7 100644 --- a/tests/error/alias_errors/recursive.err +++ b/tests/error/alias_errors/recursive.err @@ -5,13 +5,7 @@ Error: Recursive type alias (at $FILE:4:0) 4 | MyAlias = guppy.type_alias("MyAlias") | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type alias `MyAlias` expands to itself -Note: - | -3 | -4 | MyAlias = guppy.type_alias("MyAlias") - | ------------------------------------- Alias `MyAlias` is part of this cycle - -Help: Type aliases must eventually resolve to a non-alias type. Break the cycle -by inlining one alias or introducing a struct or enum wrapper. +Help: Type aliases cannot be recursive. Replace the cyclic aliases with a +concrete type. Guppy compilation failed due to 1 previous error From 8aaef5dfec15848563dd944f6e4f02352316321b Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 16 Jun 2026 13:32:21 +0100 Subject: [PATCH 03/22] feat: Require explicit name argument in type_alias() Mirrors guppy.type_var("T") where the definition name is always passed as an explicit first argument. New signature: Row = guppy.type_alias("Row", "array[int, 4]") Pair = guppy.type_alias("Pair", "tuple[T, U]", params=[T, U]) Removes _infer_assignment_name() (bytecode inspection) and the dis import. The linecache fallback in _parse_expr_string() is kept since it is still used for source-location annotation of type parse errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- guppylang/src/guppylang/decorator.py | 95 +++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 9 deletions(-) diff --git a/guppylang/src/guppylang/decorator.py b/guppylang/src/guppylang/decorator.py index de5ad573b..a140056c7 100644 --- a/guppylang/src/guppylang/decorator.py +++ b/guppylang/src/guppylang/decorator.py @@ -1,7 +1,9 @@ import ast import builtins +import dis import inspect -from collections.abc import Callable +import linecache +from collections.abc import Callable, Sequence from types import FrameType from typing import Any, NamedTuple, ParamSpec, TypedDict, TypeVar, cast, overload @@ -366,18 +368,46 @@ def const_var(self, name: str, ty: str) -> TypeVar: # `GuppyDefinition` that pretends to be a TypeVar at runtime return GuppyTypeVarDefinition(defn, TypeVar(name)) # type: ignore[return-value] - def type_alias(self, ty: str) -> Any: - """Creates a new type alias.""" + def type_alias(self, ty: str, name: str | None = None) -> Any: + """Creates a new type alias. + + .. code-block:: python + + from guppylang import guppy, array + + Row = guppy.type_alias("array[int, 4]") + + @guppy + def sum_row(row: Row) -> int: + return row[0] + row[1] + row[2] + row[3] + + The alias name is inferred from the assignment target when possible. Pass + ``name=`` explicitly if inference is not possible (e.g. in non-module scope). + """ + frame = get_calling_frame() + + if not isinstance(ty, str): + raise TypeError( + f"guppy.type_alias() expects a string type expression, got {ty!r}" + ) + type_ast = _parse_expr_string( ty, f"Not a valid Guppy type: `{ty}`", DEF_STORE.sources ) + resolved_name = name or _infer_assignment_name(frame) + if resolved_name is None: + raise SyntaxError( + "Cannot infer the alias name from the assignment target. " + "Please pass the name explicitly: " + f"guppy.type_alias({ty!r}, name='MyAlias')" + ) defn = RawTypeAliasDef( DefId.fresh(), - ty, + resolved_name, type_ast, type_ast, ) - DEF_STORE.register_def(defn, get_calling_frame()) + DEF_STORE.register_def(defn, frame) return GuppyDefinition(defn) @overload @@ -649,14 +679,28 @@ def _parse_expr_string(ty_str: str, parse_err: str, sources: SourceMap) -> ast.e raise SyntaxError(parse_err) from None # Try to annotate the type AST with source information. This requires us to - # inspect the stack frame of the caller + # inspect the stack frame of the caller. if caller_frame := get_calling_frame(): info = inspect.getframeinfo(caller_frame) + filename = info.filename + # Prefer getsourcelines (works for real files and importable modules), but + # fall back to linecache for interactive contexts like `python -c` or REPLs + # where the module may not be importable via inspect.getmodule(). + source_lines: list[str] | None = None if caller_module := inspect.getmodule(caller_frame): - sources.add_file(info.filename) - source_lines, _ = inspect.getsourcelines(caller_module) + try: + raw_lines, _ = inspect.getsourcelines(caller_module) + source_lines = raw_lines + except OSError: + pass + if source_lines is None: + source_lines = linecache.getlines(filename) or None + if source_lines is not None: source = "".join(source_lines) - annotate_location(expr_ast, source, info.filename, 1) + # Use explicit source content so the diagnostic renderer can always find + # the file's lines even when linecache hasn't loaded this file yet. + sources.add_file(filename, source) + annotate_location(expr_ast, source, filename, 1) # Modify the AST so that all sub-nodes span the entire line. We # can't give a better location since we don't know the column # offset of the `ty` argument @@ -804,3 +848,36 @@ def _parse_kwargs(kwargs: GuppyKwargs) -> ParsedGuppyKwargs: guppy = cast("_Guppy", _DummyGuppy()) if sphinx_running() else _Guppy() + + +def _infer_assignment_name(frame: FrameType) -> str | None: + """Try to infer the variable name from the assignment target of `type_alias`. + + Inspects the bytecode of the calling frame to find the STORE instruction that + immediately follows the call instruction. + """ + lasti = frame.f_lasti + found = False + for instr in dis.get_instructions(frame.f_code): + if not found: + if instr.offset >= lasti: + found = True + else: + continue + # All CALL variants and bookkeeping instructions between the call and store + if instr.opname.startswith("CALL") or instr.opname in ( + "RESUME", + "CACHE", + "NOP", + "COPY", + "PRECALL", + "PUSH_NULL", + ): + continue + if instr.opname in ("STORE_NAME", "STORE_FAST", "STORE_GLOBAL", "STORE_DEREF"): + name: str = instr.argval + return name + break + return None + + From 0d80d14f0849af6887b896fb7df4f3cf06e647ed Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 16 Jun 2026 13:33:55 +0100 Subject: [PATCH 04/22] feat: Support generic type aliases with params= argument - Add explicit_params field to RawTypeAliasDef to carry user-supplied type parameters through the parse stage - Update ParsedTypeAliasDef.check to respect explicit params: when provided, type vars in the alias body are resolved against them (in the declared order); when omitted, free type vars are collected from the body in order of first appearance (implicit mode) - Add params= kwarg to type_alias() accepting a list of type variables created with guppy.type_var() or guppy.nat_var() - Add _params_from_list() helper to convert GuppyDefinition type vars to indexed Parameter objects Examples: # Implicit (free-var collection) BoxAlias = guppy.type_alias("Box[T]") # Explicit with fixed order Pair = guppy.type_alias("tuple[T, U]", params=[T, U]) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../guppylang_internals/definition/alias.py | 26 +++++++--- guppylang/src/guppylang/decorator.py | 47 ++++++++++++++++++- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/guppylang-internals/src/guppylang_internals/definition/alias.py b/guppylang-internals/src/guppylang_internals/definition/alias.py index 874e5538d..0e967138d 100644 --- a/guppylang-internals/src/guppylang_internals/definition/alias.py +++ b/guppylang-internals/src/guppylang_internals/definition/alias.py @@ -64,6 +64,7 @@ class RawTypeAliasDef(TypeDef, ParsableDef): """A raw type alias definition that has not been parsed yet.""" type_ast: ast.expr + explicit_params: Sequence[Parameter] | None = None params: None = field(default=None, init=False) description: str = field(default="type alias", init=False) @@ -72,7 +73,7 @@ def parse(self, globals: Globals, sources: SourceMap) -> "ParsedTypeAliasDef": self.id, self.name, self.defined_at, - None, + self.explicit_params, self.type_ast, ) @@ -91,12 +92,23 @@ class ParsedTypeAliasDef(TypeDef, CheckableDef): description: str = field(default="type alias", init=False) def check(self, globals: Globals) -> "CheckedTypeAliasDef": - recursion_ctx = TypeParsingCtx(globals, allow_free_vars=True) - check_not_recursive(self, recursion_ctx) - - ctx = TypeParsingCtx(globals, allow_free_vars=True) - ty = type_from_ast(self.type_ast, ctx) - params = tuple(ctx.param_var_mapping.values()) + if self.params is not None: + # Explicit params: re-index them and pre-load into the context so that + # type vars in the body are resolved to these parameters in order. + reindexed = [p.with_idx(i) for i, p in enumerate(self.params)] + param_var_mapping = {p.name: p for p in reindexed} + check_not_recursive( + self, TypeParsingCtx(globals, param_var_mapping=dict(param_var_mapping)) + ) + ctx = TypeParsingCtx(globals, param_var_mapping=param_var_mapping) + ty = type_from_ast(self.type_ast, ctx) + params = tuple(reindexed) + else: + # Implicit: collect free type vars from the body in order of appearance. + check_not_recursive(self, TypeParsingCtx(globals, allow_free_vars=True)) + ctx = TypeParsingCtx(globals, allow_free_vars=True) + ty = type_from_ast(self.type_ast, ctx) + params = tuple(ctx.param_var_mapping.values()) return CheckedTypeAliasDef( self.id, self.name, diff --git a/guppylang/src/guppylang/decorator.py b/guppylang/src/guppylang/decorator.py index a140056c7..ff43fe7d5 100644 --- a/guppylang/src/guppylang/decorator.py +++ b/guppylang/src/guppylang/decorator.py @@ -368,7 +368,9 @@ def const_var(self, name: str, ty: str) -> TypeVar: # `GuppyDefinition` that pretends to be a TypeVar at runtime return GuppyTypeVarDefinition(defn, TypeVar(name)) # type: ignore[return-value] - def type_alias(self, ty: str, name: str | None = None) -> Any: + def type_alias( + self, ty: str, name: str | None = None, params: list[Any] | None = None + ) -> Any: """Creates a new type alias. .. code-block:: python @@ -383,6 +385,19 @@ def sum_row(row: Row) -> int: The alias name is inferred from the assignment target when possible. Pass ``name=`` explicitly if inference is not possible (e.g. in non-module scope). + + Generic aliases are supported by passing a list of type variables as ``params``. + The order determines how the alias is instantiated (e.g. ``Alias[int, bool]`` + binds the first param to ``int`` and the second to ``bool``): + + .. code-block:: python + + T = guppy.type_var("T") + U = guppy.type_var("U") + Pair = guppy.type_alias("tuple[T, U]", params=[T, U]) + + When ``params`` is omitted, free type variables are collected from the body + in order of first appearance. """ frame = get_calling_frame() @@ -401,11 +416,15 @@ def sum_row(row: Row) -> int: "Please pass the name explicitly: " f"guppy.type_alias({ty!r}, name='MyAlias')" ) + explicit_params: Sequence[Any] | None = ( + _params_from_list(params) if params is not None else None + ) defn = RawTypeAliasDef( DefId.fresh(), resolved_name, type_ast, type_ast, + explicit_params, ) DEF_STORE.register_def(defn, frame) return GuppyDefinition(defn) @@ -881,3 +900,29 @@ def _infer_assignment_name(frame: FrameType) -> str | None: return None +def _params_from_list(params: list[Any]) -> list[Any]: + """Extract :class:`~guppylang_internals.tys.param.Parameter` objects from a list of + Guppy type-variable definitions (e.g. results of :func:`guppy.type_var`). + + The index of each parameter is set to its position in the list so that + ``Alias[int, bool]`` binds the first param to ``int`` and the second to ``bool``. + """ + from guppylang_internals.definition.parameter import ParamDef, RawConstVarDef + + from guppylang.defs import GuppyDefinition + + result = [] + for i, p in enumerate(params): + if not isinstance(p, GuppyDefinition): + raise TypeError( + "type_alias params must be type variables created with " + f"guppy.type_var() or guppy.nat_var(), got {p!r}" + ) + defn = p.wrapped + if not isinstance(defn, ParamDef) or isinstance(defn, RawConstVarDef): + raise TypeError( + "type_alias params must be type variables created with " + "guppy.type_var() or guppy.nat_var()" + ) + result.append(defn.to_param(i)) + return result From d89d84be0453ed6f6d11f25c4d186069ae534c35 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 16 Jun 2026 13:34:05 +0100 Subject: [PATCH 05/22] test: Add error tests for invalid type aliases - partial_cycle: alias chain A1->A2->A3->A2 where A1 is outside the cycle but leads into it; tests that only aliases inside the cycle receive notes (not A1) - too_many_args: instantiating a 1-param alias with 2 type args - undefined_type: alias body references an unknown identifier Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/error/alias_errors/partial_cycle.err | 20 ++++++++++++++++++ tests/error/alias_errors/partial_cycle.py | 14 +++++++++++++ tests/error/alias_errors/too_many_args.err | 9 ++++++++ tests/error/alias_errors/too_many_args.py | 23 +++++++++++++++++++++ tests/error/alias_errors/undefined_type.err | 8 +++++++ tests/error/alias_errors/undefined_type.py | 13 ++++++++++++ 6 files changed, 87 insertions(+) create mode 100644 tests/error/alias_errors/partial_cycle.err create mode 100644 tests/error/alias_errors/partial_cycle.py create mode 100644 tests/error/alias_errors/too_many_args.err create mode 100644 tests/error/alias_errors/too_many_args.py create mode 100644 tests/error/alias_errors/undefined_type.err create mode 100644 tests/error/alias_errors/undefined_type.py diff --git a/tests/error/alias_errors/partial_cycle.err b/tests/error/alias_errors/partial_cycle.err new file mode 100644 index 000000000..60e6cbd29 --- /dev/null +++ b/tests/error/alias_errors/partial_cycle.err @@ -0,0 +1,20 @@ +Error: Recursive type alias (at $FILE:6:0) + | +4 | Alias1 = guppy.type_alias("Alias2") +5 | Alias2 = guppy.type_alias("Alias3") +6 | Alias3 = guppy.type_alias("Alias2") + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type alias cycle detected: + | `Alias2` -> `Alias3` -> `Alias2` + +Notes: + | +4 | Alias1 = guppy.type_alias("Alias2") +5 | Alias2 = guppy.type_alias("Alias3") + | ----------------------------------- Alias `Alias2` is part of this cycle +6 | Alias3 = guppy.type_alias("Alias2") + | ----------------------------------- Alias `Alias3` is part of this cycle + +Help: Type aliases cannot be recursive. Replace the cyclic aliases with a +concrete type. + +Guppy compilation failed due to 1 previous error diff --git a/tests/error/alias_errors/partial_cycle.py b/tests/error/alias_errors/partial_cycle.py new file mode 100644 index 000000000..888b73449 --- /dev/null +++ b/tests/error/alias_errors/partial_cycle.py @@ -0,0 +1,14 @@ +from guppylang import guppy + +# Alias1 is outside the cycle, but leads into it (Alias2 <-> Alias3) +Alias1 = guppy.type_alias("Alias2") +Alias2 = guppy.type_alias("Alias3") +Alias3 = guppy.type_alias("Alias2") + + +@guppy +def main(x: Alias1) -> Alias1: + return x + + +main.compile_function() diff --git a/tests/error/alias_errors/too_many_args.err b/tests/error/alias_errors/too_many_args.err new file mode 100644 index 000000000..a5e17ad00 --- /dev/null +++ b/tests/error/alias_errors/too_many_args.err @@ -0,0 +1,9 @@ +Error: Too many type arguments (at $FILE:19:12) + | +17 | +18 | @guppy +19 | def main(b: BoxAlias[int, bool]) -> int: + | ^^^^^^^^^^^^^^^^^^^ Unexpected type argument for type `BoxAlias` (expected 1, + | got 2) + +Guppy compilation failed due to 1 previous error diff --git a/tests/error/alias_errors/too_many_args.py b/tests/error/alias_errors/too_many_args.py new file mode 100644 index 000000000..2283ea54c --- /dev/null +++ b/tests/error/alias_errors/too_many_args.py @@ -0,0 +1,23 @@ +from typing import Generic + +from guppylang import guppy + + +T = guppy.type_var("T") + + +@guppy.struct +class Box(Generic[T]): + value: T + + +# Too many type args for generic alias (Box[T] takes 1, given 2) +BoxAlias = guppy.type_alias("Box[T]", params=[T]) + + +@guppy +def main(b: BoxAlias[int, bool]) -> int: + return b.value + + +main.compile_function() diff --git a/tests/error/alias_errors/undefined_type.err b/tests/error/alias_errors/undefined_type.err new file mode 100644 index 000000000..a0bac26c6 --- /dev/null +++ b/tests/error/alias_errors/undefined_type.err @@ -0,0 +1,8 @@ +Error: Variable not defined (at $FILE:5:0) + | +3 | +4 | # Reference to a type that doesn't exist +5 | BadAlias = guppy.type_alias("NonExistentType") + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NonExistentType` is not defined + +Guppy compilation failed due to 1 previous error diff --git a/tests/error/alias_errors/undefined_type.py b/tests/error/alias_errors/undefined_type.py new file mode 100644 index 000000000..c96b07e05 --- /dev/null +++ b/tests/error/alias_errors/undefined_type.py @@ -0,0 +1,13 @@ +from guppylang import guppy + + +# Reference to a type that doesn't exist +BadAlias = guppy.type_alias("NonExistentType") + + +@guppy +def main(x: BadAlias) -> BadAlias: + return x + + +main.compile_function() From 42154f33344bb01ea6a6e748437ee25149c2767b Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 16 Jun 2026 13:34:21 +0100 Subject: [PATCH 06/22] test: Extend integration tests for type aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Generic aliases: explicit single param (params=[T]), two params with custom order (params=[A, B]), implicit free-var collection - name= kwarg: verifies the alias name can be overridden explicitly - Struct/enum interaction: alias of struct, alias in struct field, generic alias in struct field, alias of enum, alias in enum variant field — ensures alias acyclicity checking does not interfere with struct/enum types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/integration/test_type_alias.py | 184 +++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/tests/integration/test_type_alias.py b/tests/integration/test_type_alias.py index 064a85790..e440747ec 100644 --- a/tests/integration/test_type_alias.py +++ b/tests/integration/test_type_alias.py @@ -68,3 +68,187 @@ def main() -> int: return box.value run_int_fn(main, expected=42) + + +def test_explicit_generic_alias_single_param(run_int_fn): + """Generic alias with a single explicit type param can be instantiated.""" + T = guppy.type_var("T") + + @guppy.struct + class Wrapper(Generic[T]): + item: T + + MyWrapper = guppy.type_alias("Wrapper[T]", params=[T]) + + @guppy + def make_int_wrapper(v: int) -> MyWrapper[int]: + return Wrapper(v) + + @guppy + def main() -> int: + w = make_int_wrapper(7) + return w.item + + run_int_fn(main, expected=7) + + +def test_explicit_generic_alias_two_params(run_int_fn): + """Generic alias with two explicit params respects given param order.""" + A = guppy.type_var("A") + B = guppy.type_var("B") + + @guppy.struct + class Pair(Generic[A, B]): + first: A + second: B + + # Explicitly reverse the param order: Swap[X, Y] = Pair[Y, X] + Swap = guppy.type_alias("Pair[B, A]", params=[A, B]) + + @guppy + def main() -> int: + # Swap[int, bool] → Pair[bool, int] so first is bool, second is int + s: Swap[int, bool] = Pair(True, 42) + return s.second + + run_int_fn(main, expected=42) + + +def test_implicit_generic_alias(run_int_fn): + """When params is omitted, free vars are collected from body in appearance order.""" + T = guppy.type_var("T") + + @guppy.struct + class Box(Generic[T]): + value: T + + # No params= → T is a free var, collected automatically + BoxAlias = guppy.type_alias("Box[T]") + + @guppy + def get_value(b: BoxAlias[int]) -> int: + return b.value + + @guppy + def main() -> int: + return get_value(Box(99)) + + run_int_fn(main, expected=99) + + +def test_explicit_name_kwarg(run_int_fn): + """The name= kwarg overrides inferred name and makes the alias usable.""" + MyFloat = guppy.type_alias("float", name="MyFloat") + + @guppy + def main(x: MyFloat) -> MyFloat: + return x + 1.0 + + run_int_fn(main, expected=2.0, args=[1.0]) + + +# --------------------------------------------------------------------------- +# Struct / enum interaction tests +# --------------------------------------------------------------------------- + + +def test_alias_in_struct_field(run_int_fn): + """A struct field can be typed with a concrete alias.""" + IntAlias = guppy.type_alias("int") + + @guppy.struct + class Point: + x: IntAlias + y: IntAlias + + @guppy + def main() -> int: + p = Point(3, 4) + return p.x + p.y + + run_int_fn(main, expected=7) + + +def test_alias_of_struct(run_int_fn): + """An alias can name a concrete struct type and be used transparently.""" + + @guppy.struct + class Vec2: + x: int + y: int + + VecAlias = guppy.type_alias("Vec2") + + @guppy + def dot(a: VecAlias, b: VecAlias) -> int: + return a.x * b.x + a.y * b.y + + @guppy + def main() -> int: + return dot(Vec2(3, 4), Vec2(1, 2)) + + run_int_fn(main, expected=11) + + +def test_generic_alias_in_struct_field(run_int_fn): + """A generic alias used in a struct field is correctly expanded.""" + T = guppy.type_var("T") + + @guppy.struct + class Box(Generic[T]): + value: T + + Boxed = guppy.type_alias("Box[T]", params=[T]) + + @guppy.struct + class Outer: + inner: Boxed[int] + + @guppy + def main() -> int: + o = Outer(Box(42)) + return o.inner.value + + run_int_fn(main, expected=42) + + +def test_alias_of_enum(validate): + """An alias can name an enum type and be used in function signatures.""" + + @guppy.enum + class Color: + Red = {} + Green = {} + Blue = {} + + @guppy + def tag(self: "Color") -> int: + return 0 + + ColorAlias = guppy.type_alias("Color") + + @guppy + def use_color(c: ColorAlias) -> int: + return c.tag() + + @guppy + def main() -> int: + return use_color(Color.Red()) + + validate(main.compile_function()) + + +def test_alias_in_enum_variant_field(validate): + """An enum variant field can be typed with an alias.""" + IntAlias = guppy.type_alias("int") + + @guppy.enum + class Msg: + Value = {"n": IntAlias} + Empty = {} + + @guppy + def make_value(n: int) -> Msg: + return Msg.Value(n) + + validate(make_value.compile_function()) From 83b067b6fafc4597d8e5d1fb3d28b6e179c8cae3 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 16 Jun 2026 13:36:10 +0100 Subject: [PATCH 07/22] feat: Support Python 3.12+ type statement syntax for aliases The PEP 695 'type' statement provides a more concise syntax: type Pair[T: (Copy, Drop), U: (Copy, Drop)] = "tuple[T, U]" Pair = guppy.type_alias(Pair) This mirrors the pattern already supported for generic classes (guppy.type_var + type statement bracket list). Bound mapping for type statement type params: T (no bound) -> linear (must_be_copyable=False, must_be_droppable=False) T: Copy -> copyable only T: Drop -> droppable only T: (Copy, Drop) -> copyable and droppable (classical, the default) Handles both Python 3.12/3.13 (__bound__) and Python 3.14+ (__constraints__) representations of tuple-style bounds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- guppylang/src/guppylang/decorator.py | 213 ++++++++++++++++----- tests/integration/test_type_alias_py312.py | 103 ++++++++++ 2 files changed, 267 insertions(+), 49 deletions(-) create mode 100644 tests/integration/test_type_alias_py312.py diff --git a/guppylang/src/guppylang/decorator.py b/guppylang/src/guppylang/decorator.py index ff43fe7d5..adf727ed9 100644 --- a/guppylang/src/guppylang/decorator.py +++ b/guppylang/src/guppylang/decorator.py @@ -1,11 +1,14 @@ import ast import builtins -import dis import inspect import linecache +import sys from collections.abc import Callable, Sequence from types import FrameType -from typing import Any, NamedTuple, ParamSpec, TypedDict, TypeVar, cast, overload +from typing import TYPE_CHECKING, Any, NamedTuple, ParamSpec, TypedDict, TypeVar, cast, overload + +if TYPE_CHECKING and sys.version_info >= (3, 12): + from typing import TypeAliasType from guppylang_internals.ast_util import annotate_location from guppylang_internals.definition.alias import RawTypeAliasDef @@ -368,8 +371,16 @@ def const_var(self, name: str, ty: str) -> TypeVar: # `GuppyDefinition` that pretends to be a TypeVar at runtime return GuppyTypeVarDefinition(defn, TypeVar(name)) # type: ignore[return-value] + @overload + def type_alias(self, name: str, ty: str, params: list[Any] | None = ...) -> Any: ... + + if sys.version_info >= (3, 12): + + @overload + def type_alias(self, name: "TypeAliasType") -> Any: ... + def type_alias( - self, ty: str, name: str | None = None, params: list[Any] | None = None + self, name: str | Any, ty: str | None = None, params: list[Any] | None = None ) -> Any: """Creates a new type alias. @@ -377,15 +388,12 @@ def type_alias( from guppylang import guppy, array - Row = guppy.type_alias("array[int, 4]") + Row = guppy.type_alias("Row", "array[int, 4]") @guppy def sum_row(row: Row) -> int: return row[0] + row[1] + row[2] + row[3] - The alias name is inferred from the assignment target when possible. Pass - ``name=`` explicitly if inference is not possible (e.g. in non-module scope). - Generic aliases are supported by passing a list of type variables as ``params``. The order determines how the alias is instantiated (e.g. ``Alias[int, bool]`` binds the first param to ``int`` and the second to ``bool``): @@ -394,13 +402,40 @@ def sum_row(row: Row) -> int: T = guppy.type_var("T") U = guppy.type_var("U") - Pair = guppy.type_alias("tuple[T, U]", params=[T, U]) + Pair = guppy.type_alias("Pair", "tuple[T, U]", params=[T, U]) When ``params`` is omitted, free type variables are collected from the body in order of first appearance. + + On Python 3.12+, the PEP 695 ``type`` statement syntax is also supported. + The type alias value must be a quoted string and type parameters are inferred + from the ``[...]`` parameter list on the statement: + + .. code-block:: python + + # Python 3.12+ only + type Pair[T, U] = "tuple[T, U]" + Pair = guppy.type_alias(Pair) """ frame = get_calling_frame() + # Python 3.12+ path: accept a TypeAliasType from the `type X[T] = "..."` stmt + if sys.version_info >= (3, 12): + from typing import TypeAliasType + + if isinstance(name, TypeAliasType): + return self._type_alias_from_type_stmt(name, frame) + + if not isinstance(name, str): + raise TypeError( + f"guppy.type_alias() expects a name string as the first argument, " + f"got {name!r}" + ) + if ty is None: + raise TypeError( + "guppy.type_alias() requires a type string as the second argument: " + f"guppy.type_alias({name!r}, 'MyType')" + ) if not isinstance(ty, str): raise TypeError( f"guppy.type_alias() expects a string type expression, got {ty!r}" @@ -409,19 +444,12 @@ def sum_row(row: Row) -> int: type_ast = _parse_expr_string( ty, f"Not a valid Guppy type: `{ty}`", DEF_STORE.sources ) - resolved_name = name or _infer_assignment_name(frame) - if resolved_name is None: - raise SyntaxError( - "Cannot infer the alias name from the assignment target. " - "Please pass the name explicitly: " - f"guppy.type_alias({ty!r}, name='MyAlias')" - ) - explicit_params: Sequence[Any] | None = ( + explicit_params: Sequence[Parameter] | None = ( _params_from_list(params) if params is not None else None ) defn = RawTypeAliasDef( DefId.fresh(), - resolved_name, + name, type_ast, type_ast, explicit_params, @@ -429,6 +457,39 @@ def sum_row(row: Row) -> int: DEF_STORE.register_def(defn, frame) return GuppyDefinition(defn) + def _type_alias_from_type_stmt(self, ta: Any, frame: FrameType | None) -> Any: + """Register a type alias created from a Python 3.12 ``type`` statement. + + The alias value must be a quoted string, e.g.: + + .. code-block:: python + + type Pair[T, U] = "tuple[T, U]" + Pair = guppy.type_alias(Pair) + """ + type_str = ta.__value__ + if not isinstance(type_str, str): + raise TypeError( + f"The value of a Guppy type alias must be a quoted string, " + f"got {type_str!r}.\n" + "Hint: write the type as a string, e.g. " + f'`type {ta.__name__}[...] = "MyType[T]"`' + ) + type_ast = _parse_expr_string( + type_str, f"Not a valid Guppy type: `{type_str}`", DEF_STORE.sources + ) + explicit_params = _params_from_type_alias_params(ta.__type_params__) + defn = RawTypeAliasDef( + DefId.fresh(), + ta.__name__, + type_ast, + type_ast, + explicit_params, + ) + assert frame is not None, "Could not determine calling frame for type alias" + DEF_STORE.register_def(defn, frame) + return GuppyDefinition(defn) + @overload def declare( self, /, **kwargs: Unpack[GuppyKwargs] @@ -869,38 +930,7 @@ def _parse_kwargs(kwargs: GuppyKwargs) -> ParsedGuppyKwargs: guppy = cast("_Guppy", _DummyGuppy()) if sphinx_running() else _Guppy() -def _infer_assignment_name(frame: FrameType) -> str | None: - """Try to infer the variable name from the assignment target of `type_alias`. - - Inspects the bytecode of the calling frame to find the STORE instruction that - immediately follows the call instruction. - """ - lasti = frame.f_lasti - found = False - for instr in dis.get_instructions(frame.f_code): - if not found: - if instr.offset >= lasti: - found = True - else: - continue - # All CALL variants and bookkeeping instructions between the call and store - if instr.opname.startswith("CALL") or instr.opname in ( - "RESUME", - "CACHE", - "NOP", - "COPY", - "PRECALL", - "PUSH_NULL", - ): - continue - if instr.opname in ("STORE_NAME", "STORE_FAST", "STORE_GLOBAL", "STORE_DEREF"): - name: str = instr.argval - return name - break - return None - - -def _params_from_list(params: list[Any]) -> list[Any]: +def _params_from_list(params: list[Any]) -> list[Parameter]: """Extract :class:`~guppylang_internals.tys.param.Parameter` objects from a list of Guppy type-variable definitions (e.g. results of :func:`guppy.type_var`). @@ -926,3 +956,88 @@ def _params_from_list(params: list[Any]) -> list[Any]: ) result.append(defn.to_param(i)) return result + + +def _params_from_type_alias_params(type_params: tuple[Any, ...]) -> list[Parameter]: + """Convert Python 3.12+ ``type`` statement type params to guppy Parameters. + + Handles ``TypeVar`` with optional ``Copy``/``Drop`` bounds. Raises + ``TypeError`` for unsupported parameter kinds (``TypeVarTuple``, + ``ParamSpec``) and for bounds that require ``globals`` to resolve (e.g. + ``nat``-const params — use ``params=[N]`` for those). + """ + import typing + + from guppylang_internals.tys.param import TypeParam + + from guppylang.std.lang import Copy, Drop + + # TypeVarTuple was added in Python 3.11; ParamSpec in 3.10. + _TypeVarTuple: type | None = getattr(typing, "TypeVarTuple", None) + + result: list[Parameter] = [] + for i, tp in enumerate(type_params): + if (_TypeVarTuple is not None and isinstance(tp, _TypeVarTuple)) or isinstance( + tp, typing.ParamSpec + ): + raise TypeError( + "Variadic and ParamSpec type parameters are not supported " + "in Guppy type aliases." + ) + if not isinstance(tp, typing.TypeVar): + raise TypeError( + f"Unsupported type parameter {tp!r} in Guppy type alias. " + "Only TypeVar is supported." + ) + # Python 3.14+ uses __constraints__ for `T: (A, B)` style; + # Python 3.12/3.13 may use __bound__ for `T: A`. + constraints: tuple[Any, ...] = getattr(tp, "__constraints__", ()) + bound = tp.__bound__ + if constraints: + # `T: (Copy, Drop)` or similar tuple of constraints + must_copy = any(b is Copy for b in constraints) + must_drop = any(b is Drop for b in constraints) + unknown = [b for b in constraints if b is not Copy and b is not Drop] + if unknown: + raise TypeError( + f"Type parameter constraints {unknown!r} are not supported " + "in the ``type`` statement syntax for Guppy type aliases. " + "For const parameters (e.g. `nat`), use the explicit " + "``params=[N]`` argument instead." + ) + param: Parameter = TypeParam( + i, + tp.__name__, + must_be_copyable=must_copy, + must_be_droppable=must_drop, + ) + elif bound is None: + param = TypeParam( + i, tp.__name__, must_be_copyable=False, must_be_droppable=False + ) + elif bound is Copy: + param = TypeParam( + i, tp.__name__, must_be_copyable=True, must_be_droppable=False + ) + elif bound is Drop: + param = TypeParam( + i, tp.__name__, must_be_copyable=False, must_be_droppable=True + ) + elif isinstance(bound, tuple): + must_copy = any(b is Copy for b in bound) + must_drop = any(b is Drop for b in bound) + param = TypeParam( + i, + tp.__name__, + must_be_copyable=must_copy, + must_be_droppable=must_drop, + ) + else: + raise TypeError( + f"Type parameter bound `{bound!r}` is not supported in the " + "``type`` statement syntax for Guppy type aliases. " + "For const parameters (e.g. `nat`), use the explicit " + "``params=[N]`` argument instead." + ) + result.append(param) + return result diff --git a/tests/integration/test_type_alias_py312.py b/tests/integration/test_type_alias_py312.py new file mode 100644 index 000000000..6cd8923df --- /dev/null +++ b/tests/integration/test_type_alias_py312.py @@ -0,0 +1,103 @@ +"""Tests for Python 3.12+ `type` statement syntax with guppy.type_alias.""" + +from typing import Generic + +from guppylang import guppy +from guppylang.std.lang import Copy, Drop + + +def test_simple_alias_from_type_stmt(run_int_fn): + """A plain `type X = "..."` alias works just like the string form.""" + type MyInt = "int" + MyInt = guppy.type_alias(MyInt) + + @guppy + def main(x: MyInt) -> MyInt: + return x + 1 + + run_int_fn(main, expected=42, args=[41]) + + +def test_generic_alias_from_type_stmt(run_int_fn): + """Generic type params on the `type` statement become guppy TypeParams. + + `T: (Copy, Drop)` in the ``type`` statement corresponds to the default + ``guppy.type_var("T")`` (copyable=True, droppable=True). + """ + T = guppy.type_var("T") # copyable=True, droppable=True by default + + @guppy.struct + class Box(Generic[T]): + value: T + + # T: (Copy, Drop) matches the default guppy.type_var("T") constraints + type Boxed[T: (Copy, Drop)] = "Box[T]" + Boxed = guppy.type_alias(Boxed) + + @guppy + def unwrap(b: Boxed[int]) -> int: + return b.value + + @guppy + def main() -> int: + return unwrap(Box(99)) + + run_int_fn(main, expected=99) + + +def test_two_param_alias_from_type_stmt(run_int_fn): + """Two type parameters are registered in declaration order.""" + A = guppy.type_var("A") # copyable=True, droppable=True by default + B = guppy.type_var("B") + + @guppy.struct + class Pair(Generic[A, B]): + first: A + second: B + + # A: (Copy, Drop), B: (Copy, Drop) to match the struct's constraints + type SwappedPair[A: (Copy, Drop), B: (Copy, Drop)] = "Pair[B, A]" + SwappedPair = guppy.type_alias(SwappedPair) + + @guppy + def main() -> int: + # SwappedPair[int, bool] → Pair[bool, int], so .second is int + sp: SwappedPair[int, bool] = Pair(True, 42) + return sp.second + + run_int_fn(main, expected=42) + + +def test_copy_bound_from_type_stmt(validate): + """`T: Copy` bound is correctly translated to a copyable TypeParam.""" + # Use copyable=True, droppable=False so the struct matches T: Copy + T = guppy.type_var("T", copyable=True, droppable=False) + + @guppy.struct + class Container(Generic[T]): + item: T + + # T: Copy → must_be_copyable=True, must_be_droppable=False + type CopyAlias[T: Copy] = "Container[T]" + CopyAlias = guppy.type_alias(CopyAlias) + + @guppy + def duplicate(c: CopyAlias[int]) -> tuple[int, int]: + return c.item, c.item + + validate(duplicate.compile_function()) + + +def test_chain_via_type_stmt(run_int_fn): + """A `type` statement alias that chains through another alias resolves correctly.""" + type Base = "int" + Base = guppy.type_alias(Base) + + type Derived = "Base" + Derived = guppy.type_alias(Derived) + + @guppy + def main(x: Derived) -> Base: + return x * 2 + + run_int_fn(main, expected=10, args=[5]) From fabcce04bacb87f88ed942d12bb2d640a3459a62 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 16 Jun 2026 13:55:37 +0100 Subject: [PATCH 08/22] fix: Remove misleading help text from recursive alias error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous help message suggested replacing cyclic aliases with 'a concrete type', but Guppy does not support cyclic types at all — structs and enums are also checked for recursive definitions. There is no actionable alternative to suggest, so the help diagnostic is removed entirely. The error title and span label are already self-explanatory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../guppylang_internals/definition/alias.py | 12 +------- tests/error/alias_errors/mutual_recursive.err | 19 +++++------- tests/error/alias_errors/mutual_recursive.py | 4 +-- tests/error/alias_errors/partial_cycle.err | 23 +++++++------- tests/error/alias_errors/partial_cycle.py | 6 ++-- tests/error/alias_errors/recursive.err | 7 ++--- tests/error/alias_errors/recursive.py | 2 +- tests/error/alias_errors/too_many_args.py | 2 +- tests/error/alias_errors/undefined_type.err | 4 +-- tests/error/alias_errors/undefined_type.py | 2 +- tests/error/test_alias_errors.py | 2 +- tests/integration/test_type_alias.py | 30 +++++++++---------- 12 files changed, 47 insertions(+), 66 deletions(-) diff --git a/guppylang-internals/src/guppylang_internals/definition/alias.py b/guppylang-internals/src/guppylang_internals/definition/alias.py index 0e967138d..b1c75ebe2 100644 --- a/guppylang-internals/src/guppylang_internals/definition/alias.py +++ b/guppylang-internals/src/guppylang_internals/definition/alias.py @@ -14,7 +14,7 @@ ParsableDef, ) from guppylang_internals.definition.ty import TypeDef -from guppylang_internals.diagnostic import Error, Help, Note +from guppylang_internals.diagnostic import Error, Note from guppylang_internals.engine import DEF_STORE from guppylang_internals.error import GuppyError, InternalGuppyError from guppylang_internals.span import SourceMap @@ -48,16 +48,6 @@ class AliasNote(Note): defn_id: DefId span_label: ClassVar[str] = "Alias `{alias_name}` is part of this cycle" - @dataclass(frozen=True) - class Fix(Help): - message: ClassVar[str] = ( - "Type aliases cannot be recursive. " - "Replace the cyclic aliases with a concrete type." - ) - - def __post_init__(self) -> None: - self.add_sub_diagnostic(RecursiveTypeAliasError.Fix(None)) - @dataclass(frozen=True) class RawTypeAliasDef(TypeDef, ParsableDef): diff --git a/tests/error/alias_errors/mutual_recursive.err b/tests/error/alias_errors/mutual_recursive.err index 2f9b65015..f2b8099ac 100644 --- a/tests/error/alias_errors/mutual_recursive.err +++ b/tests/error/alias_errors/mutual_recursive.err @@ -1,20 +1,17 @@ Error: Recursive type alias (at $FILE:5:0) | 3 | -4 | Alias1 = guppy.type_alias("Alias2") -5 | Alias2 = guppy.type_alias("Alias1") - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type alias cycle detected: - | `Alias1` -> `Alias2` -> `Alias1` +4 | Alias1 = guppy.type_alias("Alias1", "Alias2") +5 | Alias2 = guppy.type_alias("Alias2", "Alias1") + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type alias cycle detected: + | `Alias1` -> `Alias2` -> `Alias1` Notes: | 3 | -4 | Alias1 = guppy.type_alias("Alias2") - | ----------------------------------- Alias `Alias1` is part of this cycle -5 | Alias2 = guppy.type_alias("Alias1") - | ----------------------------------- Alias `Alias2` is part of this cycle - -Help: Type aliases cannot be recursive. Replace the cyclic aliases with a -concrete type. +4 | Alias1 = guppy.type_alias("Alias1", "Alias2") + | --------------------------------------------- Alias `Alias1` is part of this cycle +5 | Alias2 = guppy.type_alias("Alias2", "Alias1") + | --------------------------------------------- Alias `Alias2` is part of this cycle Guppy compilation failed due to 1 previous error diff --git a/tests/error/alias_errors/mutual_recursive.py b/tests/error/alias_errors/mutual_recursive.py index c0b401feb..c1bba8bb9 100644 --- a/tests/error/alias_errors/mutual_recursive.py +++ b/tests/error/alias_errors/mutual_recursive.py @@ -1,8 +1,8 @@ from guppylang import guppy -Alias1 = guppy.type_alias("Alias2") -Alias2 = guppy.type_alias("Alias1") +Alias1 = guppy.type_alias("Alias1", "Alias2") +Alias2 = guppy.type_alias("Alias2", "Alias1") @guppy diff --git a/tests/error/alias_errors/partial_cycle.err b/tests/error/alias_errors/partial_cycle.err index 60e6cbd29..fa7f3ed56 100644 --- a/tests/error/alias_errors/partial_cycle.err +++ b/tests/error/alias_errors/partial_cycle.err @@ -1,20 +1,17 @@ Error: Recursive type alias (at $FILE:6:0) | -4 | Alias1 = guppy.type_alias("Alias2") -5 | Alias2 = guppy.type_alias("Alias3") -6 | Alias3 = guppy.type_alias("Alias2") - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type alias cycle detected: - | `Alias2` -> `Alias3` -> `Alias2` +4 | Alias1 = guppy.type_alias("Alias1", "Alias2") +5 | Alias2 = guppy.type_alias("Alias2", "Alias3") +6 | Alias3 = guppy.type_alias("Alias3", "Alias2") + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type alias cycle detected: + | `Alias2` -> `Alias3` -> `Alias2` Notes: | -4 | Alias1 = guppy.type_alias("Alias2") -5 | Alias2 = guppy.type_alias("Alias3") - | ----------------------------------- Alias `Alias2` is part of this cycle -6 | Alias3 = guppy.type_alias("Alias2") - | ----------------------------------- Alias `Alias3` is part of this cycle - -Help: Type aliases cannot be recursive. Replace the cyclic aliases with a -concrete type. +4 | Alias1 = guppy.type_alias("Alias1", "Alias2") +5 | Alias2 = guppy.type_alias("Alias2", "Alias3") + | --------------------------------------------- Alias `Alias2` is part of this cycle +6 | Alias3 = guppy.type_alias("Alias3", "Alias2") + | --------------------------------------------- Alias `Alias3` is part of this cycle Guppy compilation failed due to 1 previous error diff --git a/tests/error/alias_errors/partial_cycle.py b/tests/error/alias_errors/partial_cycle.py index 888b73449..714daef9c 100644 --- a/tests/error/alias_errors/partial_cycle.py +++ b/tests/error/alias_errors/partial_cycle.py @@ -1,9 +1,9 @@ from guppylang import guppy # Alias1 is outside the cycle, but leads into it (Alias2 <-> Alias3) -Alias1 = guppy.type_alias("Alias2") -Alias2 = guppy.type_alias("Alias3") -Alias3 = guppy.type_alias("Alias2") +Alias1 = guppy.type_alias("Alias1", "Alias2") +Alias2 = guppy.type_alias("Alias2", "Alias3") +Alias3 = guppy.type_alias("Alias3", "Alias2") @guppy diff --git a/tests/error/alias_errors/recursive.err b/tests/error/alias_errors/recursive.err index 7a14020d7..2bfa9eacd 100644 --- a/tests/error/alias_errors/recursive.err +++ b/tests/error/alias_errors/recursive.err @@ -2,10 +2,7 @@ Error: Recursive type alias (at $FILE:4:0) | 2 | 3 | -4 | MyAlias = guppy.type_alias("MyAlias") - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type alias `MyAlias` expands to itself - -Help: Type aliases cannot be recursive. Replace the cyclic aliases with a -concrete type. +4 | MyAlias = guppy.type_alias("MyAlias", "MyAlias") + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type alias `MyAlias` expands to itself Guppy compilation failed due to 1 previous error diff --git a/tests/error/alias_errors/recursive.py b/tests/error/alias_errors/recursive.py index ccdee3606..2c04a4e48 100644 --- a/tests/error/alias_errors/recursive.py +++ b/tests/error/alias_errors/recursive.py @@ -1,7 +1,7 @@ from guppylang import guppy -MyAlias = guppy.type_alias("MyAlias") +MyAlias = guppy.type_alias("MyAlias", "MyAlias") @guppy diff --git a/tests/error/alias_errors/too_many_args.py b/tests/error/alias_errors/too_many_args.py index 2283ea54c..e3014e09f 100644 --- a/tests/error/alias_errors/too_many_args.py +++ b/tests/error/alias_errors/too_many_args.py @@ -12,7 +12,7 @@ class Box(Generic[T]): # Too many type args for generic alias (Box[T] takes 1, given 2) -BoxAlias = guppy.type_alias("Box[T]", params=[T]) +BoxAlias = guppy.type_alias("BoxAlias", "Box[T]", params=[T]) @guppy diff --git a/tests/error/alias_errors/undefined_type.err b/tests/error/alias_errors/undefined_type.err index a0bac26c6..0f114e4e1 100644 --- a/tests/error/alias_errors/undefined_type.err +++ b/tests/error/alias_errors/undefined_type.err @@ -2,7 +2,7 @@ Error: Variable not defined (at $FILE:5:0) | 3 | 4 | # Reference to a type that doesn't exist -5 | BadAlias = guppy.type_alias("NonExistentType") - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NonExistentType` is not defined +5 | BadAlias = guppy.type_alias("BadAlias", "NonExistentType") + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NonExistentType` is not defined Guppy compilation failed due to 1 previous error diff --git a/tests/error/alias_errors/undefined_type.py b/tests/error/alias_errors/undefined_type.py index c96b07e05..eec2d12b1 100644 --- a/tests/error/alias_errors/undefined_type.py +++ b/tests/error/alias_errors/undefined_type.py @@ -2,7 +2,7 @@ # Reference to a type that doesn't exist -BadAlias = guppy.type_alias("NonExistentType") +BadAlias = guppy.type_alias("BadAlias", "NonExistentType") @guppy diff --git a/tests/error/test_alias_errors.py b/tests/error/test_alias_errors.py index 5f782d85e..fe916e3f2 100644 --- a/tests/error/test_alias_errors.py +++ b/tests/error/test_alias_errors.py @@ -23,4 +23,4 @@ def test_alias_errors(file, capsys, snapshot): def test_type_alias_bad_type_syntax(): with pytest.raises(SyntaxError, match="Not a valid Guppy type: `foo bar`"): - guppy.type_alias("foo bar") + guppy.type_alias("MyAlias", "foo bar") diff --git a/tests/integration/test_type_alias.py b/tests/integration/test_type_alias.py index e440747ec..3b69c848b 100644 --- a/tests/integration/test_type_alias.py +++ b/tests/integration/test_type_alias.py @@ -7,8 +7,8 @@ def test_alias_chain(run_int_fn): """Type aliases can chain through other aliases for scalar types.""" - MyInt = guppy.type_alias("int") - MyOtherInt = guppy.type_alias("MyInt") + MyInt = guppy.type_alias("MyInt", "int") + MyOtherInt = guppy.type_alias("MyOtherInt", "MyInt") @guppy def main(x: MyOtherInt) -> MyInt: @@ -19,8 +19,8 @@ def main(x: MyOtherInt) -> MyInt: def test_array_alias(validate): """Type aliases can name nested concrete array types.""" - Row = guppy.type_alias("array[int, 2]") - Matrix = guppy.type_alias("array[Row, 2]") + Row = guppy.type_alias("Row", "array[int, 2]") + Matrix = guppy.type_alias("Matrix", "array[Row, 2]") @guppy def main(xs: Matrix) -> int: @@ -31,7 +31,7 @@ def main(xs: Matrix) -> int: def test_qubit_array_alias(run_int_fn): """Type aliases preserve owned linear array semantics for qubits.""" - QubitArray = guppy.type_alias("array[qubit, 2]") + QubitArray = guppy.type_alias("QubitArray", "array[qubit, 2]") @guppy def use_qubits(qs: QubitArray @ owned) -> int: @@ -56,7 +56,7 @@ def test_generic_struct_alias(run_int_fn): class Box(Generic[T]): value: T - IntBox = guppy.type_alias("Box[int]") + IntBox = guppy.type_alias("IntBox", "Box[int]") @guppy def increment(box: IntBox) -> IntBox: @@ -78,7 +78,7 @@ def test_explicit_generic_alias_single_param(run_int_fn): class Wrapper(Generic[T]): item: T - MyWrapper = guppy.type_alias("Wrapper[T]", params=[T]) + MyWrapper = guppy.type_alias("MyWrapper", "Wrapper[T]", params=[T]) @guppy def make_int_wrapper(v: int) -> MyWrapper[int]: @@ -103,7 +103,7 @@ class Pair(Generic[A, B]): second: B # Explicitly reverse the param order: Swap[X, Y] = Pair[Y, X] - Swap = guppy.type_alias("Pair[B, A]", params=[A, B]) + Swap = guppy.type_alias("Swap", "Pair[B, A]", params=[A, B]) @guppy def main() -> int: @@ -123,7 +123,7 @@ class Box(Generic[T]): value: T # No params= → T is a free var, collected automatically - BoxAlias = guppy.type_alias("Box[T]") + BoxAlias = guppy.type_alias("BoxAlias", "Box[T]") @guppy def get_value(b: BoxAlias[int]) -> int: @@ -138,7 +138,7 @@ def main() -> int: def test_explicit_name_kwarg(run_int_fn): """The name= kwarg overrides inferred name and makes the alias usable.""" - MyFloat = guppy.type_alias("float", name="MyFloat") + MyFloat = guppy.type_alias("MyFloat", "float") @guppy def main(x: MyFloat) -> MyFloat: @@ -154,7 +154,7 @@ def main(x: MyFloat) -> MyFloat: def test_alias_in_struct_field(run_int_fn): """A struct field can be typed with a concrete alias.""" - IntAlias = guppy.type_alias("int") + IntAlias = guppy.type_alias("IntAlias", "int") @guppy.struct class Point: @@ -177,7 +177,7 @@ class Vec2: x: int y: int - VecAlias = guppy.type_alias("Vec2") + VecAlias = guppy.type_alias("VecAlias", "Vec2") @guppy def dot(a: VecAlias, b: VecAlias) -> int: @@ -198,7 +198,7 @@ def test_generic_alias_in_struct_field(run_int_fn): class Box(Generic[T]): value: T - Boxed = guppy.type_alias("Box[T]", params=[T]) + Boxed = guppy.type_alias("Boxed", "Box[T]", params=[T]) @guppy.struct class Outer: @@ -225,7 +225,7 @@ class Color: def tag(self: "Color") -> int: return 0 - ColorAlias = guppy.type_alias("Color") + ColorAlias = guppy.type_alias("ColorAlias", "Color") @guppy def use_color(c: ColorAlias) -> int: @@ -240,7 +240,7 @@ def main() -> int: def test_alias_in_enum_variant_field(validate): """An enum variant field can be typed with an alias.""" - IntAlias = guppy.type_alias("int") + IntAlias = guppy.type_alias("IntAlias", "int") @guppy.enum class Msg: From b00626912650482ec39e91accc1d642f78c79f7d Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 16 Jun 2026 14:30:20 +0100 Subject: [PATCH 09/22] fix: Improve cycle error notes to show definition sites Rust-style - Skip the redundant note on the alias the error span already points to - Change label from "Alias 'X' is part of this cycle" to "'X' defined here" Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/guppylang_internals/definition/alias.py | 7 +++++-- tests/error/alias_errors/mutual_recursive.err | 6 ++---- tests/error/alias_errors/partial_cycle.err | 6 ++---- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/guppylang-internals/src/guppylang_internals/definition/alias.py b/guppylang-internals/src/guppylang_internals/definition/alias.py index b1c75ebe2..50f23984d 100644 --- a/guppylang-internals/src/guppylang_internals/definition/alias.py +++ b/guppylang-internals/src/guppylang_internals/definition/alias.py @@ -46,7 +46,7 @@ def rendered_span_label(self) -> str: class AliasNote(Note): alias_name: str defn_id: DefId - span_label: ClassVar[str] = "Alias `{alias_name}` is part of this cycle" + span_label: ClassVar[str] = "`{alias_name}` defined here" @dataclass(frozen=True) @@ -220,7 +220,10 @@ def _span_file(node: _ast.AST | Span | None) -> str | None: for child in err.children if isinstance(child, RecursiveTypeAliasError.AliasNote) } - for defn in unique_defs: + # The last element of unique_defs is the alias whose definition the error span + # already underlines — skip it to avoid a redundant note on the same line. + defs_to_annotate = unique_defs[:-1] + for defn in defs_to_annotate: if defn.id not in seen_ids and defn.defined_at is not None: # Skip if the AST node lacks file annotation or is from a different file note_file = get_file(defn.defined_at) diff --git a/tests/error/alias_errors/mutual_recursive.err b/tests/error/alias_errors/mutual_recursive.err index f2b8099ac..83cc6d729 100644 --- a/tests/error/alias_errors/mutual_recursive.err +++ b/tests/error/alias_errors/mutual_recursive.err @@ -6,12 +6,10 @@ Error: Recursive type alias (at $FILE:5:0) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type alias cycle detected: | `Alias1` -> `Alias2` -> `Alias1` -Notes: +Note: | 3 | 4 | Alias1 = guppy.type_alias("Alias1", "Alias2") - | --------------------------------------------- Alias `Alias1` is part of this cycle -5 | Alias2 = guppy.type_alias("Alias2", "Alias1") - | --------------------------------------------- Alias `Alias2` is part of this cycle + | --------------------------------------------- `Alias1` defined here Guppy compilation failed due to 1 previous error diff --git a/tests/error/alias_errors/partial_cycle.err b/tests/error/alias_errors/partial_cycle.err index fa7f3ed56..ff4e2a441 100644 --- a/tests/error/alias_errors/partial_cycle.err +++ b/tests/error/alias_errors/partial_cycle.err @@ -6,12 +6,10 @@ Error: Recursive type alias (at $FILE:6:0) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type alias cycle detected: | `Alias2` -> `Alias3` -> `Alias2` -Notes: +Note: | 4 | Alias1 = guppy.type_alias("Alias1", "Alias2") 5 | Alias2 = guppy.type_alias("Alias2", "Alias3") - | --------------------------------------------- Alias `Alias2` is part of this cycle -6 | Alias3 = guppy.type_alias("Alias3", "Alias2") - | --------------------------------------------- Alias `Alias3` is part of this cycle + | --------------------------------------------- `Alias2` defined here Guppy compilation failed due to 1 previous error From 647dd0010cae087f5626de5b6903af3018fb6917 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 16 Jun 2026 14:35:34 +0100 Subject: [PATCH 10/22] chore: Apply ruff format Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- guppylang/src/guppylang/decorator.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/guppylang/src/guppylang/decorator.py b/guppylang/src/guppylang/decorator.py index adf727ed9..d6e5028de 100644 --- a/guppylang/src/guppylang/decorator.py +++ b/guppylang/src/guppylang/decorator.py @@ -5,7 +5,16 @@ import sys from collections.abc import Callable, Sequence from types import FrameType -from typing import TYPE_CHECKING, Any, NamedTuple, ParamSpec, TypedDict, TypeVar, cast, overload +from typing import ( + TYPE_CHECKING, + Any, + NamedTuple, + ParamSpec, + TypedDict, + TypeVar, + cast, + overload, +) if TYPE_CHECKING and sys.version_info >= (3, 12): from typing import TypeAliasType From 59c1629e8948f2936550c004fd72d8dd2419358e Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 16 Jun 2026 14:45:37 +0100 Subject: [PATCH 11/22] test: Add error test for recursive alias through a struct type argument Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/error/alias_errors/struct_cycle.err | 8 ++++++++ tests/error/alias_errors/struct_cycle.py | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 tests/error/alias_errors/struct_cycle.err create mode 100644 tests/error/alias_errors/struct_cycle.py diff --git a/tests/error/alias_errors/struct_cycle.err b/tests/error/alias_errors/struct_cycle.err new file mode 100644 index 000000000..aec6a1a50 --- /dev/null +++ b/tests/error/alias_errors/struct_cycle.err @@ -0,0 +1,8 @@ +Error: Recursive type alias (at $FILE:14:0) + | +12 | +13 | # Alias whose body refers to itself via a struct type argument +14 | MyAlias = guppy.type_alias("MyAlias", "Box[MyAlias]") + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type alias `MyAlias` expands to itself + +Guppy compilation failed due to 1 previous error diff --git a/tests/error/alias_errors/struct_cycle.py b/tests/error/alias_errors/struct_cycle.py new file mode 100644 index 000000000..2d301a245 --- /dev/null +++ b/tests/error/alias_errors/struct_cycle.py @@ -0,0 +1,22 @@ +from typing import Generic + +from guppylang import guppy + +T = guppy.type_var("T") + + +@guppy.struct +class Box(Generic[T]): + value: T + + +# Alias whose body refers to itself via a struct type argument +MyAlias = guppy.type_alias("MyAlias", "Box[MyAlias]") + + +@guppy +def f(x: MyAlias) -> MyAlias: + return x + + +f.compile_function() From 63b07155e8e92f472c73d1a9f950b730581610ab Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 16 Jun 2026 14:48:14 +0100 Subject: [PATCH 12/22] fix: Move type_alias overloads inside py312 guard to avoid single-overload error On Python <3.12 exactly one @overload was visible, which mypy rejects. Moving both stubs inside sys.version_info >= (3, 12) means mypy falls back to the implementation signature on older Pythons. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- guppylang/src/guppylang/decorator.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/guppylang/src/guppylang/decorator.py b/guppylang/src/guppylang/decorator.py index d6e5028de..e08ec195b 100644 --- a/guppylang/src/guppylang/decorator.py +++ b/guppylang/src/guppylang/decorator.py @@ -380,11 +380,13 @@ def const_var(self, name: str, ty: str) -> TypeVar: # `GuppyDefinition` that pretends to be a TypeVar at runtime return GuppyTypeVarDefinition(defn, TypeVar(name)) # type: ignore[return-value] - @overload - def type_alias(self, name: str, ty: str, params: list[Any] | None = ...) -> Any: ... - if sys.version_info >= (3, 12): + @overload + def type_alias( + self, name: str, ty: str, params: list[Any] | None = ... + ) -> Any: ... + @overload def type_alias(self, name: "TypeAliasType") -> Any: ... From c9b4551718ca1ea884f1199759f3f5f3bc782055 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 16 Jun 2026 15:04:57 +0100 Subject: [PATCH 13/22] fix: Address Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use run_float_fn_approx fixture (not run_int_fn) for float alias test, and rename test to test_float_alias - In _patched_check_instantiate, delete the instance attribute on exit rather than restoring a bound method — ensures method resolution returns cleanly to the class descriptor with no lingering shadow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/guppylang_internals/definition/alias.py | 5 +++-- tests/integration/test_type_alias.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/guppylang-internals/src/guppylang_internals/definition/alias.py b/guppylang-internals/src/guppylang_internals/definition/alias.py index 50f23984d..4f3b8aebf 100644 --- a/guppylang-internals/src/guppylang_internals/definition/alias.py +++ b/guppylang-internals/src/guppylang_internals/definition/alias.py @@ -136,12 +136,13 @@ def _patched_check_instantiate( replacement: Callable[[Sequence[Argument], AstNode | None], Type], ) -> Iterator[None]: """Temporarily override `check_instantiate` for recursive-alias detection.""" - original = defn.check_instantiate object.__setattr__(defn, "check_instantiate", replacement) try: yield finally: - object.__setattr__(defn, "check_instantiate", original) + # Remove the instance attribute so method resolution falls back to the + # class descriptor, restoring the original behaviour cleanly. + object.__delattr__(defn, "check_instantiate") def check_not_recursive(defn: ParsedTypeAliasDef, ctx: TypeParsingCtx) -> None: diff --git a/tests/integration/test_type_alias.py b/tests/integration/test_type_alias.py index 3b69c848b..30a234b25 100644 --- a/tests/integration/test_type_alias.py +++ b/tests/integration/test_type_alias.py @@ -136,15 +136,15 @@ def main() -> int: run_int_fn(main, expected=99) -def test_explicit_name_kwarg(run_int_fn): - """The name= kwarg overrides inferred name and makes the alias usable.""" +def test_float_alias(run_float_fn_approx): + """A concrete alias over a built-in float type is usable in functions.""" MyFloat = guppy.type_alias("MyFloat", "float") @guppy def main(x: MyFloat) -> MyFloat: return x + 1.0 - run_int_fn(main, expected=2.0, args=[1.0]) + run_float_fn_approx(main, expected=2.0, args=[1.0]) # --------------------------------------------------------------------------- From ad3c9b1b582b42dddbecd7e3b3f011e5ba875bd1 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 16 Jun 2026 15:07:24 +0100 Subject: [PATCH 14/22] test: Remove redundant float alias test test_float_alias duplicated test_alias_chain with a different scalar type; the original reason (validating a name= kwarg fallback) no longer exists with the explicit-name API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/integration/test_type_alias.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/integration/test_type_alias.py b/tests/integration/test_type_alias.py index 30a234b25..35d643fc9 100644 --- a/tests/integration/test_type_alias.py +++ b/tests/integration/test_type_alias.py @@ -136,17 +136,6 @@ def main() -> int: run_int_fn(main, expected=99) -def test_float_alias(run_float_fn_approx): - """A concrete alias over a built-in float type is usable in functions.""" - MyFloat = guppy.type_alias("MyFloat", "float") - - @guppy - def main(x: MyFloat) -> MyFloat: - return x + 1.0 - - run_float_fn_approx(main, expected=2.0, args=[1.0]) - - # --------------------------------------------------------------------------- # Struct / enum interaction tests # --------------------------------------------------------------------------- From 9baaa9b0e3ea074121eeb35998dfd6e11e126ac3 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Thu, 18 Jun 2026 15:29:30 +0100 Subject: [PATCH 15/22] feat: Remove Python 3.12 type statement syntax for aliases The PEP 695 `type X[T] = "..."` path added little: users still had to wrap variables in `guppy.type_var`, and const vars could not be expressed through it at all. Drop it in favour of the explicit `type_alias(name, ty, params=...)` API. This removes the version-guarded overloads, the `TypeAliasType` handling, `_type_alias_from_type_stmt`, `_params_from_type_alias_params`, and the dedicated py312 test module. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- guppylang/src/guppylang/decorator.py | 160 +-------------------- tests/integration/test_type_alias_py312.py | 103 ------------- 2 files changed, 2 insertions(+), 261 deletions(-) delete mode 100644 tests/integration/test_type_alias_py312.py diff --git a/guppylang/src/guppylang/decorator.py b/guppylang/src/guppylang/decorator.py index e08ec195b..82b720558 100644 --- a/guppylang/src/guppylang/decorator.py +++ b/guppylang/src/guppylang/decorator.py @@ -2,11 +2,9 @@ import builtins import inspect import linecache -import sys from collections.abc import Callable, Sequence from types import FrameType from typing import ( - TYPE_CHECKING, Any, NamedTuple, ParamSpec, @@ -16,9 +14,6 @@ overload, ) -if TYPE_CHECKING and sys.version_info >= (3, 12): - from typing import TypeAliasType - from guppylang_internals.ast_util import annotate_location from guppylang_internals.definition.alias import RawTypeAliasDef from guppylang_internals.definition.common import DefId @@ -46,6 +41,7 @@ from guppylang_internals.metadata.common import FunctionMetadata from guppylang_internals.span import Loc, SourceMap, Span from guppylang_internals.tracing.util import hide_trace +from guppylang_internals.tys.param import Parameter from guppylang_internals.tys.ty import ( FunctionType, NoneType, @@ -380,19 +376,7 @@ def const_var(self, name: str, ty: str) -> TypeVar: # `GuppyDefinition` that pretends to be a TypeVar at runtime return GuppyTypeVarDefinition(defn, TypeVar(name)) # type: ignore[return-value] - if sys.version_info >= (3, 12): - - @overload - def type_alias( - self, name: str, ty: str, params: list[Any] | None = ... - ) -> Any: ... - - @overload - def type_alias(self, name: "TypeAliasType") -> Any: ... - - def type_alias( - self, name: str | Any, ty: str | None = None, params: list[Any] | None = None - ) -> Any: + def type_alias(self, name: str, ty: str, params: list[Any] | None = None) -> Any: """Creates a new type alias. .. code-block:: python @@ -417,36 +401,14 @@ def sum_row(row: Row) -> int: When ``params`` is omitted, free type variables are collected from the body in order of first appearance. - - On Python 3.12+, the PEP 695 ``type`` statement syntax is also supported. - The type alias value must be a quoted string and type parameters are inferred - from the ``[...]`` parameter list on the statement: - - .. code-block:: python - - # Python 3.12+ only - type Pair[T, U] = "tuple[T, U]" - Pair = guppy.type_alias(Pair) """ frame = get_calling_frame() - # Python 3.12+ path: accept a TypeAliasType from the `type X[T] = "..."` stmt - if sys.version_info >= (3, 12): - from typing import TypeAliasType - - if isinstance(name, TypeAliasType): - return self._type_alias_from_type_stmt(name, frame) - if not isinstance(name, str): raise TypeError( f"guppy.type_alias() expects a name string as the first argument, " f"got {name!r}" ) - if ty is None: - raise TypeError( - "guppy.type_alias() requires a type string as the second argument: " - f"guppy.type_alias({name!r}, 'MyType')" - ) if not isinstance(ty, str): raise TypeError( f"guppy.type_alias() expects a string type expression, got {ty!r}" @@ -468,39 +430,6 @@ def sum_row(row: Row) -> int: DEF_STORE.register_def(defn, frame) return GuppyDefinition(defn) - def _type_alias_from_type_stmt(self, ta: Any, frame: FrameType | None) -> Any: - """Register a type alias created from a Python 3.12 ``type`` statement. - - The alias value must be a quoted string, e.g.: - - .. code-block:: python - - type Pair[T, U] = "tuple[T, U]" - Pair = guppy.type_alias(Pair) - """ - type_str = ta.__value__ - if not isinstance(type_str, str): - raise TypeError( - f"The value of a Guppy type alias must be a quoted string, " - f"got {type_str!r}.\n" - "Hint: write the type as a string, e.g. " - f'`type {ta.__name__}[...] = "MyType[T]"`' - ) - type_ast = _parse_expr_string( - type_str, f"Not a valid Guppy type: `{type_str}`", DEF_STORE.sources - ) - explicit_params = _params_from_type_alias_params(ta.__type_params__) - defn = RawTypeAliasDef( - DefId.fresh(), - ta.__name__, - type_ast, - type_ast, - explicit_params, - ) - assert frame is not None, "Could not determine calling frame for type alias" - DEF_STORE.register_def(defn, frame) - return GuppyDefinition(defn) - @overload def declare( self, /, **kwargs: Unpack[GuppyKwargs] @@ -967,88 +896,3 @@ def _params_from_list(params: list[Any]) -> list[Parameter]: ) result.append(defn.to_param(i)) return result - - -def _params_from_type_alias_params(type_params: tuple[Any, ...]) -> list[Parameter]: - """Convert Python 3.12+ ``type`` statement type params to guppy Parameters. - - Handles ``TypeVar`` with optional ``Copy``/``Drop`` bounds. Raises - ``TypeError`` for unsupported parameter kinds (``TypeVarTuple``, - ``ParamSpec``) and for bounds that require ``globals`` to resolve (e.g. - ``nat``-const params — use ``params=[N]`` for those). - """ - import typing - - from guppylang_internals.tys.param import TypeParam - - from guppylang.std.lang import Copy, Drop - - # TypeVarTuple was added in Python 3.11; ParamSpec in 3.10. - _TypeVarTuple: type | None = getattr(typing, "TypeVarTuple", None) - - result: list[Parameter] = [] - for i, tp in enumerate(type_params): - if (_TypeVarTuple is not None and isinstance(tp, _TypeVarTuple)) or isinstance( - tp, typing.ParamSpec - ): - raise TypeError( - "Variadic and ParamSpec type parameters are not supported " - "in Guppy type aliases." - ) - if not isinstance(tp, typing.TypeVar): - raise TypeError( - f"Unsupported type parameter {tp!r} in Guppy type alias. " - "Only TypeVar is supported." - ) - # Python 3.14+ uses __constraints__ for `T: (A, B)` style; - # Python 3.12/3.13 may use __bound__ for `T: A`. - constraints: tuple[Any, ...] = getattr(tp, "__constraints__", ()) - bound = tp.__bound__ - if constraints: - # `T: (Copy, Drop)` or similar tuple of constraints - must_copy = any(b is Copy for b in constraints) - must_drop = any(b is Drop for b in constraints) - unknown = [b for b in constraints if b is not Copy and b is not Drop] - if unknown: - raise TypeError( - f"Type parameter constraints {unknown!r} are not supported " - "in the ``type`` statement syntax for Guppy type aliases. " - "For const parameters (e.g. `nat`), use the explicit " - "``params=[N]`` argument instead." - ) - param: Parameter = TypeParam( - i, - tp.__name__, - must_be_copyable=must_copy, - must_be_droppable=must_drop, - ) - elif bound is None: - param = TypeParam( - i, tp.__name__, must_be_copyable=False, must_be_droppable=False - ) - elif bound is Copy: - param = TypeParam( - i, tp.__name__, must_be_copyable=True, must_be_droppable=False - ) - elif bound is Drop: - param = TypeParam( - i, tp.__name__, must_be_copyable=False, must_be_droppable=True - ) - elif isinstance(bound, tuple): - must_copy = any(b is Copy for b in bound) - must_drop = any(b is Drop for b in bound) - param = TypeParam( - i, - tp.__name__, - must_be_copyable=must_copy, - must_be_droppable=must_drop, - ) - else: - raise TypeError( - f"Type parameter bound `{bound!r}` is not supported in the " - "``type`` statement syntax for Guppy type aliases. " - "For const parameters (e.g. `nat`), use the explicit " - "``params=[N]`` argument instead." - ) - result.append(param) - return result diff --git a/tests/integration/test_type_alias_py312.py b/tests/integration/test_type_alias_py312.py deleted file mode 100644 index 6cd8923df..000000000 --- a/tests/integration/test_type_alias_py312.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Tests for Python 3.12+ `type` statement syntax with guppy.type_alias.""" - -from typing import Generic - -from guppylang import guppy -from guppylang.std.lang import Copy, Drop - - -def test_simple_alias_from_type_stmt(run_int_fn): - """A plain `type X = "..."` alias works just like the string form.""" - type MyInt = "int" - MyInt = guppy.type_alias(MyInt) - - @guppy - def main(x: MyInt) -> MyInt: - return x + 1 - - run_int_fn(main, expected=42, args=[41]) - - -def test_generic_alias_from_type_stmt(run_int_fn): - """Generic type params on the `type` statement become guppy TypeParams. - - `T: (Copy, Drop)` in the ``type`` statement corresponds to the default - ``guppy.type_var("T")`` (copyable=True, droppable=True). - """ - T = guppy.type_var("T") # copyable=True, droppable=True by default - - @guppy.struct - class Box(Generic[T]): - value: T - - # T: (Copy, Drop) matches the default guppy.type_var("T") constraints - type Boxed[T: (Copy, Drop)] = "Box[T]" - Boxed = guppy.type_alias(Boxed) - - @guppy - def unwrap(b: Boxed[int]) -> int: - return b.value - - @guppy - def main() -> int: - return unwrap(Box(99)) - - run_int_fn(main, expected=99) - - -def test_two_param_alias_from_type_stmt(run_int_fn): - """Two type parameters are registered in declaration order.""" - A = guppy.type_var("A") # copyable=True, droppable=True by default - B = guppy.type_var("B") - - @guppy.struct - class Pair(Generic[A, B]): - first: A - second: B - - # A: (Copy, Drop), B: (Copy, Drop) to match the struct's constraints - type SwappedPair[A: (Copy, Drop), B: (Copy, Drop)] = "Pair[B, A]" - SwappedPair = guppy.type_alias(SwappedPair) - - @guppy - def main() -> int: - # SwappedPair[int, bool] → Pair[bool, int], so .second is int - sp: SwappedPair[int, bool] = Pair(True, 42) - return sp.second - - run_int_fn(main, expected=42) - - -def test_copy_bound_from_type_stmt(validate): - """`T: Copy` bound is correctly translated to a copyable TypeParam.""" - # Use copyable=True, droppable=False so the struct matches T: Copy - T = guppy.type_var("T", copyable=True, droppable=False) - - @guppy.struct - class Container(Generic[T]): - item: T - - # T: Copy → must_be_copyable=True, must_be_droppable=False - type CopyAlias[T: Copy] = "Container[T]" - CopyAlias = guppy.type_alias(CopyAlias) - - @guppy - def duplicate(c: CopyAlias[int]) -> tuple[int, int]: - return c.item, c.item - - validate(duplicate.compile_function()) - - -def test_chain_via_type_stmt(run_int_fn): - """A `type` statement alias that chains through another alias resolves correctly.""" - type Base = "int" - Base = guppy.type_alias(Base) - - type Derived = "Base" - Derived = guppy.type_alias(Derived) - - @guppy - def main(x: Derived) -> Base: - return x * 2 - - run_int_fn(main, expected=10, args=[5]) From 8b4b169bca1cb681c51ebc6022792c2d76d72c65 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Thu, 18 Jun 2026 15:34:14 +0100 Subject: [PATCH 16/22] feat: Support const_var params in type aliases via deferred resolution `_params_from_list` now validates that each entry is a type variable (`type_var`, `nat_var`, or `const_var`) and returns the underlying `ParamDef`s rather than eagerly converting them to `Parameter`s. This lets `const_var` params through: their type is an unparsed AST that can only be resolved once globals are available. `ParsedTypeAliasDef` now carries the `ParamDef`s in a `param_defs` field and resolves them to `Parameter`s in `check()`, parsing `RawConstVarDef`s into `ConstVarDef`s along the way. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../guppylang_internals/definition/alias.py | 33 ++++++++++++---- guppylang/src/guppylang/decorator.py | 38 ++++++++----------- tests/integration/test_type_alias.py | 22 +++++++++++ 3 files changed, 63 insertions(+), 30 deletions(-) diff --git a/guppylang-internals/src/guppylang_internals/definition/alias.py b/guppylang-internals/src/guppylang_internals/definition/alias.py index 4f3b8aebf..1c0700cea 100644 --- a/guppylang-internals/src/guppylang_internals/definition/alias.py +++ b/guppylang-internals/src/guppylang_internals/definition/alias.py @@ -13,6 +13,7 @@ DefId, ParsableDef, ) +from guppylang_internals.definition.parameter import ParamDef, RawConstVarDef from guppylang_internals.definition.ty import TypeDef from guppylang_internals.diagnostic import Error, Note from guppylang_internals.engine import DEF_STORE @@ -54,7 +55,7 @@ class RawTypeAliasDef(TypeDef, ParsableDef): """A raw type alias definition that has not been parsed yet.""" type_ast: ast.expr - explicit_params: Sequence[Parameter] | None = None + explicit_params: Sequence[ParamDef] | None = None params: None = field(default=None, init=False) description: str = field(default="type alias", init=False) @@ -73,26 +74,42 @@ def check_instantiate( raise InternalGuppyError("Tried to instantiate raw type alias definition") +def _resolve_param(defn: ParamDef, idx: int, globals: Globals) -> Parameter: + """Convert a parameter definition to a positional :class:`Parameter`. + + ``const_var`` definitions arrive unparsed (their type is still a raw AST), so we + parse them here, where the ``globals`` needed to resolve the type are available. + """ + if isinstance(defn, RawConstVarDef): + defn = defn.parse(globals, DEF_STORE.sources) + return defn.to_param(idx) + + @dataclass(frozen=True) class ParsedTypeAliasDef(TypeDef, CheckableDef): """A type alias definition whose target type has not been checked yet.""" - params: Sequence[Parameter] | None + param_defs: Sequence[ParamDef] | None type_ast: ast.expr + params: None = field(default=None, init=False) description: str = field(default="type alias", init=False) def check(self, globals: Globals) -> "CheckedTypeAliasDef": - if self.params is not None: - # Explicit params: re-index them and pre-load into the context so that - # type vars in the body are resolved to these parameters in order. - reindexed = [p.with_idx(i) for i, p in enumerate(self.params)] - param_var_mapping = {p.name: p for p in reindexed} + if self.param_defs is not None: + # Explicit params: resolve each definition to a parameter (parsing + # `const_var` types now that globals are available) and pre-load them + # into the context so that variables in the body bind to these params + # in order. + resolved = [ + _resolve_param(p, i, globals) for i, p in enumerate(self.param_defs) + ] + param_var_mapping = {p.name: p for p in resolved} check_not_recursive( self, TypeParsingCtx(globals, param_var_mapping=dict(param_var_mapping)) ) ctx = TypeParsingCtx(globals, param_var_mapping=param_var_mapping) ty = type_from_ast(self.type_ast, ctx) - params = tuple(reindexed) + params = tuple(resolved) else: # Implicit: collect free type vars from the body in order of appearance. check_not_recursive(self, TypeParsingCtx(globals, allow_free_vars=True)) diff --git a/guppylang/src/guppylang/decorator.py b/guppylang/src/guppylang/decorator.py index 82b720558..d8bbc9dfd 100644 --- a/guppylang/src/guppylang/decorator.py +++ b/guppylang/src/guppylang/decorator.py @@ -26,6 +26,7 @@ from guppylang_internals.definition.overloaded import OverloadedFunctionDef from guppylang_internals.definition.parameter import ( ConstVarDef, + ParamDef, RawConstVarDef, TypeVarDef, ) @@ -41,7 +42,6 @@ from guppylang_internals.metadata.common import FunctionMetadata from guppylang_internals.span import Loc, SourceMap, Span from guppylang_internals.tracing.util import hide_trace -from guppylang_internals.tys.param import Parameter from guppylang_internals.tys.ty import ( FunctionType, NoneType, @@ -417,7 +417,7 @@ def sum_row(row: Row) -> int: type_ast = _parse_expr_string( ty, f"Not a valid Guppy type: `{ty}`", DEF_STORE.sources ) - explicit_params: Sequence[Parameter] | None = ( + explicit_params: Sequence[ParamDef] | None = ( _params_from_list(params) if params is not None else None ) defn = RawTypeAliasDef( @@ -870,29 +870,23 @@ def _parse_kwargs(kwargs: GuppyKwargs) -> ParsedGuppyKwargs: guppy = cast("_Guppy", _DummyGuppy()) if sphinx_running() else _Guppy() -def _params_from_list(params: list[Any]) -> list[Parameter]: - """Extract :class:`~guppylang_internals.tys.param.Parameter` objects from a list of - Guppy type-variable definitions (e.g. results of :func:`guppy.type_var`). +def _params_from_list(params: list[Any]) -> list[ParamDef]: + """Validate a list of Guppy type-variable definitions for use as alias params. - The index of each parameter is set to its position in the list so that - ``Alias[int, bool]`` binds the first param to ``int`` and the second to ``bool``. + Each entry must be a type variable created with :func:`guppy.type_var`, + :func:`guppy.nat_var`, or :func:`guppy.const_var`. The underlying + :class:`~guppylang_internals.definition.parameter.ParamDef`\\ s are returned in + order; they are converted to :class:`~guppylang_internals.tys.param.Parameter`\\ s + later (in :meth:`ParsedTypeAliasDef.check`) where the globals needed to resolve + ``const_var`` types are available. """ - from guppylang_internals.definition.parameter import ParamDef, RawConstVarDef - - from guppylang.defs import GuppyDefinition - - result = [] - for i, p in enumerate(params): - if not isinstance(p, GuppyDefinition): - raise TypeError( - "type_alias params must be type variables created with " - f"guppy.type_var() or guppy.nat_var(), got {p!r}" - ) - defn = p.wrapped - if not isinstance(defn, ParamDef) or isinstance(defn, RawConstVarDef): + result: list[ParamDef] = [] + for p in params: + defn = p.wrapped if isinstance(p, GuppyDefinition) else None + if not isinstance(defn, ParamDef): raise TypeError( "type_alias params must be type variables created with " - "guppy.type_var() or guppy.nat_var()" + f"guppy.type_var(), guppy.nat_var(), or guppy.const_var(), got {p!r}" ) - result.append(defn.to_param(i)) + result.append(defn) return result diff --git a/tests/integration/test_type_alias.py b/tests/integration/test_type_alias.py index 35d643fc9..a52069060 100644 --- a/tests/integration/test_type_alias.py +++ b/tests/integration/test_type_alias.py @@ -136,6 +136,28 @@ def main() -> int: run_int_fn(main, expected=99) +def test_const_var_alias(run_int_fn): + """Generic aliases can be parameterised by const variables.""" + B = guppy.const_var("B", "bool") + + @guppy.struct + class Flagged(Generic[B]): + value: int + + # Alias parameterised by a const var; resolved lazily when the alias is checked. + MyFlagged = guppy.type_alias("MyFlagged", "Flagged[B]", params=[B]) + + @guppy + def get_value(f: MyFlagged[True]) -> int: + return f.value + + @guppy + def main() -> int: + return get_value(Flagged(7)) + + run_int_fn(main, expected=7) + + # --------------------------------------------------------------------------- # Struct / enum interaction tests # --------------------------------------------------------------------------- From 3ca3df6b6c13ce7affa36003698a974b3317ffc1 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Thu, 18 Jun 2026 15:35:30 +0100 Subject: [PATCH 17/22] refactor: Use a single TypeParsingCtx when checking type aliases The recursion check and the real type parse now share one context instead of constructing two and copying the param mapping between them. The explicit-param mapping is never mutated and the implicit pass re-collects free vars by name, so sharing the context is safe and drops the redundant `dict(...)` copy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/guppylang_internals/definition/alias.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/guppylang-internals/src/guppylang_internals/definition/alias.py b/guppylang-internals/src/guppylang_internals/definition/alias.py index 1c0700cea..792fd5c2f 100644 --- a/guppylang-internals/src/guppylang_internals/definition/alias.py +++ b/guppylang-internals/src/guppylang_internals/definition/alias.py @@ -103,17 +103,16 @@ def check(self, globals: Globals) -> "CheckedTypeAliasDef": resolved = [ _resolve_param(p, i, globals) for i, p in enumerate(self.param_defs) ] - param_var_mapping = {p.name: p for p in resolved} - check_not_recursive( - self, TypeParsingCtx(globals, param_var_mapping=dict(param_var_mapping)) + ctx = TypeParsingCtx( + globals, param_var_mapping={p.name: p for p in resolved} ) - ctx = TypeParsingCtx(globals, param_var_mapping=param_var_mapping) + check_not_recursive(self, ctx) ty = type_from_ast(self.type_ast, ctx) params = tuple(resolved) else: # Implicit: collect free type vars from the body in order of appearance. - check_not_recursive(self, TypeParsingCtx(globals, allow_free_vars=True)) ctx = TypeParsingCtx(globals, allow_free_vars=True) + check_not_recursive(self, ctx) ty = type_from_ast(self.type_ast, ctx) params = tuple(ctx.param_var_mapping.values()) return CheckedTypeAliasDef( From 538bb1a12492916bd76dc41c7e43827a509e7cb0 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Thu, 18 Jun 2026 15:37:15 +0100 Subject: [PATCH 18/22] refactor: Tidy cycle-note construction for recursive aliases Move the `get_file`/`to_span` imports to module top, derive the error's file with `to_span(err.span).file` instead of a bespoke helper, and drop the dead `err.children` dedup seed (the error has no children yet when notes are added). The same-file / non-None file guard is kept and documented: both `add_sub_diagnostic` and `to_span` require notes to carry a matching file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../guppylang_internals/definition/alias.py | 58 +++++++------------ 1 file changed, 21 insertions(+), 37 deletions(-) diff --git a/guppylang-internals/src/guppylang_internals/definition/alias.py b/guppylang-internals/src/guppylang_internals/definition/alias.py index 792fd5c2f..295316bb8 100644 --- a/guppylang-internals/src/guppylang_internals/definition/alias.py +++ b/guppylang-internals/src/guppylang_internals/definition/alias.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from typing import ClassVar -from guppylang_internals.ast_util import AstNode +from guppylang_internals.ast_util import AstNode, get_file from guppylang_internals.checker.core import Globals from guppylang_internals.definition.common import ( CheckableDef, @@ -18,7 +18,7 @@ from guppylang_internals.diagnostic import Error, Note from guppylang_internals.engine import DEF_STORE from guppylang_internals.error import GuppyError, InternalGuppyError -from guppylang_internals.span import SourceMap +from guppylang_internals.span import SourceMap, to_span from guppylang_internals.tys.arg import Argument from guppylang_internals.tys.param import Parameter, check_all_args from guppylang_internals.tys.parsing import TypeParsingCtx, type_from_ast @@ -211,42 +211,26 @@ def _add_alias_notes_for_cycle( Cross-file or un-annotated spans are silently skipped; the cycle chain in the main error's span label is still fully informative on its own. """ - import ast as _ast - - from guppylang_internals.ast_util import get_file - from guppylang_internals.span import Span - - def _span_file(node: _ast.AST | Span | None) -> str | None: - """Return the filename for either a Span or an annotated AST node.""" - if node is None: - return None - if isinstance(node, Span): - return node.file - return get_file(node) - unique_defs = cycle_defs[:-1] # drop the repeated last element if len(unique_defs) <= 1: return - # Determine the file that the main error is anchored to (may be None if unset) - err_file: str | None = _span_file(err.span) - - # Use DefId for deduplication so that aliases with identical names don't collide. - seen_ids: set[DefId] = { - child.defn_id - for child in err.children - if isinstance(child, RecursiveTypeAliasError.AliasNote) - } - # The last element of unique_defs is the alias whose definition the error span - # already underlines — skip it to avoid a redundant note on the same line. - defs_to_annotate = unique_defs[:-1] - for defn in defs_to_annotate: - if defn.id not in seen_ids and defn.defined_at is not None: - # Skip if the AST node lacks file annotation or is from a different file - note_file = get_file(defn.defined_at) - if note_file is None or note_file != err_file: - continue - seen_ids.add(defn.id) - err.add_sub_diagnostic( - RecursiveTypeAliasError.AliasNote(defn.defined_at, defn.name, defn.id) - ) + # File the main error is anchored to. `add_sub_diagnostic` requires every note to + # share this file, and `to_span` requires the span to carry a file, so definitions + # without a matching file annotation are skipped below. + err_file = to_span(err.span).file if err.span is not None else None + + # The last element of `unique_defs` is the alias whose definition the error span + # already underlines — skip it to avoid a redundant note on the same line. Use + # DefId for deduplication so that aliases with identical names don't collide. + seen_ids: set[DefId] = set() + for defn in unique_defs[:-1]: + if defn.id in seen_ids or defn.defined_at is None: + continue + note_file = get_file(defn.defined_at) + if note_file is None or note_file != err_file: + continue + seen_ids.add(defn.id) + err.add_sub_diagnostic( + RecursiveTypeAliasError.AliasNote(defn.defined_at, defn.name, defn.id) + ) From 38631d324e1a29795f95cbe8fd6499c674ff6edd Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Thu, 18 Jun 2026 15:38:12 +0100 Subject: [PATCH 19/22] refactor: Revert unrelated linecache change in _parse_expr_string The linecache fallback was added for the since-removed name-inference work and is unrelated to type aliases. Restore the original `inspect.getsourcelines` based implementation; the alias error tests (including the cross-file `partial_cycle` case) still pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- guppylang/src/guppylang/decorator.py | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/guppylang/src/guppylang/decorator.py b/guppylang/src/guppylang/decorator.py index d8bbc9dfd..1b08798f6 100644 --- a/guppylang/src/guppylang/decorator.py +++ b/guppylang/src/guppylang/decorator.py @@ -1,7 +1,6 @@ import ast import builtins import inspect -import linecache from collections.abc import Callable, Sequence from types import FrameType from typing import ( @@ -699,28 +698,14 @@ def _parse_expr_string(ty_str: str, parse_err: str, sources: SourceMap) -> ast.e raise SyntaxError(parse_err) from None # Try to annotate the type AST with source information. This requires us to - # inspect the stack frame of the caller. + # inspect the stack frame of the caller if caller_frame := get_calling_frame(): info = inspect.getframeinfo(caller_frame) - filename = info.filename - # Prefer getsourcelines (works for real files and importable modules), but - # fall back to linecache for interactive contexts like `python -c` or REPLs - # where the module may not be importable via inspect.getmodule(). - source_lines: list[str] | None = None if caller_module := inspect.getmodule(caller_frame): - try: - raw_lines, _ = inspect.getsourcelines(caller_module) - source_lines = raw_lines - except OSError: - pass - if source_lines is None: - source_lines = linecache.getlines(filename) or None - if source_lines is not None: + sources.add_file(info.filename) + source_lines, _ = inspect.getsourcelines(caller_module) source = "".join(source_lines) - # Use explicit source content so the diagnostic renderer can always find - # the file's lines even when linecache hasn't loaded this file yet. - sources.add_file(filename, source) - annotate_location(expr_ast, source, filename, 1) + annotate_location(expr_ast, source, info.filename, 1) # Modify the AST so that all sub-nodes span the entire line. We # can't give a better location since we don't know the column # offset of the `ty` argument From 8e384cbce5913fcc4cc658f1901457861372d52a Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Thu, 18 Jun 2026 15:39:02 +0100 Subject: [PATCH 20/22] test: Add error tests for direct type_alias argument errors Cover the `TypeError`s raised by `guppy.type_alias` itself: a non-string name, a non-string type expression, a missing type argument, and invalid `params` entries (a plain value and a non-type-variable definition). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/error/test_alias_errors.py | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/error/test_alias_errors.py b/tests/error/test_alias_errors.py index fe916e3f2..b67e58d56 100644 --- a/tests/error/test_alias_errors.py +++ b/tests/error/test_alias_errors.py @@ -24,3 +24,39 @@ def test_alias_errors(file, capsys, snapshot): def test_type_alias_bad_type_syntax(): with pytest.raises(SyntaxError, match="Not a valid Guppy type: `foo bar`"): guppy.type_alias("MyAlias", "foo bar") + + +def test_type_alias_non_str_name(): + with pytest.raises(TypeError, match="expects a name string as the first argument"): + guppy.type_alias(123, "int") # type: ignore[arg-type] + + +def test_type_alias_missing_type(): + with pytest.raises(TypeError, match="missing 1 required positional argument"): + guppy.type_alias("MyAlias") # type: ignore[call-arg] + + +def test_type_alias_non_str_type(): + with pytest.raises(TypeError, match="expects a string type expression"): + guppy.type_alias("MyAlias", 123) # type: ignore[arg-type] + + +def test_type_alias_invalid_param(): + with pytest.raises( + TypeError, + match="type_alias params must be type variables created with", + ): + guppy.type_alias("MyAlias", "int", params=["not a type var"]) + + +def test_type_alias_param_not_a_param_def(): + # A `GuppyDefinition` that isn't a type variable (e.g. a struct) is rejected. + @guppy.struct + class SomeStruct: + x: int + + with pytest.raises( + TypeError, + match="type_alias params must be type variables created with", + ): + guppy.type_alias("MyAlias", "int", params=[SomeStruct]) From 12a1c8a4e69476c02155748bfa45df3c7cb0256d Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Fri, 19 Jun 2026 12:30:23 +0100 Subject: [PATCH 21/22] fix: Assert note_file is not None rather than silently skipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If `defn.defined_at` is set, `get_file` should always return a filename — a None here would indicate a bug in how the AST was annotated, not a graceful fallback case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/guppylang_internals/definition/alias.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/guppylang-internals/src/guppylang_internals/definition/alias.py b/guppylang-internals/src/guppylang_internals/definition/alias.py index 295316bb8..098f323ef 100644 --- a/guppylang-internals/src/guppylang_internals/definition/alias.py +++ b/guppylang-internals/src/guppylang_internals/definition/alias.py @@ -216,8 +216,7 @@ def _add_alias_notes_for_cycle( return # File the main error is anchored to. `add_sub_diagnostic` requires every note to - # share this file, and `to_span` requires the span to carry a file, so definitions - # without a matching file annotation are skipped below. + # share this file, so definitions from a different file are skipped below. err_file = to_span(err.span).file if err.span is not None else None # The last element of `unique_defs` is the alias whose definition the error span @@ -228,7 +227,10 @@ def _add_alias_notes_for_cycle( if defn.id in seen_ids or defn.defined_at is None: continue note_file = get_file(defn.defined_at) - if note_file is None or note_file != err_file: + assert note_file is not None, ( + f"defined_at node for alias `{defn.name}` has no file annotation" + ) + if note_file != err_file: continue seen_ids.add(defn.id) err.add_sub_diagnostic( From 0a6f59eedf6c951ebe014e1e2239f708e300da02 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Fri, 19 Jun 2026 12:35:23 +0100 Subject: [PATCH 22/22] refactor: Drop redundant runtime guards covered by the type signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `type_alias(name: str, ty: str, ...)` statically forbids non-string arguments and a missing `ty`. The three isinstance checks and their corresponding tests just reproduced what mypy and Python's own argument machinery already enforce — remove them and keep only the genuinely runtime errors (bad type syntax, invalid params). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- guppylang/src/guppylang/decorator.py | 10 ---------- tests/error/test_alias_errors.py | 15 --------------- 2 files changed, 25 deletions(-) diff --git a/guppylang/src/guppylang/decorator.py b/guppylang/src/guppylang/decorator.py index 1b08798f6..61f60ad20 100644 --- a/guppylang/src/guppylang/decorator.py +++ b/guppylang/src/guppylang/decorator.py @@ -403,16 +403,6 @@ def sum_row(row: Row) -> int: """ frame = get_calling_frame() - if not isinstance(name, str): - raise TypeError( - f"guppy.type_alias() expects a name string as the first argument, " - f"got {name!r}" - ) - if not isinstance(ty, str): - raise TypeError( - f"guppy.type_alias() expects a string type expression, got {ty!r}" - ) - type_ast = _parse_expr_string( ty, f"Not a valid Guppy type: `{ty}`", DEF_STORE.sources ) diff --git a/tests/error/test_alias_errors.py b/tests/error/test_alias_errors.py index b67e58d56..f62eff754 100644 --- a/tests/error/test_alias_errors.py +++ b/tests/error/test_alias_errors.py @@ -26,21 +26,6 @@ def test_type_alias_bad_type_syntax(): guppy.type_alias("MyAlias", "foo bar") -def test_type_alias_non_str_name(): - with pytest.raises(TypeError, match="expects a name string as the first argument"): - guppy.type_alias(123, "int") # type: ignore[arg-type] - - -def test_type_alias_missing_type(): - with pytest.raises(TypeError, match="missing 1 required positional argument"): - guppy.type_alias("MyAlias") # type: ignore[call-arg] - - -def test_type_alias_non_str_type(): - with pytest.raises(TypeError, match="expects a string type expression"): - guppy.type_alias("MyAlias", 123) # type: ignore[arg-type] - - def test_type_alias_invalid_param(): with pytest.raises( TypeError,