From b3011c8395372a730a80274bef379e182c0e5c25 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 13 Apr 2025 20:29:28 -0400 Subject: [PATCH 01/11] Apply mypy-tests custom config to other mypy-based tests --- lib/ts_utils/metadata.py | 1 + lib/ts_utils/mypy.py | 80 +++++++++++++++++ tests/mypy_test.py | 131 ++++++--------------------- tests/regr_test.py | 112 ++++++++++++----------- tests/stubtest_third_party.py | 165 ++++++++++++++++++---------------- 5 files changed, 256 insertions(+), 233 deletions(-) create mode 100644 lib/ts_utils/mypy.py diff --git a/lib/ts_utils/metadata.py b/lib/ts_utils/metadata.py index f851ce536519..ec30f9301425 100644 --- a/lib/ts_utils/metadata.py +++ b/lib/ts_utils/metadata.py @@ -166,6 +166,7 @@ def is_obsolete(self) -> bool: "tool", "partial_stub", "requires_python", + "mypy-tests", } ) _KNOWN_METADATA_TOOL_FIELDS: Final = { diff --git a/lib/ts_utils/mypy.py b/lib/ts_utils/mypy.py new file mode 100644 index 000000000000..28e7adc851c8 --- /dev/null +++ b/lib/ts_utils/mypy.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import os +import sys +import tempfile +from collections.abc import Generator, Iterable +from contextlib import contextmanager +from enum import Enum +from typing import Any, NamedTuple + +import tomli + +from ts_utils.metadata import metadata_path + + +class MypyDistConf(NamedTuple): + module_name: str + values: dict[str, dict[str, Any]] + + +class MypyResult(Enum): + SUCCESS = 0 + FAILURE = 1 + CRASH = 2 + + +# 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[tempfile._TemporaryFileWrapper[str]]: # 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 workaround with `tempfile.NamedTemporaryFile("w+", delete_on_close=False)` + temp = tempfile.NamedTemporaryFile("w+", delete=sys.platform != "win32") # noqa: SIM115 + 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() + if sys.platform == "win32": + os.remove(temp.name) diff --git a/tests/mypy_test.py b/tests/mypy_test.py index 2eeb532d1ca6..f3b98b2a45ae 100755 --- a/tests/mypy_test.py +++ b/tests/mypy_test.py @@ -5,26 +5,23 @@ 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 from pathlib import Path from threading import Lock -from typing import Annotated, Any, NamedTuple -from typing_extensions import TypeAlias +from typing import NamedTuple +from typing_extensions import Annotated, 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, MypyResult, 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, @@ -46,24 +43,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] @@ -177,50 +156,6 @@ 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 - - def run_mypy( args: TestConfig, configurations: list[MypyDistConf], @@ -234,15 +169,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, @@ -278,29 +205,27 @@ 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() + if result.returncode == 0: + return MypyResult.SUCCESS + elif result.returncode == 1: + return MypyResult.FAILURE + else: + return MypyResult.CRASH -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 @@ -311,7 +236,6 @@ def add_third_party_files( if name.startswith("."): continue add_files(files, (root / name), args) - add_configuration(configurations, distribution) class TestResult(NamedTuple): @@ -328,9 +252,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) @@ -393,7 +317,8 @@ def stdlib_module_name_from_path(path: Path) -> str: assert path.suffix == ".pyi" parts = list(path.parts[1:-1]) if path.parts[-1] != "__init__.pyi": - parts.append(path.parts[-1].removesuffix(".pyi")) + # TODO: Python 3.9+: Use removesuffix. + parts.append(path.parts[-1][:-4]) return ".".join(parts) diff --git a/tests/regr_test.py b/tests/regr_test.py index fc4e48c55ff6..32bfb259390c 100755 --- a/tests/regr_test.py +++ b/tests/regr_test.py @@ -22,6 +22,7 @@ from typing_extensions import TypeAlias from ts_utils.metadata import get_recursive_requirements, read_metadata +from ts_utils.mypy import mypy_configuration_from_distribution, temporary_mypy_config_file from ts_utils.paths import STDLIB_PATH, TEST_CASES_DIR, TS_BASE_PATH, distribution_path from ts_utils.utils import ( PYTHON_VERSION, @@ -169,62 +170,71 @@ def run_testcases( env_vars = dict(os.environ) new_test_case_dir = tempdir / TEST_CASES_DIR - # "--enable-error-code ignore-without-code" is purposefully omitted. - # See https://github.com/python/typeshed/pull/8083 - flags = [ - "--python-version", - version, - "--show-traceback", - "--no-error-summary", - "--platform", - platform, - "--strict", - "--pretty", - # Avoid race conditions when reading the cache - # (https://github.com/python/typeshed/issues/11220) - "--no-incremental", - # Not useful for the test cases - "--disable-error-code=empty-body", - ] - if package.is_stdlib: - python_exe = sys.executable - custom_typeshed = TS_BASE_PATH - flags.append("--no-site-packages") + configurations = [] else: - custom_typeshed = tempdir / TYPESHED - env_vars["MYPYPATH"] = os.pathsep.join(map(str, custom_typeshed.glob("stubs/*"))) - has_non_types_dependencies = (tempdir / VENV_DIR).exists() - if has_non_types_dependencies: - python_exe = str(venv_python(tempdir / VENV_DIR)) - else: + configurations = mypy_configuration_from_distribution(package.name) + + with temporary_mypy_config_file(configurations) as temp: + + # "--enable-error-code ignore-without-code" is purposefully omitted. + # See https://github.com/python/typeshed/pull/8083 + flags = [ + "--python-version", + version, + "--show-traceback", + "--no-error-summary", + "--platform", + platform, + "--strict", + "--pretty", + "--config-file", + temp.name, + # Avoid race conditions when reading the cache + # (https://github.com/python/typeshed/issues/11220) + "--no-incremental", + # Not useful for the test cases + "--disable-error-code=empty-body", + ] + + if package.is_stdlib: python_exe = sys.executable + custom_typeshed = TS_BASE_PATH flags.append("--no-site-packages") - - flags.extend(["--custom-typeshed-dir", str(custom_typeshed)]) - - # If the test-case filename ends with -py39, - # only run the test if --python-version was set to 3.9 or higher (for example) - for path in new_test_case_dir.rglob("*.py"): - if match := re.fullmatch(r".*-py3(\d{1,2})", path.stem): - minor_version_required = int(match[1]) - assert f"3.{minor_version_required}" in SUPPORTED_VERSIONS - python_minor_version = int(version.split(".")[1]) - if minor_version_required > python_minor_version: - continue - flags.append(str(path)) - - mypy_command = [python_exe, "-m", "mypy", *flags] - if verbosity is Verbosity.VERBOSE: - description = f"{package.name}/{version}/{platform}" - msg = f"{description}: {mypy_command=}\n" - if "MYPYPATH" in env_vars: - msg += f"{description}: {env_vars['MYPYPATH']=}" else: - msg += f"{description}: MYPYPATH not set" - msg += "\n" - verbose_log(msg) - return subprocess.run(mypy_command, capture_output=True, text=True, env=env_vars, check=False) + custom_typeshed = tempdir / TYPESHED + env_vars["MYPYPATH"] = os.pathsep.join(map(str, custom_typeshed.glob("stubs/*"))) + has_non_types_dependencies = (tempdir / VENV_DIR).exists() + if has_non_types_dependencies: + python_exe = str(venv_python(tempdir / VENV_DIR)) + else: + python_exe = sys.executable + flags.append("--no-site-packages") + + flags.extend(["--custom-typeshed-dir", str(custom_typeshed)]) + + # If the test-case filename ends with -py39, + # only run the test if --python-version was set to 3.9 or higher (for example) + for path in new_test_case_dir.rglob("*.py"): + if match := re.fullmatch(r".*-py3(\d{1,2})", path.stem): + minor_version_required = int(match[1]) + assert f"3.{minor_version_required}" in SUPPORTED_VERSIONS + python_minor_version = int(version.split(".")[1]) + if minor_version_required > python_minor_version: + continue + flags.append(str(path)) + + mypy_command = [python_exe, "-m", "mypy", *flags] + if verbosity is Verbosity.VERBOSE: + description = f"{package.name}/{version}/{platform}" + msg = f"{description}: {mypy_command=}\n" + if "MYPYPATH" in env_vars: + msg += f"{description}: {env_vars['MYPYPATH']=}" + else: + msg += f"{description}: MYPYPATH not set" + msg += "\n" + verbose_log(msg) + return subprocess.run(mypy_command, capture_output=True, text=True, env=env_vars, check=False) @dataclass(frozen=True) diff --git a/tests/stubtest_third_party.py b/tests/stubtest_third_party.py index 8b8cb6265dfd..0530f6279628 100755 --- a/tests/stubtest_third_party.py +++ b/tests/stubtest_third_party.py @@ -16,6 +16,7 @@ from typing import NoReturn from ts_utils.metadata import NoSuchStubError, get_recursive_requirements, read_metadata +from ts_utils.mypy import mypy_configuration_from_distribution, temporary_mypy_config_file from ts_utils.paths import STUBS_PATH, allowlists_path, tests_path from ts_utils.utils import ( PYTHON_VERSION, @@ -95,89 +96,95 @@ def run_stubtest( print_command_failure("Failed to install", e) return False - ignore_missing_stub = ["--ignore-missing-stub"] if stubtest_settings.ignore_missing_stub else [] - packages_to_check = [d.name for d in dist.iterdir() if d.is_dir() and d.name.isidentifier()] - modules_to_check = [d.stem for d in dist.iterdir() if d.is_file() and d.suffix == ".pyi"] - stubtest_cmd = [ - python_exe, - "-m", - "mypy.stubtest", - # Use --custom-typeshed-dir in case we make linked changes to stdlib or _typeshed - "--custom-typeshed-dir", - str(dist.parent.parent), - *ignore_missing_stub, - *packages_to_check, - *modules_to_check, - *allowlist_stubtest_arguments(dist_name), - ] + mypy_configuration = mypy_configuration_from_distribution(dist_name) + with temporary_mypy_config_file(mypy_configuration) as temp: + ignore_missing_stub = ["--ignore-missing-stub"] if stubtest_settings.ignore_missing_stub else [] + packages_to_check = [d.name for d in dist.iterdir() if d.is_dir() and d.name.isidentifier()] + modules_to_check = [d.stem for d in dist.iterdir() if d.is_file() and d.suffix == ".pyi"] + stubtest_cmd = [ + python_exe, + "-m", + "mypy.stubtest", + "--mypy-config-file", + temp.name, + # Use --custom-typeshed-dir in case we make linked changes to stdlib or _typeshed + "--custom-typeshed-dir", + str(dist.parent.parent), + *ignore_missing_stub, + *packages_to_check, + *modules_to_check, + *allowlist_stubtest_arguments(dist_name), + ] + + stubs_dir = dist.parent + mypypath_items = [str(dist)] + [str(stubs_dir / pkg.name) for pkg in requirements.typeshed_pkgs] + mypypath = os.pathsep.join(mypypath_items) + # For packages that need a display, we need to pass at least $DISPLAY + # to stubtest. $DISPLAY is set by xvfb-run in CI. + # + # It seems that some other environment variables are needed too, + # because the CI fails if we pass only os.environ["DISPLAY"]. I didn't + # "bisect" to see which variables are actually needed. + stubtest_env = os.environ | {"MYPYPATH": mypypath, "MYPY_FORCE_COLOR": "1"} + + # Perform some black magic in order to run stubtest inside uWSGI + if dist_name == "uWSGI": + if not setup_uwsgi_stubtest_command(dist, venv_dir, stubtest_cmd): + return False + + if dist_name == "gdb": + if not setup_gdb_stubtest_command(venv_dir, stubtest_cmd): + return False + + try: + subprocess.run(stubtest_cmd, env=stubtest_env, check=True, capture_output=True) + except subprocess.CalledProcessError as e: + print_time(time() - t) + print_error("fail") + + print_divider() + print("Commands run:") + print_commands(pip_cmd, stubtest_cmd, mypypath) + + print_divider() + print("Command output:\n") + print_command_output(e) + + print_divider() + print("Python version: ", end="", flush=True) + ret = subprocess.run([sys.executable, "-VV"], capture_output=True, check=False) + print_command_output(ret) - stubs_dir = dist.parent - mypypath_items = [str(dist)] + [str(stubs_dir / pkg.name) for pkg in requirements.typeshed_pkgs] - mypypath = os.pathsep.join(mypypath_items) - # For packages that need a display, we need to pass at least $DISPLAY - # to stubtest. $DISPLAY is set by xvfb-run in CI. - # - # It seems that some other environment variables are needed too, - # because the CI fails if we pass only os.environ["DISPLAY"]. I didn't - # "bisect" to see which variables are actually needed. - stubtest_env = os.environ | {"MYPYPATH": mypypath, "MYPY_FORCE_COLOR": "1"} - - # Perform some black magic in order to run stubtest inside uWSGI - if dist_name == "uWSGI": - if not setup_uwsgi_stubtest_command(dist, venv_dir, stubtest_cmd): - return False + print("\nRan with the following environment:") + ret = subprocess.run([pip_exe, "freeze", "--all"], capture_output=True, check=False) + print_command_output(ret) + if keep_tmp_dir: + print("Path to virtual environment:", venv_dir, flush=True) + + print_divider() + main_allowlist_path = allowlists_path(dist_name) / "stubtest_allowlist.txt" + if main_allowlist_path.exists(): + print(f'To fix "unused allowlist" errors, remove the corresponding entries from {main_allowlist_path}') + print() + else: + print(f"Re-running stubtest with --generate-allowlist.\nAdd the following to {main_allowlist_path}:") + ret = subprocess.run( + [*stubtest_cmd, "--generate-allowlist"], env=stubtest_env, capture_output=True, check=False + ) + print_command_output(ret) + + print_divider() + print(f"Upstream repository: {metadata.upstream_repository}") + print(f"Typeshed source code: https://github.com/python/typeshed/tree/main/stubs/{dist.name}") + + print_divider() - if dist_name == "gdb": - if not setup_gdb_stubtest_command(venv_dir, stubtest_cmd): return False - - try: - subprocess.run(stubtest_cmd, env=stubtest_env, check=True, capture_output=True) - except subprocess.CalledProcessError as e: - print_time(time() - t) - print_error("fail") - - print_divider() - print("Commands run:") - print_commands(pip_cmd, stubtest_cmd, mypypath) - - print_divider() - print("Command output:\n") - print_command_output(e) - - print_divider() - print("Python version: ", end="", flush=True) - ret = subprocess.run([sys.executable, "-VV"], capture_output=True, check=False) - print_command_output(ret) - - print("\nRan with the following environment:") - ret = subprocess.run([pip_exe, "freeze", "--all"], capture_output=True, check=False) - print_command_output(ret) - if keep_tmp_dir: - print("Path to virtual environment:", venv_dir, flush=True) - - print_divider() - main_allowlist_path = allowlists_path(dist_name) / "stubtest_allowlist.txt" - if main_allowlist_path.exists(): - print(f'To fix "unused allowlist" errors, remove the corresponding entries from {main_allowlist_path}') - print() else: - print(f"Re-running stubtest with --generate-allowlist.\nAdd the following to {main_allowlist_path}:") - ret = subprocess.run([*stubtest_cmd, "--generate-allowlist"], env=stubtest_env, capture_output=True, check=False) - print_command_output(ret) - - print_divider() - print(f"Upstream repository: {metadata.upstream_repository}") - print(f"Typeshed source code: https://github.com/python/typeshed/tree/main/stubs/{dist.name}") - - print_divider() - - return False - else: - print_time(time() - t) - print_success_msg() - if keep_tmp_dir: - print_info(f"Virtual environment kept at: {venv_dir}") + print_time(time() - t) + print_success_msg() + if keep_tmp_dir: + print_info(f"Virtual environment kept at: {venv_dir}") finally: if not keep_tmp_dir: rmtree(venv_dir) From 4624afd84af4c0d833deadd19a2c8a03ba30f61e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 00:33:28 +0000 Subject: [PATCH 02/11] [pre-commit.ci] auto fixes from pre-commit.com hooks --- tests/mypy_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/mypy_test.py b/tests/mypy_test.py index f3b98b2a45ae..eb5fe8f8ed0d 100755 --- a/tests/mypy_test.py +++ b/tests/mypy_test.py @@ -15,8 +15,8 @@ from itertools import product from pathlib import Path from threading import Lock -from typing import NamedTuple -from typing_extensions import Annotated, TypeAlias +from typing import Annotated, NamedTuple +from typing_extensions import TypeAlias from packaging.requirements import Requirement From d637e45c81c3f8c00a41630a8b99f09ee7e4c04b Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 13 Apr 2025 20:33:57 -0400 Subject: [PATCH 03/11] Refresh with more updated main --- tests/mypy_test.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/mypy_test.py b/tests/mypy_test.py index f3b98b2a45ae..f697471555b7 100755 --- a/tests/mypy_test.py +++ b/tests/mypy_test.py @@ -15,8 +15,8 @@ from itertools import product from pathlib import Path from threading import Lock -from typing import NamedTuple -from typing_extensions import Annotated, TypeAlias +from typing import Annotated, NamedTuple +from typing_extensions import TypeAlias from packaging.requirements import Requirement @@ -317,8 +317,7 @@ def stdlib_module_name_from_path(path: Path) -> str: assert path.suffix == ".pyi" parts = list(path.parts[1:-1]) if path.parts[-1] != "__init__.pyi": - # TODO: Python 3.9+: Use removesuffix. - parts.append(path.parts[-1][:-4]) + parts.append(path.parts[-1].removesuffix(".pyi")) return ".".join(parts) From 0111d5d060935fb3a72d03ebec8ca0a1cec981c0 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 13 Apr 2025 20:41:29 -0400 Subject: [PATCH 04/11] Move MypyResult back to tests/mypy_test.py --- lib/ts_utils/mypy.py | 7 ------- tests/mypy_test.py | 28 ++++++++++++++++++++-------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/lib/ts_utils/mypy.py b/lib/ts_utils/mypy.py index 28e7adc851c8..c33101749ebd 100644 --- a/lib/ts_utils/mypy.py +++ b/lib/ts_utils/mypy.py @@ -5,7 +5,6 @@ import tempfile from collections.abc import Generator, Iterable from contextlib import contextmanager -from enum import Enum from typing import Any, NamedTuple import tomli @@ -18,12 +17,6 @@ class MypyDistConf(NamedTuple): values: dict[str, dict[str, Any]] -class MypyResult(Enum): - SUCCESS = 0 - FAILURE = 1 - CRASH = 2 - - # The configuration section in the metadata file looks like the following, with multiple module sections possible # [mypy-tests] # [mypy-tests.yaml] diff --git a/tests/mypy_test.py b/tests/mypy_test.py index f697471555b7..84c8fa1467a8 100755 --- a/tests/mypy_test.py +++ b/tests/mypy_test.py @@ -12,16 +12,17 @@ import time from collections import defaultdict from dataclasses import dataclass +from enum import Enum from itertools import product from pathlib import Path from threading import Lock -from typing import Annotated, NamedTuple +from typing import Annotated, Any, NamedTuple from typing_extensions import TypeAlias from packaging.requirements import Requirement from ts_utils.metadata import PackageDependencies, get_recursive_requirements, read_metadata -from ts_utils.mypy import MypyDistConf, MypyResult, mypy_configuration_from_distribution, temporary_mypy_config_file +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, @@ -156,6 +157,21 @@ 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 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, configurations: list[MypyDistConf], @@ -217,12 +233,8 @@ def run_mypy( print() else: print_success_msg() - if result.returncode == 0: - return MypyResult.SUCCESS - elif result.returncode == 1: - return MypyResult.FAILURE - else: - return MypyResult.CRASH + + return MypyResult.from_process_result(result) def add_third_party_files(distribution: str, files: list[Path], args: TestConfig, seen_dists: set[str]) -> None: From ae7cc8de0cc9f74a391a43d59d947e424ec5114d Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 19 Apr 2025 20:57:34 -0400 Subject: [PATCH 05/11] Try extracting NamedTemporaryFile workaround --- lib/ts_utils/mypy.py | 3 ++- lib/ts_utils/utils.py | 30 ++++++++++++++++++++++++++---- pyproject.toml | 5 +++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/lib/ts_utils/mypy.py b/lib/ts_utils/mypy.py index c33101749ebd..55e87112aab9 100644 --- a/lib/ts_utils/mypy.py +++ b/lib/ts_utils/mypy.py @@ -10,6 +10,7 @@ import tomli from ts_utils.metadata import metadata_path +from ts_utils.utils import NamedTemporaryFile class MypyDistConf(NamedTuple): @@ -58,7 +59,7 @@ def temporary_mypy_config_file( # 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)` - temp = tempfile.NamedTemporaryFile("w+", delete=sys.platform != "win32") # noqa: SIM115 + temp = NamedTemporaryFile("w+") try: for dist_conf in configurations: temp.write(f"[mypy-{dist_conf.module_name}]\n") diff --git a/lib/ts_utils/utils.py b/lib/ts_utils/utils.py index e4a687600099..676057b6161d 100644 --- a/lib/ts_utils/utils.py +++ b/lib/ts_utils/utils.py @@ -3,16 +3,23 @@ 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 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: @@ -21,8 +28,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}" @@ -196,6 +201,23 @@ def allowlists(distribution_name: str) -> list[str]: return ["stubtest_allowlist.txt", platform_allowlist] +# 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) -> tempfile._TemporaryFileWrapper[str]: # noqa: N802 + def close(self: tempfile._TemporaryFileWrapper[str]) -> None: + os.remove(temp.name) + return tempfile._TemporaryFileWrapper.close(self) + + temp = tempfile.NamedTemporaryFile(mode, delete=False) # noqa: SIM115, TID251 + temp.close = close # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] + return temp + + # ==================================================================== # Parsing .gitignore # ==================================================================== @@ -215,7 +237,7 @@ def spec_matches_path(spec: pathspec.PathSpec, path: Path) -> bool: # ==================================================================== -# mypy/stubtest call +# stubtest call # ==================================================================== diff --git a/pyproject.toml b/pyproject.toml index 5d6bd434156b..b3e1a5821102 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 @@ -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 From 23b2d02820a602daa11f1fac9d40ad3b2237d4f3 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 19 Apr 2025 21:00:02 -0400 Subject: [PATCH 06/11] Leftover remove in temporary_mypy_config_file --- lib/ts_utils/mypy.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/ts_utils/mypy.py b/lib/ts_utils/mypy.py index 55e87112aab9..a75b3c5700f5 100644 --- a/lib/ts_utils/mypy.py +++ b/lib/ts_utils/mypy.py @@ -1,7 +1,5 @@ from __future__ import annotations -import os -import sys import tempfile from collections.abc import Generator, Iterable from contextlib import contextmanager @@ -70,5 +68,3 @@ def temporary_mypy_config_file( yield temp finally: temp.close() - if sys.platform == "win32": - os.remove(temp.name) From 4ee3ac55403b56c099bd5c82048571889b06a8dd Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 19 Apr 2025 21:01:36 -0400 Subject: [PATCH 07/11] Simplify self.close() call --- lib/ts_utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ts_utils/utils.py b/lib/ts_utils/utils.py index 676057b6161d..bdc2cdfba47d 100644 --- a/lib/ts_utils/utils.py +++ b/lib/ts_utils/utils.py @@ -211,7 +211,7 @@ def allowlists(distribution_name: str) -> list[str]: def NamedTemporaryFile(mode: OpenTextMode) -> tempfile._TemporaryFileWrapper[str]: # noqa: N802 def close(self: tempfile._TemporaryFileWrapper[str]) -> None: os.remove(temp.name) - return tempfile._TemporaryFileWrapper.close(self) + return self.close() temp = tempfile.NamedTemporaryFile(mode, delete=False) # noqa: SIM115, TID251 temp.close = close # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] From 378f6696657c3feb2f80d6e915a58dc2ce990288 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 19 Apr 2025 21:02:13 -0400 Subject: [PATCH 08/11] Use self.name --- lib/ts_utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ts_utils/utils.py b/lib/ts_utils/utils.py index bdc2cdfba47d..2211ce138e32 100644 --- a/lib/ts_utils/utils.py +++ b/lib/ts_utils/utils.py @@ -210,7 +210,7 @@ def allowlists(distribution_name: str) -> list[str]: def NamedTemporaryFile(mode: OpenTextMode) -> tempfile._TemporaryFileWrapper[str]: # noqa: N802 def close(self: tempfile._TemporaryFileWrapper[str]) -> None: - os.remove(temp.name) + os.remove(self.name) return self.close() temp = tempfile.NamedTemporaryFile(mode, delete=False) # noqa: SIM115, TID251 From 99c62605e5ee6786eac9869fe9694c71405df7ae Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 19 Apr 2025 21:04:56 -0400 Subject: [PATCH 09/11] Flip close and remove --- lib/ts_utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ts_utils/utils.py b/lib/ts_utils/utils.py index 2211ce138e32..7fab3b0a8679 100644 --- a/lib/ts_utils/utils.py +++ b/lib/ts_utils/utils.py @@ -210,8 +210,8 @@ def allowlists(distribution_name: str) -> list[str]: def NamedTemporaryFile(mode: OpenTextMode) -> tempfile._TemporaryFileWrapper[str]: # noqa: N802 def close(self: tempfile._TemporaryFileWrapper[str]) -> None: + self.close() os.remove(self.name) - return self.close() temp = tempfile.NamedTemporaryFile(mode, delete=False) # noqa: SIM115, TID251 temp.close = close # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] From fc55a22e37f327e61610e2e6f10e032daebd56a2 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 19 Apr 2025 21:05:42 -0400 Subject: [PATCH 10/11] Close can't use self, because self-referenctial --- lib/ts_utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ts_utils/utils.py b/lib/ts_utils/utils.py index 7fab3b0a8679..9f8948e90d12 100644 --- a/lib/ts_utils/utils.py +++ b/lib/ts_utils/utils.py @@ -210,7 +210,7 @@ def allowlists(distribution_name: str) -> list[str]: def NamedTemporaryFile(mode: OpenTextMode) -> tempfile._TemporaryFileWrapper[str]: # noqa: N802 def close(self: tempfile._TemporaryFileWrapper[str]) -> None: - self.close() + tempfile._TemporaryFileWrapper.close(self) os.remove(self.name) temp = tempfile.NamedTemporaryFile(mode, delete=False) # noqa: SIM115, TID251 From 2dc0a2a1fe0710eb8ab0d5be5285a8bffc6c99c6 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 19 Apr 2025 22:04:03 -0400 Subject: [PATCH 11/11] Remove extra comment and fix pyright issues --- lib/ts_utils/mypy.py | 10 ++-------- lib/ts_utils/utils.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/lib/ts_utils/mypy.py b/lib/ts_utils/mypy.py index a75b3c5700f5..7fc050b155d1 100644 --- a/lib/ts_utils/mypy.py +++ b/lib/ts_utils/mypy.py @@ -1,6 +1,5 @@ from __future__ import annotations -import tempfile from collections.abc import Generator, Iterable from contextlib import contextmanager from typing import Any, NamedTuple @@ -8,7 +7,7 @@ import tomli from ts_utils.metadata import metadata_path -from ts_utils.utils import NamedTemporaryFile +from ts_utils.utils import NamedTemporaryFile, TemporaryFileWrapper class MypyDistConf(NamedTuple): @@ -51,12 +50,7 @@ def validate_configuration(section_name: str, mypy_section: dict[str, Any]) -> M @contextmanager -def temporary_mypy_config_file( - configurations: Iterable[MypyDistConf], -) -> Generator[tempfile._TemporaryFileWrapper[str]]: # 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 workaround with `tempfile.NamedTemporaryFile("w+", delete_on_close=False)` +def temporary_mypy_config_file(configurations: Iterable[MypyDistConf]) -> Generator[TemporaryFileWrapper[str]]: temp = NamedTemporaryFile("w+") try: for dist_conf in configurations: diff --git a/lib/ts_utils/utils.py b/lib/ts_utils/utils.py index 9f8948e90d12..fba574d7557f 100644 --- a/lib/ts_utils/utils.py +++ b/lib/ts_utils/utils.py @@ -9,6 +9,7 @@ import tempfile from collections.abc import Iterable, Mapping from pathlib import Path +from types import MethodType from typing import TYPE_CHECKING, Any, Final, NamedTuple from typing_extensions import TypeAlias @@ -201,6 +202,9 @@ 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)` @@ -208,13 +212,13 @@ def allowlists(distribution_name: str) -> list[str]: NamedTemporaryFile = tempfile.NamedTemporaryFile # noqa: TID251 else: - def NamedTemporaryFile(mode: OpenTextMode) -> tempfile._TemporaryFileWrapper[str]: # noqa: N802 - def close(self: tempfile._TemporaryFileWrapper[str]) -> None: - tempfile._TemporaryFileWrapper.close(self) + 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 = close # type: ignore[assignment] # pyright: ignore[reportAttributeAccessIssue] + temp.close = MethodType(close, temp) # type: ignore[method-assign] return temp