Skip to content

Commit 7ef70a8

Browse files
brunnsclaude
andcommitted
Make Matcher contravariant to fix type checking (issue #222)
Fixes #222: Matchers should be contravariant with respect to their type parameter. This allows more flexible type assignments where a Matcher[Base] can be used where a Matcher[Derived] is expected, which is the correct variance for types that consume their type parameter. Changes: - Make Matcher's TypeVar contravariant in src/hamcrest/core/matcher.py - Add type: ignore comment to is_() overload to suppress mypy's overly strict overlap detection (the overloads work correctly at runtime) - Add test cases for issues #222 and #234 in test_assert_that.yml - Add comprehensive type tests for all common matchers to verify no regressions in tests/type-hinting/test_common_matchers.yml Example now working: matcher: Matcher[str] = has_length(greater_than(0)) Since str is Sized, Matcher[Sized] can now be assigned to Matcher[str] because Matcher is contravariant. This makes the type system more flexible and correct. Testing: - All mypy type checking passes - All type-hinting tests pass (17 tests covering all major matchers) - Comprehensive tests added for core, logical, collection, number, object, and text matchers - No functional regressions detected Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent ab5ceb3 commit 7ef70a8

4 files changed

Lines changed: 267 additions & 3 deletions

File tree

src/hamcrest/core/core/is_.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@ def _wrap_value_or_type(x):
4444

4545

4646
@overload
47-
def is_(x: Type) -> Matcher[Any]: ...
47+
def is_(x: Matcher[T]) -> Matcher[T]: ... # type: ignore[overload-overlap]
4848

4949

5050
@overload
51-
def is_(x: Matcher[T]) -> Matcher[T]: ...
51+
def is_(x: Type) -> Matcher[Any]: ...
5252

5353

5454
@overload

src/hamcrest/core/matcher.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
__copyright__ = "Copyright 2011 hamcrest.org"
99
__license__ = "BSD, see License.txt"
1010

11-
T = TypeVar("T")
11+
T = TypeVar("T", contravariant=True)
1212

1313

1414
class Matcher(Generic[T], SelfDescribing):

tests/type-hinting/core/test_assert_that.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,35 @@
99
assert_that(99, starts_with("str"))
1010
out: |
1111
main:5: error: Cannot infer type argument 1 of "assert_that" [misc]
12+
13+
- case: matcher_contravariance_issue_222
14+
# Issue 222: Matchers should be contravariant
15+
# https://github.com/hamcrest/PyHamcrest/issues/222
16+
# FIXED: Matcher is now contravariant
17+
skip: platform.python_implementation() == "PyPy"
18+
main: |
19+
from hamcrest import has_length, greater_than
20+
from hamcrest.core.matcher import Matcher
21+
22+
# This now works: str is Sized, so Matcher[Sized] is assignable to Matcher[str]
23+
# because Matcher is contravariant
24+
matcher: Matcher[str] = has_length(greater_than(0))
25+
26+
- case: sequence_matcher_types_issue_234
27+
# Issue 234: Unexpected type warnings with sequence matchers
28+
# https://github.com/hamcrest/PyHamcrest/issues/234
29+
# NOTE: This issue appears to be resolved - no type errors are generated
30+
skip: platform.python_implementation() == "PyPy"
31+
main: |
32+
from hamcrest import assert_that, contains_exactly
33+
34+
li = [1, 2, 3]
35+
ls = ['1', '2', '3']
36+
s = '123'
37+
38+
# These should not produce type warnings (and they don't!)
39+
assert_that(li, contains_exactly(*li))
40+
assert_that(ls, contains_exactly(*ls))
41+
assert_that(ls, contains_exactly(*s))
42+
assert_that(s, contains_exactly(*s))
43+
assert_that(s, contains_exactly(*ls))
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
- case: core_matchers_basic
2+
# Test basic core matchers work with contravariant Matcher
3+
skip: platform.python_implementation() == "PyPy"
4+
main: |
5+
from hamcrest import (
6+
assert_that, equal_to, is_, is_not, none, not_none,
7+
same_instance, instance_of, anything
8+
)
9+
10+
# equal_to
11+
assert_that("hello", equal_to("hello"))
12+
assert_that(42, equal_to(42))
13+
14+
# is_
15+
assert_that("hello", is_("hello"))
16+
assert_that(42, is_(42))
17+
18+
# is_not
19+
assert_that("hello", is_not("world"))
20+
assert_that(42, is_not(0))
21+
22+
# none / not_none
23+
assert_that(None, none())
24+
assert_that("hello", not_none())
25+
26+
# same_instance
27+
obj = object()
28+
assert_that(obj, same_instance(obj))
29+
30+
# instance_of
31+
assert_that("hello", instance_of(str))
32+
assert_that(42, instance_of(int))
33+
34+
# anything
35+
assert_that("hello", anything())
36+
assert_that(42, anything())
37+
38+
- case: logical_matchers
39+
# Test logical combinators work with contravariant Matcher
40+
skip: platform.python_implementation() == "PyPy"
41+
main: |
42+
from hamcrest import (
43+
assert_that, all_of, any_of, is_not,
44+
equal_to, greater_than, less_than, instance_of
45+
)
46+
47+
# all_of
48+
assert_that(5, all_of(greater_than(0), less_than(10)))
49+
50+
# any_of
51+
assert_that(5, any_of(equal_to(5), equal_to(10)))
52+
53+
# not_ (via is_not)
54+
assert_that(5, is_not(equal_to(10)))
55+
56+
- case: collection_matchers_lists
57+
# Test list/sequence matchers work with contravariant Matcher
58+
skip: platform.python_implementation() == "PyPy"
59+
main: |
60+
from hamcrest import (
61+
assert_that, has_item, has_items, contains_exactly,
62+
contains_inanyorder, only_contains, empty, is_in
63+
)
64+
65+
# has_item
66+
assert_that([1, 2, 3], has_item(2))
67+
assert_that(["a", "b"], has_item("a"))
68+
69+
# has_items
70+
assert_that([1, 2, 3], has_items(1, 3))
71+
72+
# contains_exactly
73+
assert_that([1, 2, 3], contains_exactly(1, 2, 3))
74+
75+
# contains_inanyorder
76+
assert_that([3, 1, 2], contains_inanyorder(1, 2, 3))
77+
78+
# only_contains
79+
assert_that([1, 1, 2], only_contains(1, 2))
80+
81+
# empty
82+
assert_that([], empty())
83+
assert_that("", empty())
84+
85+
# is_in
86+
assert_that(2, is_in([1, 2, 3]))
87+
88+
- case: collection_matchers_dicts
89+
# Test dictionary matchers work with contravariant Matcher
90+
skip: platform.python_implementation() == "PyPy"
91+
main: |
92+
from hamcrest import (
93+
assert_that, has_entry, has_entries, has_key, has_value
94+
)
95+
96+
d = {"a": 1, "b": 2, "c": 3}
97+
98+
# has_entry
99+
assert_that(d, has_entry("a", 1))
100+
101+
# has_entries
102+
assert_that(d, has_entries({"a": 1, "b": 2}))
103+
assert_that(d, has_entries(a=1, b=2))
104+
105+
# has_key
106+
assert_that(d, has_key("a"))
107+
108+
# has_value
109+
assert_that(d, has_value(1))
110+
111+
- case: number_matchers
112+
# Test number comparison matchers work with contravariant Matcher
113+
skip: platform.python_implementation() == "PyPy"
114+
main: |
115+
from hamcrest import (
116+
assert_that, greater_than, greater_than_or_equal_to,
117+
less_than, less_than_or_equal_to, close_to
118+
)
119+
120+
# greater_than
121+
assert_that(5, greater_than(3))
122+
123+
# greater_than_or_equal_to
124+
assert_that(5, greater_than_or_equal_to(5))
125+
assert_that(5, greater_than_or_equal_to(3))
126+
127+
# less_than
128+
assert_that(3, less_than(5))
129+
130+
# less_than_or_equal_to
131+
assert_that(3, less_than_or_equal_to(3))
132+
assert_that(3, less_than_or_equal_to(5))
133+
134+
# close_to
135+
assert_that(1.0, close_to(1.01, 0.02))
136+
137+
- case: object_matchers
138+
# Test object property matchers work with contravariant Matcher
139+
skip: platform.python_implementation() == "PyPy"
140+
main: |
141+
from hamcrest import (
142+
assert_that, has_property, has_properties,
143+
has_length, has_string
144+
)
145+
146+
class Obj:
147+
def __init__(self):
148+
self.name = "test"
149+
self.value = 42
150+
def __str__(self):
151+
return "Obj(test)"
152+
153+
obj = Obj()
154+
155+
# has_property
156+
assert_that(obj, has_property("name", "test"))
157+
assert_that(obj, has_property("value", 42))
158+
159+
# has_properties
160+
assert_that(obj, has_properties(name="test", value=42))
161+
assert_that(obj, has_properties({"name": "test"}))
162+
163+
# has_length
164+
assert_that([1, 2, 3], has_length(3))
165+
assert_that("hello", has_length(5))
166+
167+
# has_string
168+
assert_that(obj, has_string("test"))
169+
170+
- case: text_matchers
171+
# Test string matchers work with contravariant Matcher
172+
skip: platform.python_implementation() == "PyPy"
173+
main: |
174+
from hamcrest import (
175+
assert_that, contains_string, starts_with, ends_with,
176+
matches_regexp, equal_to_ignoring_case, equal_to_ignoring_whitespace,
177+
string_contains_in_order
178+
)
179+
180+
# contains_string
181+
assert_that("hello world", contains_string("world"))
182+
183+
# starts_with
184+
assert_that("hello world", starts_with("hello"))
185+
186+
# ends_with
187+
assert_that("hello world", ends_with("world"))
188+
189+
# matches_regexp
190+
assert_that("hello123", matches_regexp(r"hello\d+"))
191+
192+
# equal_to_ignoring_case
193+
assert_that("HELLO", equal_to_ignoring_case("hello"))
194+
195+
# equal_to_ignoring_whitespace
196+
assert_that("hello world", equal_to_ignoring_whitespace("hello world"))
197+
198+
# string_contains_in_order
199+
assert_that("hello beautiful world", string_contains_in_order("hello", "world"))
200+
201+
- case: matcher_assignment_contravariance
202+
# Test that contravariance works for matcher assignment
203+
skip: platform.python_implementation() == "PyPy"
204+
main: |
205+
from hamcrest import has_length, greater_than, has_item, equal_to
206+
from hamcrest.core.matcher import Matcher
207+
from typing import Sized, Sequence
208+
209+
# str is Sized, so Matcher[Sized] should be assignable to Matcher[str]
210+
m1: Matcher[str] = has_length(greater_than(0))
211+
212+
# list[int] is Sequence, so Matcher[Sequence] should be assignable to Matcher[list[int]]
213+
m2: Matcher[list[int]] = has_item(1)
214+
215+
# int is object, so Matcher[object] should be assignable to Matcher[int]
216+
m3: Matcher[int] = equal_to(42)
217+
218+
- case: nested_matchers
219+
# Test nested matchers work with contravariant Matcher
220+
skip: platform.python_implementation() == "PyPy"
221+
main: |
222+
from hamcrest import (
223+
assert_that, has_item, has_items, all_of,
224+
greater_than, less_than, instance_of
225+
)
226+
227+
# Nested matchers in collections
228+
assert_that([1, 2, 3, 4], has_item(greater_than(3)))
229+
assert_that([1, 2, 3, 4], has_items(greater_than(0), less_than(5)))
230+
231+
# Nested matchers in logical combinators
232+
assert_that(5, all_of(greater_than(0), less_than(10), instance_of(int)))

0 commit comments

Comments
 (0)