forked from temporalio/sdk-python
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_type_errors.py
More file actions
174 lines (138 loc) · 5.99 KB
/
Copy pathtest_type_errors.py
File metadata and controls
174 lines (138 loc) · 5.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
"""
This file contains a test allowing assertions to be made that an expected type error is in
fact produced by the type-checker. I.e. that the type checker is not delivering a false
negative.
To use the test, add a comment of the following form to your test code:
# assert-type-error-pyright: 'No overloads for "execute_operation" match' await
nexus_client.execute_operation( # type: ignore
The `type: ignore` is only necessary if your test code is being type-checked.
This is a copy of https://github.com/nexus-rpc/sdk-python/blob/main/tests/test_type_errors.py
Until a shared library is created, please keep the two in sync.
"""
import itertools
import json
import os
import platform
import re
import subprocess
import tempfile
from pathlib import Path
import pytest
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
"""Dynamically generate test cases for files with type error assertions."""
if metafunc.function.__name__ in [
"test_type_errors_pyright",
"test_type_errors_mypy",
]:
tests_dir = Path(__file__).parent
files_with_assertions = []
for test_file in tests_dir.rglob("test_*.py"):
if test_file.name == "test_type_errors.py":
continue
if _has_type_error_assertions(test_file):
files_with_assertions.append(test_file)
metafunc.parametrize(
"test_file",
files_with_assertions,
ids=lambda f: str(f.relative_to(tests_dir)),
)
@pytest.mark.skipif(platform.system() == "Windows", reason="TODO: broken on Windows")
def test_type_errors_pyright(test_file: Path):
"""
Validate type error assertions in a single test file using pyright.
For each line with a comment of the form `# assert-type-error-pyright: "regex"`,
verify that pyright reports an error on the next non-comment line matching the regex.
Also verify that there are no unexpected type errors.
"""
_test_type_errors(
test_file,
_get_expected_errors(test_file, "pyright"),
_get_pyright_errors(test_file),
)
def _test_type_errors(
test_file: Path,
expected_errors: dict[int, str],
actual_errors: dict[int, str],
) -> None:
for line_num, expected_pattern in sorted(expected_errors.items()):
if line_num not in actual_errors:
pytest.fail(
f"{test_file}:{line_num}: Expected type error matching '{expected_pattern}' but no error found"
)
actual_msg = actual_errors[line_num]
if not re.search(expected_pattern, actual_msg):
pytest.fail(
f"{test_file}:{line_num}: Expected error matching '{expected_pattern}' but got '{actual_msg}'"
)
def _has_type_error_assertions(test_file: Path) -> bool:
"""Check if a file contains any type error assertions."""
with open(test_file) as f:
return any(re.search(r"# assert-type-error-\w+:", line) for line in f)
def _get_expected_errors(test_file: Path, type_checker: str) -> dict[int, str]:
"""Parse expected type errors from comments in a file for the specified type checker."""
expected_errors = {}
with open(test_file) as f:
lines = zip(itertools.count(1), f)
for line_num, line in lines:
if match := re.search(
rf'# assert-type-error-{re.escape(type_checker)}:\s*["\'](.+)["\']',
line,
):
pattern = match.group(1)
for line_num, line in lines:
if line := line.strip():
if not line.startswith("#"):
expected_errors[line_num] = pattern
break
return expected_errors
def _get_pyright_errors(test_file: Path) -> dict[int, str]:
"""Run pyright on a file and parse the actual type errors."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
# Create a temporary config file to disable type ignore comments
config_data = {"enableTypeIgnoreComments": False}
json.dump(config_data, f)
config_path = f.name
try:
result = subprocess.run(
["uv", "run", "pyright", "--project", config_path, str(test_file)],
capture_output=True,
text=True,
)
actual_errors = {}
abs_path = test_file.resolve()
for line in result.stdout.splitlines():
# pyright output format: /full/path/to/file.py:line:column - error: message (error_code)
if match := re.match(
rf"\s*{re.escape(str(abs_path))}:(\d+):\d+\s*-\s*error:\s*(.+)", line
):
line_num = int(match.group(1))
error_msg = match.group(2).strip()
# Remove error code in parentheses if present
error_msg = re.sub(r"\s*\([^)]+\)$", "", error_msg)
actual_errors[line_num] = error_msg
return actual_errors
finally:
if os.path.exists(config_path):
os.unlink(config_path)
def _get_mypy_errors(test_file: Path) -> dict[int, str]: # pyright: ignore[reportUnusedFunction]
"""Run mypy on a file and parse the actual type errors.
Note: mypy does not have a direct equivalent to pyright's enableTypeIgnoreComments=false,
so type ignore comments will still be respected by mypy. Users should avoid placing
# type: ignore comments on lines they want to test, or manually remove them for testing.
"""
result = subprocess.run(
["uv", "run", "mypy", str(test_file)],
capture_output=True,
text=True,
)
actual_errors = {}
abs_path = test_file.resolve()
for line in result.stdout.splitlines():
# mypy output format: file.py:line: error: message
if match := re.match(
rf"{re.escape(str(abs_path))}:(\d+):\s*error:\s*(.+)", line
):
line_num = int(match.group(1))
error_msg = match.group(2).strip()
actual_errors[line_num] = error_msg
return actual_errors