Skip to content

Apply mypy-tests custom config to other mypy-based tests #13825

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions lib/ts_utils/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ def is_obsolete(self) -> bool:
"tool",
"partial_stub",
"requires_python",
"mypy-tests",
}
)
_KNOWN_METADATA_TOOL_FIELDS: Final = {
Expand Down
64 changes: 64 additions & 0 deletions lib/ts_utils/mypy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from __future__ import annotations

from collections.abc import Generator, Iterable
from contextlib import contextmanager
from typing import Any, NamedTuple

import tomli

from ts_utils.metadata import metadata_path
from ts_utils.utils import NamedTemporaryFile, TemporaryFileWrapper


class MypyDistConf(NamedTuple):
module_name: str
values: dict[str, dict[str, Any]]


# The configuration section in the metadata file looks like the following, with multiple module sections possible
# [mypy-tests]
# [mypy-tests.yaml]
# module_name = "yaml"
# [mypy-tests.yaml.values]
# disallow_incomplete_defs = true
# disallow_untyped_defs = true


def mypy_configuration_from_distribution(distribution: str) -> list[MypyDistConf]:
with metadata_path(distribution).open("rb") as f:
data = tomli.load(f)

# TODO: This could be added to ts_utils.metadata
mypy_tests_conf: dict[str, dict[str, Any]] = data.get("mypy-tests", {})
if not mypy_tests_conf:
return []

def validate_configuration(section_name: str, mypy_section: dict[str, Any]) -> MypyDistConf:
assert isinstance(mypy_section, dict), f"{section_name} should be a section"
module_name = mypy_section.get("module_name")

assert module_name is not None, f"{section_name} should have a module_name key"
assert isinstance(module_name, str), f"{section_name} should be a key-value pair"

assert "values" in mypy_section, f"{section_name} should have a values section"
values: dict[str, dict[str, Any]] = mypy_section["values"]
assert isinstance(values, dict), "values should be a section"
return MypyDistConf(module_name, values.copy())

assert isinstance(mypy_tests_conf, dict), "mypy-tests should be a section"
return [validate_configuration(section_name, mypy_section) for section_name, mypy_section in mypy_tests_conf.items()]


@contextmanager
def temporary_mypy_config_file(configurations: Iterable[MypyDistConf]) -> Generator[TemporaryFileWrapper[str]]:
temp = NamedTemporaryFile("w+")
try:
for dist_conf in configurations:
temp.write(f"[mypy-{dist_conf.module_name}]\n")
for k, v in dist_conf.values.items():
temp.write(f"{k} = {v}\n")
temp.write("[mypy]\n")
temp.flush()
yield temp
finally:
temp.close()
34 changes: 30 additions & 4 deletions lib/ts_utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,24 @@
from __future__ import annotations

import functools
import os
import re
import sys
import tempfile
from collections.abc import Iterable, Mapping
from pathlib import Path
from typing import Any, Final, NamedTuple
from types import MethodType
from typing import TYPE_CHECKING, Any, Final, NamedTuple
from typing_extensions import TypeAlias

import pathspec
from packaging.requirements import Requirement

from .paths import REQUIREMENTS_PATH, STDLIB_PATH, STUBS_PATH, TEST_CASES_DIR, allowlists_path, test_cases_path

if TYPE_CHECKING:
from _typeshed import OpenTextMode

try:
from termcolor import colored as colored # pyright: ignore[reportAssignmentType]
except ImportError:
Expand All @@ -21,8 +29,6 @@ def colored(text: str, color: str | None = None, **kwargs: Any) -> str: # type:
return text


from .paths import REQUIREMENTS_PATH, STDLIB_PATH, STUBS_PATH, TEST_CASES_DIR, allowlists_path, test_cases_path

PYTHON_VERSION: Final = f"{sys.version_info.major}.{sys.version_info.minor}"


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


# Re-exposing as a public name to avoid many pyright reportPrivateUsage
TemporaryFileWrapper = tempfile._TemporaryFileWrapper # pyright: ignore[reportPrivateUsage]

# We need to work around a limitation of tempfile.NamedTemporaryFile on Windows
# For details, see https://github.com/python/typeshed/pull/13620#discussion_r1990185997
# Python 3.12 added a cross-platform solution with `tempfile.NamedTemporaryFile("w+", delete_on_close=False)`
if sys.platform != "win32":
NamedTemporaryFile = tempfile.NamedTemporaryFile # noqa: TID251
else:

def NamedTemporaryFile(mode: OpenTextMode) -> TemporaryFileWrapper[str]: # noqa: N802
def close(self: TemporaryFileWrapper[str]) -> None:
TemporaryFileWrapper.close(self) # pyright: ignore[reportUnknownMemberType]
os.remove(self.name)

temp = tempfile.NamedTemporaryFile(mode, delete=False) # noqa: SIM115, TID251
temp.close = MethodType(close, temp) # type: ignore[method-assign]
return temp


# ====================================================================
# Parsing .gitignore
# ====================================================================
Expand All @@ -215,7 +241,7 @@ def spec_matches_path(spec: pathspec.PathSpec, path: Path) -> bool:


# ====================================================================
# mypy/stubtest call
# stubtest call
# ====================================================================


Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ select = [
"TC005", # Found empty type-checking block
# "TC008", # TODO: Enable when out of preview
"TC010", # Invalid string member in `X | Y`-style union type
# Used for lint.flake8-import-conventions.aliases
"TID251", # `{name}` is banned: {message}
]
extend-safe-fixes = [
"UP036", # Remove unnecessary `sys.version_info` blocks
Expand Down Expand Up @@ -235,6 +237,9 @@ convention = "pep257" # https://docs.astral.sh/ruff/settings/#lint_pydocstyle_co
typing_extensions = "typing_extensions"
typing = "typing"

[tool.ruff.lint.flake8-tidy-imports.banned-api]
"tempfile.NamedTemporaryFile".msg = "Use `ts_util.util.NamedTemporaryFile` instead."

[tool.ruff.lint.isort]
split-on-trailing-comma = false
combine-as-imports = true
Expand Down
122 changes: 29 additions & 93 deletions tests/mypy_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@

import argparse
import concurrent.futures
import functools
import os
import subprocess
import sys
import tempfile
import time
from collections import defaultdict
from collections.abc import Generator
from dataclasses import dataclass
from enum import Enum
from itertools import product
Expand All @@ -21,10 +19,10 @@
from typing import Annotated, Any, NamedTuple
from typing_extensions import TypeAlias

import tomli
from packaging.requirements import Requirement

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

# We need to work around a limitation of tempfile.NamedTemporaryFile on Windows
# For details, see https://github.com/python/typeshed/pull/13620#discussion_r1990185997
# Python 3.12 added a workaround with `tempfile.NamedTemporaryFile("w+", delete_on_close=False)`
if sys.platform != "win32":
_named_temporary_file = functools.partial(tempfile.NamedTemporaryFile, "w+")
else:
from contextlib import contextmanager

@contextmanager
def _named_temporary_file() -> Generator[tempfile._TemporaryFileWrapper[str]]: # pyright: ignore[reportPrivateUsage]
temp = tempfile.NamedTemporaryFile("w+", delete=False) # noqa: SIM115
try:
yield temp
finally:
temp.close()
os.remove(temp.name)


SUPPORTED_VERSIONS = ["3.13", "3.12", "3.11", "3.10", "3.9"]
SUPPORTED_PLATFORMS = ("linux", "win32", "darwin")
DIRECTORIES_TO_TEST = [STDLIB_PATH, STUBS_PATH]
Expand Down Expand Up @@ -177,49 +157,20 @@ def add_files(files: list[Path], module: Path, args: TestConfig) -> None:
files.extend(sorted(file for file in module.rglob("*.pyi") if match(file, args)))


class MypyDistConf(NamedTuple):
module_name: str
values: dict[str, dict[str, Any]]


# The configuration section in the metadata file looks like the following, with multiple module sections possible
# [mypy-tests]
# [mypy-tests.yaml]
# module_name = "yaml"
# [mypy-tests.yaml.values]
# disallow_incomplete_defs = true
# disallow_untyped_defs = true


def add_configuration(configurations: list[MypyDistConf], distribution: str) -> None:
with metadata_path(distribution).open("rb") as f:
data = tomli.load(f)

# TODO: This could be added to ts_utils.metadata, but is currently unused
mypy_tests_conf: dict[str, dict[str, Any]] = data.get("mypy-tests", {})
if not mypy_tests_conf:
return

assert isinstance(mypy_tests_conf, dict), "mypy-tests should be a section"
for section_name, mypy_section in mypy_tests_conf.items():
assert isinstance(mypy_section, dict), f"{section_name} should be a section"
module_name = mypy_section.get("module_name")

assert module_name is not None, f"{section_name} should have a module_name key"
assert isinstance(module_name, str), f"{section_name} should be a key-value pair"

assert "values" in mypy_section, f"{section_name} should have a values section"
values: dict[str, dict[str, Any]] = mypy_section["values"]
assert isinstance(values, dict), "values should be a section"

configurations.append(MypyDistConf(module_name, values.copy()))


class MypyResult(Enum):
SUCCESS = 0
FAILURE = 1
CRASH = 2

@staticmethod
def from_process_result(result: subprocess.CompletedProcess[Any]) -> MypyResult:
if result.returncode == 0:
return MypyResult.SUCCESS
elif result.returncode == 1:
return MypyResult.FAILURE
else:
return MypyResult.CRASH


def run_mypy(
args: TestConfig,
Expand All @@ -234,15 +185,7 @@ def run_mypy(
env_vars = dict(os.environ)
if mypypath is not None:
env_vars["MYPYPATH"] = mypypath

with _named_temporary_file() as temp:
temp.write("[mypy]\n")
for dist_conf in configurations:
temp.write(f"[mypy-{dist_conf.module_name}]\n")
for k, v in dist_conf.values.items():
temp.write(f"{k} = {v}\n")
temp.flush()

with temporary_mypy_config_file(configurations) as temp:
flags = [
"--python-version",
args.version,
Expand Down Expand Up @@ -278,29 +221,23 @@ def run_mypy(
if args.verbose:
print(colored(f"running {' '.join(mypy_command)}", "blue"))
result = subprocess.run(mypy_command, capture_output=True, text=True, env=env_vars, check=False)
if result.returncode:
print_error(f"failure (exit code {result.returncode})\n")
if result.stdout:
print_error(result.stdout)
if result.stderr:
print_error(result.stderr)
if non_types_dependencies and args.verbose:
print("Ran with the following environment:")
subprocess.run(["uv", "pip", "freeze"], env={**os.environ, "VIRTUAL_ENV": str(venv_dir)}, check=False)
print()
else:
print_success_msg()
if result.returncode == 0:
return MypyResult.SUCCESS
elif result.returncode == 1:
return MypyResult.FAILURE
else:
return MypyResult.CRASH
if result.returncode:
print_error(f"failure (exit code {result.returncode})\n")
if result.stdout:
print_error(result.stdout)
if result.stderr:
print_error(result.stderr)
if non_types_dependencies and args.verbose:
print("Ran with the following environment:")
subprocess.run(["uv", "pip", "freeze"], env={**os.environ, "VIRTUAL_ENV": str(venv_dir)}, check=False)
print()
else:
print_success_msg()

return MypyResult.from_process_result(result)


def add_third_party_files(
distribution: str, files: list[Path], args: TestConfig, configurations: list[MypyDistConf], seen_dists: set[str]
) -> None:
def add_third_party_files(distribution: str, files: list[Path], args: TestConfig, seen_dists: set[str]) -> None:
typeshed_reqs = get_recursive_requirements(distribution).typeshed_pkgs
if distribution in seen_dists:
return
Expand All @@ -311,7 +248,6 @@ def add_third_party_files(
if name.startswith("."):
continue
add_files(files, (root / name), args)
add_configuration(configurations, distribution)


class TestResult(NamedTuple):
Expand All @@ -328,9 +264,9 @@ def test_third_party_distribution(
and the second element is the number of checked files.
"""
files: list[Path] = []
configurations: list[MypyDistConf] = []
seen_dists: set[str] = set()
add_third_party_files(distribution, files, args, configurations, seen_dists)
add_third_party_files(distribution, files, args, seen_dists)
configurations = mypy_configuration_from_distribution(distribution)

if not files and args.filter:
return TestResult(MypyResult.SUCCESS, 0)
Expand Down
Loading