Skip to content

Syntax error for custom type annotations #513

Description

@Qwlouse

Things to check first

  • I have searched the existing issues and didn't find my bug already reported there

  • I have checked that my bug is still present in the latest release

Typeguard version

4.4.1

Python version

3.11

What happened?

typeguard.typechecked fails with a syntax error for annotations that use item access with a string that is not eval-able. Basic example:

import typeguard
annot = {"a b": int, "c d": str}

@typeguard.typechecked
def foo1(x: annot["a b"]) -> int:
  return 10

yields

File "<unknown>", line 1
    a b
      ^
SyntaxError: invalid syntax

This problem occurs with custom annotation types like for example jaxtyping which uses annotations like Float[np.ndarray, "H W C"] to annotate shape information. The problem was previously raised in #353 for the special case of Annotated. The fix from be4dd33 solves this for plain Annotated, but is quite brittle. For example this fails:

import typing

MyAnnotated = typing.Annotated  # simple alias

@typeguard.typechecked
def foo2(x: MyAnnotated[int, "a b"]) -> int:
  return 10

The problem in both cases is that the instrumentation tries to evaluate "a b" as a forward reference which leads to a syntax error. I don't think this is the desired behaviour. Custom annotations are allowed by python and should not lead to errors. For reference, the typing.get_type_hints() function succeeds in both cases:

typing.get_type_hints(foo1)
# {'x': int, 'return': int}

typing.get_type_hints(foo2)
# {'x': int, 'return': int}

I suggest to solve this problem by changing the instrumented code for a function to use typing.get_type_hints() rather than trying to manually catch all corner cases during the AST parsing.
Currently the instrumented code for a simple function looks like this:

def bar(x: int, y: str) -> None:
  ...
  return

# ---- instrumented ---
def bar(x: int, y: str) -> None:
    from typeguard import TypeCheckMemo
    from typeguard._functions import check_argument_types, check_return_type
    memo = TypeCheckMemo(globals(), locals())
    check_argument_types('bar', {'x': (x, int), 'y': (y, str)}, memo)
    ...
    return check_return_type('bar', None, None, memo)

The problem could be fixed if instead trying to resolve the forward references during AST parsing, the check_arguments_types function simply called typing.get_type_hints(). So with a new check_argument_types_v2:

def check_argument_types_v2(fun, args, memo):
  name = fun.__name__
  hints = typing.get_type_hints(fun)
  annotated_arguments = {
        k: (v, hints[k])
        for k, v in args.items()
        if k in hints
  }
  return check_argument_types(name, annotated_arguments, memo)

The instrumented code could become:

def bar(x: int, y: str) -> None:
    from typeguard import TypeCheckMemo
    from typeguard._functions import check_argument_types, check_return_type
    memo = TypeCheckMemo(globals(), locals())
    check_argument_types_2(bar, {'x': x, 'y': y}, memo)
    ...
    return check_return_type('bar', None, None, memo)  # (should probably do the same here)

This should avoid all the above edge-cases and be pretty straightforward to implement.
I have a proof-of-concept working. Happy to submit it as a PR if that is wanted (though I am not very familiar with the AST-parsing code, and I don't feel confident in my edits).

How can we reproduce the bug?

import typeguard
annot = {"a b": int}

@typeguard.typechecked
def foo1(x: annot["a b"]) -> int:
  return 10

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions