PyHamcrest icon indicating copy to clipboard operation
PyHamcrest copied to clipboard

Type hints for ´sized´ matchers do not allow for instances of subclasses of ´Sized´.

Open david-anders opened this issue 3 years ago • 9 comments

I didnt find this issue mentioned anywhere, so I'm sorry if i missed it, or if i'm doing something wrong.

When creating tests for collection objects I'm getting plenty of type errors, from mypy as well as the PyCharm builtin type checker.

Example: The following:

assert_that([], is_(empty()))

results in:

Unexpected type(s): (list, Matcher[Sized]) Possible types: (bool, str) (list, Matcher[list])

It seems to me to be because the type declaration of the empty matcher is too strict/"working in the wrong direction". This can be solved by using a TypeVar instead of the static Matcher[Sized] type declaration (see the ´empty2´ definition in the example below)

Code snippet for clarification:

from typing import TypeVar, Sized, List, Any

from hamcrest.core.assert_that import assert_that
from hamcrest.core.core.is_ import is_
from hamcrest.core.matcher import Matcher
from hamcrest.library.collection import empty


SizedT = TypeVar("SizedT", bound=Sized)


def empty2() -> Matcher[SizedT]:
    """
    wraps the ´hamcrest.empty´ Matcher with type hints that support instances of subclasses of ´typing.Sized´.
    """
    return empty()  # type: ignore


some_list: List[Any] = [1]

""" Unexpected type(s): (list, Matcher[Sized]) Possible types: (bool, str) (list, Matcher[list]) """
assert_that(some_list, is_(empty()))

""" All good: """
assert_that(some_list, is_(empty2()))

The same issue exists for most of the other collection matchers as far as i can tell. (Sorry for accidentally hitting ´enter´ while writing my draft for the title, thus adding a issue without an explanation and a odd title at first).

david-anders avatar Jul 16 '21 08:07 david-anders

Can you give an example? What do you mean by a sized matcher? Do you mean has_length()?

brunns avatar Jul 16 '21 08:07 brunns

Sorry i accidentally published the issue before finishing my description. Updated it. has_length as well as is_empty are suffering the exact same problem, as they are typed in the same way

david-anders avatar Jul 16 '21 08:07 david-anders

Ooooh, thanks. I'll take a look - or feel free to submit a PR.

brunns avatar Jul 16 '21 08:07 brunns

I can try to submit a PR for that. I‘m not sure how to test for that nicely though. Any suggestions?

david-anders avatar Jul 16 '21 11:07 david-anders

Hah - funnily enough I'm just looking into that now.

brunns avatar Jul 16 '21 11:07 brunns

I'm playing with tests in #179, but weirdly, while I do see your problem in IntelliJ, mypy seems to be happy with assert_that([], is_(empty())). Hmmm.

brunns avatar Jul 16 '21 12:07 brunns

Ah - I was missing the is_ - that does recreate the issue. I think there's an issue with is_() itself - is_(empty()) fails all by itself!

brunns avatar Jul 16 '21 13:07 brunns

Oh yea! I missed that initally. It really does seem to be a general problem with is_, and specifically not Is.

is_ just seems to expect a Matcher[<nothing>] consistently, which does not throw any errors with the very generic matchers Matcher[Any] (e.g. equal_to, greater_than, ...) since Any includes nothing. Trying a few others of the more specifc matchers, I do get the same error:

reveal_type(is_(greater_than(0)))  # Revealed type is "hamcrest.core.matcher.Matcher[<nothing>]"

is_(greater_than(0))  # [All fine]
is_(contains_string(''))  # Argument 1 to "is_" has incompatible type "Matcher[str]"; expected "Matcher[<nothing>]"
is_(instance_of(object))  #  Argument 1 to "is_" has incompatible type "Matcher[object]"; expected "Matcher[<nothing>]"

Is(greater_than(0))  # [All fine]
Is(contains_string(''))  # [All fine]
Is(instance_of(object))  # [All fine]

I dont really understand why but manually flattening the Union[Matcher[T], T] seems to resolve this. in src/hamcrest/core/core/is_.py

@overload
def is_(x: Union[Matcher[T], T]) -> Matcher[T]: ...

->

@overload
def is_(x: T) -> Matcher[T]: ...


@overload
def is_(x: Matcher[T]) -> Matcher[T]: ...

With that change, the correct type is revealed:

reveal_type(is_(greater_than(0)))  # Revealed type is "hamcrest.core.matcher.Matcher[hamcrest.core.matcher.Matcher*[Any]]"

But, I dont understand why there would be an issue with Union[Matcher[T], T].

The PyCharm issue persists, but this might actually be a different problem.

david-anders avatar Jul 17 '21 13:07 david-anders

But, I dont understand why there would be an issue with Union[Matcher[T], T].

Nor me - but if it works, it works.

The PyCharm issue persists, but this might actually be a different problem.

Yup - if mypy is happy, that's probably all we can do. (This makes #179 worthwhile, I think - we can easily test our annotations that way.) The PyCharm issue might even be a bug on their end.

brunns avatar Aug 11 '21 09:08 brunns