Skip to content

Commit 5faa040

Browse files
authored
Apply mypy-tests custom config to other mypy-based tests (#13825)
1 parent 23f94ff commit 5faa040

File tree

7 files changed

+276
-227
lines changed

7 files changed

+276
-227
lines changed

lib/ts_utils/metadata.py

+1
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ def is_obsolete(self) -> bool:
166166
"tool",
167167
"partial_stub",
168168
"requires_python",
169+
"mypy-tests",
169170
}
170171
)
171172
_KNOWN_METADATA_TOOL_FIELDS: Final = {

lib/ts_utils/mypy.py

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Generator, Iterable
4+
from contextlib import contextmanager
5+
from typing import Any, NamedTuple
6+
7+
import tomli
8+
9+
from ts_utils.metadata import metadata_path
10+
from ts_utils.utils import NamedTemporaryFile, TemporaryFileWrapper
11+
12+
13+
class MypyDistConf(NamedTuple):
14+
module_name: str
15+
values: dict[str, dict[str, Any]]
16+
17+
18+
# The configuration section in the metadata file looks like the following, with multiple module sections possible
19+
# [mypy-tests]
20+
# [mypy-tests.yaml]
21+
# module_name = "yaml"
22+
# [mypy-tests.yaml.values]
23+
# disallow_incomplete_defs = true
24+
# disallow_untyped_defs = true
25+
26+
27+
def mypy_configuration_from_distribution(distribution: str) -> list[MypyDistConf]:
28+
with metadata_path(distribution).open("rb") as f:
29+
data = tomli.load(f)
30+
31+
# TODO: This could be added to ts_utils.metadata
32+
mypy_tests_conf: dict[str, dict[str, Any]] = data.get("mypy-tests", {})
33+
if not mypy_tests_conf:
34+
return []
35+
36+
def validate_configuration(section_name: str, mypy_section: dict[str, Any]) -> MypyDistConf:
37+
assert isinstance(mypy_section, dict), f"{section_name} should be a section"
38+
module_name = mypy_section.get("module_name")
39+
40+
assert module_name is not None, f"{section_name} should have a module_name key"
41+
assert isinstance(module_name, str), f"{section_name} should be a key-value pair"
42+
43+
assert "values" in mypy_section, f"{section_name} should have a values section"
44+
values: dict[str, dict[str, Any]] = mypy_section["values"]
45+
assert isinstance(values, dict), "values should be a section"
46+
return MypyDistConf(module_name, values.copy())
47+
48+
assert isinstance(mypy_tests_conf, dict), "mypy-tests should be a section"
49+
return [validate_configuration(section_name, mypy_section) for section_name, mypy_section in mypy_tests_conf.items()]
50+
51+
52+
@contextmanager
53+
def temporary_mypy_config_file(configurations: Iterable[MypyDistConf]) -> Generator[TemporaryFileWrapper[str]]:
54+
temp = NamedTemporaryFile("w+")
55+
try:
56+
for dist_conf in configurations:
57+
temp.write(f"[mypy-{dist_conf.module_name}]\n")
58+
for k, v in dist_conf.values.items():
59+
temp.write(f"{k} = {v}\n")
60+
temp.write("[mypy]\n")
61+
temp.flush()
62+
yield temp
63+
finally:
64+
temp.close()

lib/ts_utils/utils.py

+30-4
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,24 @@
33
from __future__ import annotations
44

55
import functools
6+
import os
67
import re
78
import sys
9+
import tempfile
810
from collections.abc import Iterable, Mapping
911
from pathlib import Path
10-
from typing import Any, Final, NamedTuple
12+
from types import MethodType
13+
from typing import TYPE_CHECKING, Any, Final, NamedTuple
1114
from typing_extensions import TypeAlias
1215

1316
import pathspec
1417
from packaging.requirements import Requirement
1518

19+
from .paths import REQUIREMENTS_PATH, STDLIB_PATH, STUBS_PATH, TEST_CASES_DIR, allowlists_path, test_cases_path
20+
21+
if TYPE_CHECKING:
22+
from _typeshed import OpenTextMode
23+
1624
try:
1725
from termcolor import colored as colored # pyright: ignore[reportAssignmentType]
1826
except ImportError:
@@ -21,8 +29,6 @@ def colored(text: str, color: str | None = None, **kwargs: Any) -> str: # type:
2129
return text
2230

2331

24-
from .paths import REQUIREMENTS_PATH, STDLIB_PATH, STUBS_PATH, TEST_CASES_DIR, allowlists_path, test_cases_path
25-
2632
PYTHON_VERSION: Final = f"{sys.version_info.major}.{sys.version_info.minor}"
2733

2834

@@ -196,6 +202,26 @@ def allowlists(distribution_name: str) -> list[str]:
196202
return ["stubtest_allowlist.txt", platform_allowlist]
197203

198204

205+
# Re-exposing as a public name to avoid many pyright reportPrivateUsage
206+
TemporaryFileWrapper = tempfile._TemporaryFileWrapper # pyright: ignore[reportPrivateUsage]
207+
208+
# We need to work around a limitation of tempfile.NamedTemporaryFile on Windows
209+
# For details, see https://github.com/python/typeshed/pull/13620#discussion_r1990185997
210+
# Python 3.12 added a cross-platform solution with `tempfile.NamedTemporaryFile("w+", delete_on_close=False)`
211+
if sys.platform != "win32":
212+
NamedTemporaryFile = tempfile.NamedTemporaryFile # noqa: TID251
213+
else:
214+
215+
def NamedTemporaryFile(mode: OpenTextMode) -> TemporaryFileWrapper[str]: # noqa: N802
216+
def close(self: TemporaryFileWrapper[str]) -> None:
217+
TemporaryFileWrapper.close(self) # pyright: ignore[reportUnknownMemberType]
218+
os.remove(self.name)
219+
220+
temp = tempfile.NamedTemporaryFile(mode, delete=False) # noqa: SIM115, TID251
221+
temp.close = MethodType(close, temp) # type: ignore[method-assign]
222+
return temp
223+
224+
199225
# ====================================================================
200226
# Parsing .gitignore
201227
# ====================================================================
@@ -215,7 +241,7 @@ def spec_matches_path(spec: pathspec.PathSpec, path: Path) -> bool:
215241

216242

217243
# ====================================================================
218-
# mypy/stubtest call
244+
# stubtest call
219245
# ====================================================================
220246

221247

pyproject.toml

+5
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ select = [
139139
"TC005", # Found empty type-checking block
140140
# "TC008", # TODO: Enable when out of preview
141141
"TC010", # Invalid string member in `X | Y`-style union type
142+
# Used for lint.flake8-import-conventions.aliases
143+
"TID251", # `{name}` is banned: {message}
142144
]
143145
extend-safe-fixes = [
144146
"UP036", # Remove unnecessary `sys.version_info` blocks
@@ -235,6 +237,9 @@ convention = "pep257" # https://docs.astral.sh/ruff/settings/#lint_pydocstyle_co
235237
typing_extensions = "typing_extensions"
236238
typing = "typing"
237239

240+
[tool.ruff.lint.flake8-tidy-imports.banned-api]
241+
"tempfile.NamedTemporaryFile".msg = "Use `ts_util.util.NamedTemporaryFile` instead."
242+
238243
[tool.ruff.lint.isort]
239244
split-on-trailing-comma = false
240245
combine-as-imports = true

tests/mypy_test.py

+29-93
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,12 @@
55

66
import argparse
77
import concurrent.futures
8-
import functools
98
import os
109
import subprocess
1110
import sys
1211
import tempfile
1312
import time
1413
from collections import defaultdict
15-
from collections.abc import Generator
1614
from dataclasses import dataclass
1715
from enum import Enum
1816
from itertools import product
@@ -21,10 +19,10 @@
2119
from typing import Annotated, Any, NamedTuple
2220
from typing_extensions import TypeAlias
2321

24-
import tomli
2522
from packaging.requirements import Requirement
2623

27-
from ts_utils.metadata import PackageDependencies, get_recursive_requirements, metadata_path, read_metadata
24+
from ts_utils.metadata import PackageDependencies, get_recursive_requirements, read_metadata
25+
from ts_utils.mypy import MypyDistConf, mypy_configuration_from_distribution, temporary_mypy_config_file
2826
from ts_utils.paths import STDLIB_PATH, STUBS_PATH, TESTS_DIR, TS_BASE_PATH, distribution_path
2927
from ts_utils.utils import (
3028
PYTHON_VERSION,
@@ -46,24 +44,6 @@
4644
print_error("Cannot import mypy. Did you install it?")
4745
sys.exit(1)
4846

49-
# We need to work around a limitation of tempfile.NamedTemporaryFile on Windows
50-
# For details, see https://github.com/python/typeshed/pull/13620#discussion_r1990185997
51-
# Python 3.12 added a workaround with `tempfile.NamedTemporaryFile("w+", delete_on_close=False)`
52-
if sys.platform != "win32":
53-
_named_temporary_file = functools.partial(tempfile.NamedTemporaryFile, "w+")
54-
else:
55-
from contextlib import contextmanager
56-
57-
@contextmanager
58-
def _named_temporary_file() -> Generator[tempfile._TemporaryFileWrapper[str]]: # pyright: ignore[reportPrivateUsage]
59-
temp = tempfile.NamedTemporaryFile("w+", delete=False) # noqa: SIM115
60-
try:
61-
yield temp
62-
finally:
63-
temp.close()
64-
os.remove(temp.name)
65-
66-
6747
SUPPORTED_VERSIONS = ["3.13", "3.12", "3.11", "3.10", "3.9"]
6848
SUPPORTED_PLATFORMS = ("linux", "win32", "darwin")
6949
DIRECTORIES_TO_TEST = [STDLIB_PATH, STUBS_PATH]
@@ -177,49 +157,20 @@ def add_files(files: list[Path], module: Path, args: TestConfig) -> None:
177157
files.extend(sorted(file for file in module.rglob("*.pyi") if match(file, args)))
178158

179159

180-
class MypyDistConf(NamedTuple):
181-
module_name: str
182-
values: dict[str, dict[str, Any]]
183-
184-
185-
# The configuration section in the metadata file looks like the following, with multiple module sections possible
186-
# [mypy-tests]
187-
# [mypy-tests.yaml]
188-
# module_name = "yaml"
189-
# [mypy-tests.yaml.values]
190-
# disallow_incomplete_defs = true
191-
# disallow_untyped_defs = true
192-
193-
194-
def add_configuration(configurations: list[MypyDistConf], distribution: str) -> None:
195-
with metadata_path(distribution).open("rb") as f:
196-
data = tomli.load(f)
197-
198-
# TODO: This could be added to ts_utils.metadata, but is currently unused
199-
mypy_tests_conf: dict[str, dict[str, Any]] = data.get("mypy-tests", {})
200-
if not mypy_tests_conf:
201-
return
202-
203-
assert isinstance(mypy_tests_conf, dict), "mypy-tests should be a section"
204-
for section_name, mypy_section in mypy_tests_conf.items():
205-
assert isinstance(mypy_section, dict), f"{section_name} should be a section"
206-
module_name = mypy_section.get("module_name")
207-
208-
assert module_name is not None, f"{section_name} should have a module_name key"
209-
assert isinstance(module_name, str), f"{section_name} should be a key-value pair"
210-
211-
assert "values" in mypy_section, f"{section_name} should have a values section"
212-
values: dict[str, dict[str, Any]] = mypy_section["values"]
213-
assert isinstance(values, dict), "values should be a section"
214-
215-
configurations.append(MypyDistConf(module_name, values.copy()))
216-
217-
218160
class MypyResult(Enum):
219161
SUCCESS = 0
220162
FAILURE = 1
221163
CRASH = 2
222164

165+
@staticmethod
166+
def from_process_result(result: subprocess.CompletedProcess[Any]) -> MypyResult:
167+
if result.returncode == 0:
168+
return MypyResult.SUCCESS
169+
elif result.returncode == 1:
170+
return MypyResult.FAILURE
171+
else:
172+
return MypyResult.CRASH
173+
223174

224175
def run_mypy(
225176
args: TestConfig,
@@ -234,15 +185,7 @@ def run_mypy(
234185
env_vars = dict(os.environ)
235186
if mypypath is not None:
236187
env_vars["MYPYPATH"] = mypypath
237-
238-
with _named_temporary_file() as temp:
239-
temp.write("[mypy]\n")
240-
for dist_conf in configurations:
241-
temp.write(f"[mypy-{dist_conf.module_name}]\n")
242-
for k, v in dist_conf.values.items():
243-
temp.write(f"{k} = {v}\n")
244-
temp.flush()
245-
188+
with temporary_mypy_config_file(configurations) as temp:
246189
flags = [
247190
"--python-version",
248191
args.version,
@@ -278,29 +221,23 @@ def run_mypy(
278221
if args.verbose:
279222
print(colored(f"running {' '.join(mypy_command)}", "blue"))
280223
result = subprocess.run(mypy_command, capture_output=True, text=True, env=env_vars, check=False)
281-
if result.returncode:
282-
print_error(f"failure (exit code {result.returncode})\n")
283-
if result.stdout:
284-
print_error(result.stdout)
285-
if result.stderr:
286-
print_error(result.stderr)
287-
if non_types_dependencies and args.verbose:
288-
print("Ran with the following environment:")
289-
subprocess.run(["uv", "pip", "freeze"], env={**os.environ, "VIRTUAL_ENV": str(venv_dir)}, check=False)
290-
print()
291-
else:
292-
print_success_msg()
293-
if result.returncode == 0:
294-
return MypyResult.SUCCESS
295-
elif result.returncode == 1:
296-
return MypyResult.FAILURE
297-
else:
298-
return MypyResult.CRASH
224+
if result.returncode:
225+
print_error(f"failure (exit code {result.returncode})\n")
226+
if result.stdout:
227+
print_error(result.stdout)
228+
if result.stderr:
229+
print_error(result.stderr)
230+
if non_types_dependencies and args.verbose:
231+
print("Ran with the following environment:")
232+
subprocess.run(["uv", "pip", "freeze"], env={**os.environ, "VIRTUAL_ENV": str(venv_dir)}, check=False)
233+
print()
234+
else:
235+
print_success_msg()
236+
237+
return MypyResult.from_process_result(result)
299238

300239

301-
def add_third_party_files(
302-
distribution: str, files: list[Path], args: TestConfig, configurations: list[MypyDistConf], seen_dists: set[str]
303-
) -> None:
240+
def add_third_party_files(distribution: str, files: list[Path], args: TestConfig, seen_dists: set[str]) -> None:
304241
typeshed_reqs = get_recursive_requirements(distribution).typeshed_pkgs
305242
if distribution in seen_dists:
306243
return
@@ -311,7 +248,6 @@ def add_third_party_files(
311248
if name.startswith("."):
312249
continue
313250
add_files(files, (root / name), args)
314-
add_configuration(configurations, distribution)
315251

316252

317253
class TestResult(NamedTuple):
@@ -328,9 +264,9 @@ def test_third_party_distribution(
328264
and the second element is the number of checked files.
329265
"""
330266
files: list[Path] = []
331-
configurations: list[MypyDistConf] = []
332267
seen_dists: set[str] = set()
333-
add_third_party_files(distribution, files, args, configurations, seen_dists)
268+
add_third_party_files(distribution, files, args, seen_dists)
269+
configurations = mypy_configuration_from_distribution(distribution)
334270

335271
if not files and args.filter:
336272
return TestResult(MypyResult.SUCCESS, 0)

0 commit comments

Comments
 (0)