agronholm / typeguard

Run-time type checker for Python
Other
1.52k stars 114 forks source link

typeguard is unable to check for explicit UnionType class #467

Open MyKo101 opened 4 months ago

MyKo101 commented 4 months ago

Things to check first

Typeguard version

4.3.0

Python version

3.10

What happened?

When trying to check if an object is of the type UnionType, typeguard rejects regardless of whether the type matches. It is important to note that I am not checking if an object is one of multiple types (using, for example does "hello" match int | str), I am looking at whether an object is a UnionType (for example, if int | str is a UnionType). See the MWE for more information and to make clearer what my code is doing.

When using the | operator when defining type-hints, it will either generate a Union or UnionType type object. If the two objects you are combining are standard types, then it will create UnionType but if they are complex constructs (such as a Literal) then it will create a Union object. I am not sure if it is as cut-and-dry as this, but this seems to be the general behaviour.

These are handled in typeguard by the check_union() and check_uniontype() functions which are currently identical. These function looks through all of the arguments of the provided type and if one of them matches (dispatched to check_type_internal()) then it returns and is considered to have passed. However in my code, I am using UnionType on its own without any arguments and so this loop is never entered and the check_uniontype() function always raises a TypeCheckError.

From my understanding the only time a UnionType object will be in use without arguments is when it is being used explicitly, and otherwise it will have been created via a | operation between two types. I also believe that a Union would always have arguments as this a SpecialForm and so other checkers such as mypy would already fail in this instance and if your function is to take a general Union construct, then it would have to take a SpecialForm and check the subclassing because otherwise you would need to use Union[X,Y].

For this reason, I think a solution would be to include an early escape within the check_uniontype() function if the args is an empty list and if the value is explicitly a UnionType. I appreciate if further consideration is needed since the difference between Union and UnionType is probably more nuanced than I am assuming.

How can we reproduce the bug?

The following code demonstrates the issue (and hopefully will provide some insight into what I am trying to check).

from types import UnionType
from typeguard import typechecked

@typechecked
def is_uniontype(t: type | UnionType) -> bool:
    if isinstance(t, UnionType):
        return True
    return False

def test_is_uniontype():
    assert is_uniontype(int | float) # Should be correct as we are passing a UnionType object

A smaller example would be the following which calls the check_type() function directly:

from types import UnionType

from typeguard import check_type

check_type(int | float, UnionType)
MyKo101 commented 4 months ago

I believe the following fix to the check_uniontype() function would cover this issue:

def check_uniontype(
    value: Any,
    origin_type: Any,
    args: tuple[Any, ...],
    memo: TypeCheckMemo,
) -> None:
   # NEW CODE HERE VVVVVV
    if len(args) == 0:
        if isinstance(value,types.UnionType):
            return
        else:
            raise TypeCheckError("is not a UnionType") 
   # ^^^^^^^^^^^^^^^^^
    errors: dict[str, TypeCheckError] = {}
    for type_ in args:
        try:
            check_type_internal(value, type_, memo)
            return
        except TypeCheckError as exc:
            errors[get_type_name(type_)] = exc

    formatted_errors = indent(
        "\n".join(f"{key}: {error}" for key, error in errors.items()), "  "
    )
    raise TypeCheckError(f"did not match any element in the union:\n{formatted_errors}")