Skip to content

Commit 652ff8a

Browse files
author
David Fritzsche
committed
Add syntax to compare just mypy error codes and not full messages
1 parent 7cbcb1a commit 652ff8a

File tree

6 files changed

+174
-47
lines changed

6 files changed

+174
-47
lines changed

README.md

+37-7
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ the file:
6464
* `# N: <msg>` - we expect a mypy note message
6565
* `# W: <msg>` - we expect a mypy warning message
6666
* `# E: <msg>` - we expect a mypy error message
67+
* `# F: <msg>` - we expect a mypy fatal error message
6768
* `# R: <msg>` - we expect a mypy note message `Revealed type is
6869
'<msg>'`. This is useful to easily check `reveal_type` output:
6970
```python
@@ -73,13 +74,15 @@ the file:
7374
reveal_type(456) # R: Literal[456]?
7475
```
7576

76-
## mypy Error Codes
77+
## mypy Error Code Matching
7778

7879
The algorithm matching messages parses mypy error code both in the
79-
output generated by mypy and in the Python comments. If both the mypy
80-
output and the Python comment contain an error code, then the codes
81-
must match. So the following test case expects that mypy writes out an
82-
``assignment`` error code:
80+
output generated by mypy and in the Python comments.
81+
82+
If both the mypy output and the Python comment contain an error code
83+
and a full message, then the messages and the error codes must
84+
match. The following test case expects that mypy writes out an
85+
``assignment`` error code and a specific error message:
8386

8487
``` python
8588
@pytest.mark.mypy_testing
@@ -89,7 +92,27 @@ def mypy_test_invalid_assignment() -> None:
8992
```
9093

9194
If the Python comment does not contain an error code, then the error
92-
code written out by mypy (if any) is simply ignored.
95+
code written out by mypy (if any) is ignored. The following test case
96+
expects a specific error message from mypy, but ignores the error code
97+
produced by mypy:
98+
99+
``` python
100+
@pytest.mark.mypy_testing
101+
def mypy_test_invalid_assignment() -> None:
102+
foo = "abc"
103+
foo = 123 # E: Incompatible types in assignment (expression has type "int", variable has type "str")
104+
```
105+
106+
If the Python comment specifies only an error code, then the message
107+
written out by mypy is ignored, i.e., the following test case checks
108+
that mypy reports an `assignment` error:
109+
110+
``` python
111+
@pytest.mark.mypy_testing
112+
def mypy_test_invalid_assignment() -> None:
113+
foo = "abc"
114+
foo = 123 # E: [assignment]
115+
```
93116

94117

95118
## Skipping and Expected Failures
@@ -114,9 +137,15 @@ decorators are extracted from the ast.
114137

115138
# Changelog
116139

140+
## v0.1.1
141+
142+
* Compare just mypy error codes if given and no error message is given
143+
in the test case Python comment ([#36][i36], [#41][p43])
144+
117145
## v0.1.0
118146

119-
* Implement support for flexible matching of mypy error codes (towards [#36][i36], [#41][p41])
147+
* Implement support for flexible matching of mypy error codes (towards
148+
[#36][i36], [#41][p41])
120149
* Add support for pytest 7.2.x ([#42][p42])
121150
* Add support for mypy 1.0.x ([#42][p42])
122151
* Add support for Python 3.11 ([#42][p42])
@@ -192,3 +221,4 @@ decorators are extracted from the ast.
192221
[p40]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/40
193222
[p41]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/41
194223
[p42]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/42
224+
[p43]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/43

pytest.ini

+4-1
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
# SPDX-License-Identifier: CC0-1.0
33
[pytest]
44
testpaths =
5-
tests mypy_tests
5+
tests mypy_tests pytest_mypy_testing
66
addopts =
7+
--doctest-continue-on-failure
8+
--doctest-modules
79
--failed-first
10+
--pyargs
811
--showlocals
912
-p no:mypy-testing
1013
doctest_optionflags = NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL ELLIPSIS

src/pytest_mypy_testing/message.py

+98-36
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class Severity(enum.Enum):
2222
NOTE = 1
2323
WARNING = 2
2424
ERROR = 3
25+
FATAL = 4
2526

2627
@classmethod
2728
def from_string(cls, string: str) -> "Severity":
@@ -39,6 +40,7 @@ def __repr__(self) -> str:
3940
"N": Severity.NOTE,
4041
"W": Severity.WARNING,
4142
"E": Severity.ERROR,
43+
"F": Severity.FATAL,
4244
}
4345

4446
_COMMENT_MESSAGES = frozenset(
@@ -55,11 +57,11 @@ def __repr__(self) -> str:
5557
class Message:
5658
"""Mypy message"""
5759

58-
filename: str
59-
lineno: int
60-
colno: Optional[int]
61-
severity: Severity
62-
message: str
60+
filename: str = ""
61+
lineno: int = 0
62+
colno: Optional[int] = None
63+
severity: Severity = Severity.ERROR
64+
message: str = ""
6365
revealed_type: Optional[str] = None
6466
error_code: Optional[str] = None
6567

@@ -72,19 +74,22 @@ class Message:
7274
COMMENT_RE = re.compile(
7375
r"^(?:# *type: *ignore *)?(?:# *)?"
7476
r"(?P<severity>[RENW]):"
75-
r"((?P<colno>\d+):)? *"
76-
r"(?P<message>[^#]*?)"
77-
r"(?: +\[(?P<error_code>[^\]]*)\])?"
77+
r"((?P<colno>\d+):)?"
78+
r" *"
79+
r"(?P<message_and_error_code>[^#]*)"
7880
r"(?:#.*?)?$"
7981
)
8082

83+
MESSAGE_AND_ERROR_CODE = re.compile(
84+
r"(?P<message>[^\[][^#]*?)" r" +" r"\[(?P<error_code>[^\]]*)\]"
85+
)
86+
8187
OUTPUT_RE = re.compile(
8288
r"^(?P<fname>([a-zA-Z]:)?[^:]+):"
8389
r"(?P<lineno>[0-9]+):"
8490
r"((?P<colno>[0-9]+):)?"
8591
r" *(?P<severity>(error|note|warning)):"
86-
r"(?P<message>.*?)"
87-
r"(?: +\[(?P<error_code>[^\]]*)\])?"
92+
r"(?P<message_and_error_code>.*?)"
8893
r"$"
8994
)
9095

@@ -128,7 +133,7 @@ def astuple(self, *, normalized: bool = False) -> "Message.TupleType":
128133
129134
>>> m = Message("foo.py", 1, 1, Severity.NOTE, 'Revealed type is "float"')
130135
>>> m.astuple()
131-
('foo.py', 1, 1, Severity.NOTE, 'Revealed type is "float"', 'float')
136+
('foo.py', 1, 1, Severity.NOTE, 'Revealed type is "float"', 'float', None)
132137
"""
133138
return (
134139
self.filename,
@@ -144,7 +149,11 @@ def is_comment(self) -> bool:
144149
return (self.severity, self.message) in _COMMENT_MESSAGES
145150

146151
def _as_short_tuple(
147-
self, *, normalized: bool = False, default_error_code: Optional[str] = None
152+
self,
153+
*,
154+
normalized: bool = False,
155+
default_message: str = "",
156+
default_error_code: Optional[str] = None,
148157
) -> "Message.TupleType":
149158
if normalized:
150159
message = self.normalized_message
@@ -155,32 +164,73 @@ def _as_short_tuple(
155164
self.lineno,
156165
None,
157166
self.severity,
158-
message,
167+
message or default_message,
159168
self.revealed_type,
160169
self.error_code or default_error_code,
161170
)
162171

172+
def __hash__(self) -> int:
173+
t = (self.filename, self.lineno, self.severity, self.revealed_type)
174+
return hash(t)
175+
163176
def __eq__(self, other):
177+
"""Compare if *self* and *other* are equal.
178+
179+
Returns `True` if *other* is a :obj:`Message:` object
180+
considered to be equal to *self*.
181+
182+
>>> Message() == Message()
183+
True
184+
>>> Message(error_code="baz") == Message(message="some text", error_code="baz")
185+
True
186+
>>> Message(message="some text") == Message(message="some text", error_code="baz")
187+
True
188+
189+
>>> Message() == Message(message="some text", error_code="baz")
190+
False
191+
>>> Message(error_code="baz") == Message(error_code="bax")
192+
False
193+
"""
164194
if isinstance(other, Message):
165195
default_error_code = self.error_code or other.error_code
166-
if self.colno is None or other.colno is None:
167-
a = self._as_short_tuple(
168-
normalized=True, default_error_code=default_error_code
169-
)
170-
b = other._as_short_tuple(
171-
normalized=True, default_error_code=default_error_code
196+
if self.error_code and other.error_code:
197+
default_message = self.normalized_message or other.normalized_message
198+
else:
199+
default_message = ""
200+
201+
def to_tuple(m: Message):
202+
return m._as_short_tuple(
203+
normalized=True,
204+
default_message=default_message,
205+
default_error_code=default_error_code,
172206
)
173-
return a == b
207+
208+
if self.colno is None or other.colno is None:
209+
return to_tuple(self) == to_tuple(other)
174210
else:
175211
return self.astuple(normalized=True) == other.astuple(normalized=True)
176212
else:
177213
return NotImplemented
178214

179-
def __hash__(self) -> int:
180-
return hash(self._as_short_tuple(normalized=True))
181-
182215
def __str__(self) -> str:
183-
return f"{self._prefix} {self.severity.name.lower()}: {self.message}"
216+
return self.to_string(prefix=f"{self._prefix} ")
217+
218+
def to_string(self, prefix: Optional[str] = None) -> str:
219+
prefix = prefix or f"{self._prefix} "
220+
error_code = f" [{self.error_code}]" if self.error_code else ""
221+
return f"{prefix}{self.severity.name.lower()}: {self.message}{error_code}"
222+
223+
@classmethod
224+
def __split_message_and_error_code(cls, msg: str) -> Tuple[str, Optional[str]]:
225+
msg = msg.strip()
226+
if msg.startswith("[") and msg.endswith("]"):
227+
return "", msg[1:-1]
228+
else:
229+
m = cls.MESSAGE_AND_ERROR_CODE.fullmatch(msg)
230+
if m:
231+
return m.group("message"), m.group("error_code")
232+
else:
233+
return msg, None
184234

185235
@classmethod
186236
def from_comment(
@@ -189,13 +239,17 @@ def from_comment(
189239
"""Create message object from Python *comment*.
190240
191241
>>> Message.from_comment("foo.py", 1, "R: foo")
192-
Message(filename='foo.py', lineno=1, colno=None, severity=Severity.NOTE, message="Revealed type is 'foo'", revealed_type='foo')
242+
Message(filename='foo.py', lineno=1, colno=None, severity=Severity.NOTE, message="Revealed type is 'foo'", revealed_type='foo', error_code=None)
243+
>>> Message.from_comment("foo.py", 1, "E: [assignment]")
244+
Message(filename='foo.py', lineno=1, colno=None, severity=Severity.ERROR, message='', revealed_type=None, error_code='assignment')
193245
"""
194246
m = cls.COMMENT_RE.match(comment.strip())
195247
if not m:
196248
raise ValueError("Not a valid mypy message comment")
197249
colno = int(m.group("colno")) if m.group("colno") else None
198-
message = m.group("message").strip()
250+
message, error_code = cls.__split_message_and_error_code(
251+
m.group("message_and_error_code")
252+
)
199253
if m.group("severity") == "R":
200254
revealed_type = message
201255
message = "Revealed type is {!r}".format(message)
@@ -208,37 +262,45 @@ def from_comment(
208262
severity=Severity.from_string(m.group("severity")),
209263
message=message,
210264
revealed_type=revealed_type,
211-
error_code=m.group("error_code") or None,
265+
error_code=error_code,
212266
)
213267

214268
@classmethod
215269
def from_output(cls, line: str) -> "Message":
216270
"""Create message object from mypy output line.
217271
218272
>>> m = Message.from_output("z.py:1: note: bar")
219-
>>> (m.lineno, m.colno, m.severity, m.message, m.revealed_type)
220-
(1, None, Severity.NOTE, 'bar', None)
273+
>>> (m.lineno, m.colno, m.severity, m.message, m.revealed_type, m.error_code)
274+
(1, None, Severity.NOTE, 'bar', None, None)
221275
222276
>>> m = Message.from_output("z.py:1:13: note: bar")
223-
>>> (m.lineno, m.colno, m.severity, m.message, m.revealed_type)
224-
(1, 13, Severity.NOTE, 'bar', None)
277+
>>> (m.lineno, m.colno, m.severity, m.message, m.revealed_type, m.error_code)
278+
(1, 13, Severity.NOTE, 'bar', None, None)
225279
226280
>>> m = Message.from_output("z.py:1: note: Revealed type is 'bar'")
227-
>>> (m.lineno, m.colno, m.severity, m.message, m.revealed_type)
228-
(1, None, Severity.NOTE, "Revealed type is 'bar'", 'bar')
281+
>>> (m.lineno, m.colno, m.severity, m.message, m.revealed_type, m.error_code)
282+
(1, None, Severity.NOTE, "Revealed type is 'bar'", 'bar', None)
229283
230284
>>> m = Message.from_output('z.py:1: note: Revealed type is "bar"')
231-
>>> (m.lineno, m.colno, m.severity, m.message, m.revealed_type)
232-
(1, None, Severity.NOTE, 'Revealed type is "bar"', 'bar')
285+
>>> (m.lineno, m.colno, m.severity, m.message, m.revealed_type, m.error_code)
286+
(1, None, Severity.NOTE, 'Revealed type is "bar"', 'bar', None)
287+
288+
>>> m = Message.from_output("z.py:1:13: error: bar [baz]")
289+
>>> (m.lineno, m.colno, m.severity, m.message, m.revealed_type, m.error_code)
290+
(1, 13, Severity.ERROR, 'bar', None, 'baz')
233291
234292
"""
235293
m = cls.OUTPUT_RE.match(line)
236294
if not m:
237295
raise ValueError("Not a valid mypy message")
296+
message, error_code = cls.__split_message_and_error_code(
297+
m.group("message_and_error_code")
298+
)
238299
return cls(
239300
os.path.abspath(m.group("fname")),
240301
lineno=int(m.group("lineno")),
241302
colno=int(m.group("colno")) if m.group("colno") else None,
242303
severity=Severity[m.group("severity").upper()],
243-
message=m.group("message").strip(),
304+
message=message,
305+
error_code=error_code,
244306
)

src/pytest_mypy_testing/output_processing.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,7 @@ def __post_init__(self) -> None:
4646
def _fmt(msg: Message, actual_expected: str = "", *, indent: str = " ") -> str:
4747
if actual_expected:
4848
actual_expected += ": "
49-
return (
50-
f"{indent}{actual_expected}{msg.severity.name.lower()}: {msg.message}"
51-
)
49+
return msg.to_string(prefix=f"{indent}{actual_expected}")
5250

5351
if not any([self.actual, self.expected]):
5452
raise ValueError("At least one of actual and expected must be given")

src/pytest_mypy_testing/plugin.py

+1
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ def _run_mypy(self, filename: Union[pathlib.Path, os.PathLike, str]) -> MypyResu
172172
"--no-silence-site-packages",
173173
"--no-warn-unused-configs",
174174
"--show-column-numbers",
175+
"--show-error-codes",
175176
"--show-traceback",
176177
str(filename),
177178
]

tests/test_basics.mypy-testing

+33
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,39 @@ def mypy_test_invalid_assginment():
1111
foo = 123 # E: Incompatible types in assignment (expression has type "int", variable has type "str")
1212

1313

14+
@pytest.mark.mypy_testing
15+
def mypy_test_invalid_assginment_with_error_code():
16+
foo = "abc"
17+
foo = 123 # E: Incompatible types in assignment (expression has type "int", variable has type "str") [assignment]
18+
19+
20+
@pytest.mark.xfail
21+
@pytest.mark.mypy_testing
22+
def mypy_test_invalid_assginment_with_error_code__message_does_not_match():
23+
foo = "abc"
24+
foo = 123 # E: Invalid assignment [assignment]
25+
26+
27+
@pytest.mark.mypy_testing
28+
def mypy_test_invalid_assginment_only_error_code():
29+
foo = "abc"
30+
foo = 123 # E: [assignment]
31+
32+
33+
@pytest.mark.xfail
34+
@pytest.mark.mypy_testing
35+
def mypy_test_invalid_assginment_only_error_code__error_code_does_not_match():
36+
foo = "abc"
37+
foo = 123 # E: [baz]
38+
39+
40+
@pytest.mark.xfail
41+
@pytest.mark.mypy_testing
42+
def mypy_test_invalid_assginment_no_message_and_no_error_code():
43+
foo = "abc"
44+
foo = 123 # E:
45+
46+
1447
@pytest.mark.mypy_testing
1548
def mypy_test_use_reveal_type():
1649
reveal_type(123) # N: Revealed type is 'Literal[123]?'

0 commit comments

Comments
 (0)