From 33d3a99bef8ef86d87e9f71300d2b7e1462bd6f1 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Tue, 6 May 2025 11:41:04 +0700 Subject: [PATCH 1/6] Add mypy plugin support to stubtest configuration --- CONTRIBUTING.md | 6 ++++++ lib/ts_utils/metadata.py | 15 ++++++++++++++- lib/ts_utils/mypy.py | 15 +++++++++++++-- tests/README.md | 17 +++++++++++++++++ tests/check_typeshed_structure.py | 5 ++++- tests/stubtest_third_party.py | 2 +- 6 files changed, 55 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e6645ede68bc..34e341d0272e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -229,6 +229,12 @@ This has the following keys: If not specified, stubtest is run only on `linux`. Only add extra OSes to the test if there are platform-specific branches in a stubs package. +* `mypy_plugins` (default: `[]`): A list of Python modules to use as mypy plugins +when running stubtest. For example: `mypy_plugins = ["mypy_django_plugin.main"]` +* `mypy_plugins_config` (default: `{}`): A dictionary mapping plugin names to their +configuration dictionaries for use by mypy plugins. For example: +`mypy_plugins_config = {"django-stubs" = {"django_settings_module" = "django_settings"}}` + `*_dependencies` are usually packages needed to `pip install` the implementation distribution. diff --git a/lib/ts_utils/metadata.py b/lib/ts_utils/metadata.py index ec30f9301425..e8e0f73d5f33 100644 --- a/lib/ts_utils/metadata.py +++ b/lib/ts_utils/metadata.py @@ -11,7 +11,7 @@ from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Annotated, Final, NamedTuple, final +from typing import Annotated, Final, NamedTuple, final, Any from typing_extensions import TypeGuard import tomli @@ -41,6 +41,9 @@ def _is_list_of_strings(obj: object) -> TypeGuard[list[str]]: return isinstance(obj, list) and all(isinstance(item, str) for item in obj) +def _is_nested_dict(obj: object) -> TypeGuard[dict[str, dict[str, Any]]]: + return isinstance(obj, dict) and all(isinstance(item, dict) for item in obj.values()) + @functools.cache def _get_oldest_supported_python() -> str: @@ -71,6 +74,8 @@ class StubtestSettings: ignore_missing_stub: bool platforms: list[str] stubtest_requirements: list[str] + mypy_plugins: list[str] + mypy_plugins_config: dict[str, dict[str, Any]] def system_requirements_for_platform(self, platform: str) -> list[str]: assert platform in _STUBTEST_PLATFORM_MAPPING, f"Unrecognised platform {platform!r}" @@ -93,6 +98,8 @@ def read_stubtest_settings(distribution: str) -> StubtestSettings: ignore_missing_stub: object = data.get("ignore_missing_stub", False) specified_platforms: object = data.get("platforms", ["linux"]) stubtest_requirements: object = data.get("stubtest_requirements", []) + mypy_plugins: object = data.get("mypy_plugins", []) + mypy_plugins_config: object = data.get("mypy_plugins_config", {}) assert type(skip) is bool assert type(ignore_missing_stub) is bool @@ -104,6 +111,8 @@ def read_stubtest_settings(distribution: str) -> StubtestSettings: assert _is_list_of_strings(choco_dependencies) assert _is_list_of_strings(extras) assert _is_list_of_strings(stubtest_requirements) + assert _is_list_of_strings(mypy_plugins) + assert _is_nested_dict(mypy_plugins_config) unrecognised_platforms = set(specified_platforms) - _STUBTEST_PLATFORM_MAPPING.keys() assert not unrecognised_platforms, f"Unrecognised platforms specified for {distribution!r}: {unrecognised_platforms}" @@ -124,6 +133,8 @@ def read_stubtest_settings(distribution: str) -> StubtestSettings: ignore_missing_stub=ignore_missing_stub, platforms=specified_platforms, stubtest_requirements=stubtest_requirements, + mypy_plugins=mypy_plugins, + mypy_plugins_config=mypy_plugins_config, ) @@ -179,6 +190,8 @@ def is_obsolete(self) -> bool: "ignore_missing_stub", "platforms", "stubtest_requirements", + "mypy_plugins", + "mypy_plugins_config", } } _DIST_NAME_RE: Final = re.compile(r"^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$", re.IGNORECASE) diff --git a/lib/ts_utils/mypy.py b/lib/ts_utils/mypy.py index 7fc050b155d1..dc058539cdb0 100644 --- a/lib/ts_utils/mypy.py +++ b/lib/ts_utils/mypy.py @@ -6,7 +6,7 @@ import tomli -from ts_utils.metadata import metadata_path +from ts_utils.metadata import metadata_path, StubtestSettings from ts_utils.utils import NamedTemporaryFile, TemporaryFileWrapper @@ -50,7 +50,7 @@ def validate_configuration(section_name: str, mypy_section: dict[str, Any]) -> M @contextmanager -def temporary_mypy_config_file(configurations: Iterable[MypyDistConf]) -> Generator[TemporaryFileWrapper[str]]: +def temporary_mypy_config_file(configurations: Iterable[MypyDistConf], stubtest_settings: StubtestSettings | None = None) -> Generator[TemporaryFileWrapper[str]]: temp = NamedTemporaryFile("w+") try: for dist_conf in configurations: @@ -58,6 +58,17 @@ def temporary_mypy_config_file(configurations: Iterable[MypyDistConf]) -> Genera for k, v in dist_conf.values.items(): temp.write(f"{k} = {v}\n") temp.write("[mypy]\n") + + if stubtest_settings: + if stubtest_settings.mypy_plugins: + temp.write(f"plugins = {'.'.join(stubtest_settings.mypy_plugins)}\n") + + if stubtest_settings.mypy_plugins_config: + for plugin_name, plugin_dict in stubtest_settings.mypy_plugins_config.items(): + temp.write(f"[mypy.plugins.{plugin_name}]\n") + for k, v in plugin_dict.items(): + temp.write(f"{k} = {v}\n") + temp.flush() yield temp finally: diff --git a/tests/README.md b/tests/README.md index a00b1733146c..9df4f9c7ac6b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -196,6 +196,23 @@ that stubtest reports to be missing should necessarily be added to the stub. For some implementation details, it is often better to add allowlist entries for missing objects rather than trying to match the runtime in every detail. +### Support for mypy plugins in stubtest + +For stubs that require mypy plugins to check correctly (such as Django), stubtest +supports configuring mypy plugins through the METADATA.toml file. This allows stubtest to +leverage type information provided by these plugins when validating stubs. + +To use this feature, add the following configuration to the `tool.stubtest` section in your METADATA.toml: + +```toml +mypy_plugins = ["mypy_django_plugin.main"] +mypy_plugins_config = { "django-stubs" = { "django_settings_module" = "django_settings" } } +``` + +For Django stubs specifically, you'll need to create a `django_settings.py` file in your test directory +that contains the Django settings required by the plugin. This file will be referenced by the plugin +configuration to properly validate Django-specific types during stubtest execution. + ## typecheck\_typeshed.py Run using diff --git a/tests/check_typeshed_structure.py b/tests/check_typeshed_structure.py index bcb02061e055..672dd01a34f3 100755 --- a/tests/check_typeshed_structure.py +++ b/tests/check_typeshed_structure.py @@ -72,7 +72,10 @@ def check_stubs() -> None: ), f"Directory name must be a valid distribution name: {dist}" assert not dist.name.startswith("types-"), f"Directory name not allowed to start with 'types-': {dist}" - allowed = {"METADATA.toml", "README", "README.md", "README.rst", TESTS_DIR} + extra_allowed_files = { + "django_settings.py", # This file contains Django settings used by the mypy_django_plugin during stubtest execution. + } + allowed = {"METADATA.toml", "README", "README.md", "README.rst", *extra_allowed_files, TESTS_DIR} assert_consistent_filetypes(dist, kind=".pyi", allowed=allowed) tests_dir = tests_path(dist.name) diff --git a/tests/stubtest_third_party.py b/tests/stubtest_third_party.py index 0530f6279628..1b853c1c408e 100755 --- a/tests/stubtest_third_party.py +++ b/tests/stubtest_third_party.py @@ -97,7 +97,7 @@ def run_stubtest( return False mypy_configuration = mypy_configuration_from_distribution(dist_name) - with temporary_mypy_config_file(mypy_configuration) as temp: + with temporary_mypy_config_file(mypy_configuration, stubtest_settings) 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"] From 7a879ac3ed88fa79a7bc63e478245849d08d305b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 6 May 2025 04:43:40 +0000 Subject: [PATCH 2/6] [pre-commit.ci] auto fixes from pre-commit.com hooks --- lib/ts_utils/metadata.py | 3 ++- lib/ts_utils/mypy.py | 8 +++++--- tests/check_typeshed_structure.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/ts_utils/metadata.py b/lib/ts_utils/metadata.py index e8e0f73d5f33..fd21a5bbe4a3 100644 --- a/lib/ts_utils/metadata.py +++ b/lib/ts_utils/metadata.py @@ -11,7 +11,7 @@ from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Annotated, Final, NamedTuple, final, Any +from typing import Annotated, Any, Final, NamedTuple, final from typing_extensions import TypeGuard import tomli @@ -41,6 +41,7 @@ def _is_list_of_strings(obj: object) -> TypeGuard[list[str]]: return isinstance(obj, list) and all(isinstance(item, str) for item in obj) + def _is_nested_dict(obj: object) -> TypeGuard[dict[str, dict[str, Any]]]: return isinstance(obj, dict) and all(isinstance(item, dict) for item in obj.values()) diff --git a/lib/ts_utils/mypy.py b/lib/ts_utils/mypy.py index dc058539cdb0..39f4255ec011 100644 --- a/lib/ts_utils/mypy.py +++ b/lib/ts_utils/mypy.py @@ -6,7 +6,7 @@ import tomli -from ts_utils.metadata import metadata_path, StubtestSettings +from ts_utils.metadata import StubtestSettings, metadata_path from ts_utils.utils import NamedTemporaryFile, TemporaryFileWrapper @@ -50,7 +50,9 @@ def validate_configuration(section_name: str, mypy_section: dict[str, Any]) -> M @contextmanager -def temporary_mypy_config_file(configurations: Iterable[MypyDistConf], stubtest_settings: StubtestSettings | None = None) -> Generator[TemporaryFileWrapper[str]]: +def temporary_mypy_config_file( + configurations: Iterable[MypyDistConf], stubtest_settings: StubtestSettings | None = None +) -> Generator[TemporaryFileWrapper[str]]: temp = NamedTemporaryFile("w+") try: for dist_conf in configurations: @@ -62,7 +64,7 @@ def temporary_mypy_config_file(configurations: Iterable[MypyDistConf], stubtest_ if stubtest_settings: if stubtest_settings.mypy_plugins: temp.write(f"plugins = {'.'.join(stubtest_settings.mypy_plugins)}\n") - + if stubtest_settings.mypy_plugins_config: for plugin_name, plugin_dict in stubtest_settings.mypy_plugins_config.items(): temp.write(f"[mypy.plugins.{plugin_name}]\n") diff --git a/tests/check_typeshed_structure.py b/tests/check_typeshed_structure.py index 672dd01a34f3..1ae62295df94 100755 --- a/tests/check_typeshed_structure.py +++ b/tests/check_typeshed_structure.py @@ -73,7 +73,7 @@ def check_stubs() -> None: assert not dist.name.startswith("types-"), f"Directory name not allowed to start with 'types-': {dist}" extra_allowed_files = { - "django_settings.py", # This file contains Django settings used by the mypy_django_plugin during stubtest execution. + "django_settings.py" # This file contains Django settings used by the mypy_django_plugin during stubtest execution. } allowed = {"METADATA.toml", "README", "README.md", "README.rst", *extra_allowed_files, TESTS_DIR} assert_consistent_filetypes(dist, kind=".pyi", allowed=allowed) From 6d3dac130c8c7d901f6be871643afb2c7c8169b2 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Tue, 6 May 2025 12:07:04 +0700 Subject: [PATCH 3/6] Remove allowing django_settings.py file, and suggest put django settings file to @tests dir instead --- CONTRIBUTING.md | 2 +- tests/README.md | 4 ++-- tests/check_typeshed_structure.py | 5 +---- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34e341d0272e..60ac72a9ad05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -233,7 +233,7 @@ This has the following keys: when running stubtest. For example: `mypy_plugins = ["mypy_django_plugin.main"]` * `mypy_plugins_config` (default: `{}`): A dictionary mapping plugin names to their configuration dictionaries for use by mypy plugins. For example: -`mypy_plugins_config = {"django-stubs" = {"django_settings_module" = "django_settings"}}` +`mypy_plugins_config = {"django-stubs" = {"django_settings_module" = "@tests.jango_settings"}}` `*_dependencies` are usually packages needed to `pip install` the implementation diff --git a/tests/README.md b/tests/README.md index 9df4f9c7ac6b..e2fd81fb7362 100644 --- a/tests/README.md +++ b/tests/README.md @@ -206,10 +206,10 @@ To use this feature, add the following configuration to the `tool.stubtest` sect ```toml mypy_plugins = ["mypy_django_plugin.main"] -mypy_plugins_config = { "django-stubs" = { "django_settings_module" = "django_settings" } } +mypy_plugins_config = { "django-stubs" = { "django_settings_module" = "@tests.django_settings" } } ``` -For Django stubs specifically, you'll need to create a `django_settings.py` file in your test directory +For Django stubs specifically, you'll need to create a `django_settings.py` file in your `@tests` directory that contains the Django settings required by the plugin. This file will be referenced by the plugin configuration to properly validate Django-specific types during stubtest execution. diff --git a/tests/check_typeshed_structure.py b/tests/check_typeshed_structure.py index 1ae62295df94..bcb02061e055 100755 --- a/tests/check_typeshed_structure.py +++ b/tests/check_typeshed_structure.py @@ -72,10 +72,7 @@ def check_stubs() -> None: ), f"Directory name must be a valid distribution name: {dist}" assert not dist.name.startswith("types-"), f"Directory name not allowed to start with 'types-': {dist}" - extra_allowed_files = { - "django_settings.py" # This file contains Django settings used by the mypy_django_plugin during stubtest execution. - } - allowed = {"METADATA.toml", "README", "README.md", "README.rst", *extra_allowed_files, TESTS_DIR} + allowed = {"METADATA.toml", "README", "README.md", "README.rst", TESTS_DIR} assert_consistent_filetypes(dist, kind=".pyi", allowed=allowed) tests_dir = tests_path(dist.name) From ce0740fcfd3945589b643922e929df1bad934ab6 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Tue, 6 May 2025 12:19:56 +0700 Subject: [PATCH 4/6] Allow django_settings.py file to put in @tests folder --- tests/check_typeshed_structure.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/check_typeshed_structure.py b/tests/check_typeshed_structure.py index bcb02061e055..2ab6b335fd1a 100755 --- a/tests/check_typeshed_structure.py +++ b/tests/check_typeshed_structure.py @@ -27,6 +27,10 @@ # consistent CI runs. linters = {"mypy", "pyright", "pytype", "ruff"} +ALLOWED_PY_FILES_IN_TESTS_DIR = { + "django_settings.py" # This file contains Django settings used by the mypy_django_plugin during stubtest execution. +} + def assert_consistent_filetypes( directory: Path, *, kind: str, allowed: set[str], allow_nonidentifier_filenames: bool = False @@ -81,7 +85,9 @@ def check_stubs() -> None: def check_tests_dir(tests_dir: Path) -> None: - py_files_present = any(file.suffix == ".py" for file in tests_dir.iterdir()) + py_files_present = any( + file.suffix == ".py" and file.name not in ALLOWED_PY_FILES_IN_TESTS_DIR for file in tests_dir.iterdir() + ) error_message = f"Test-case files must be in an `{TESTS_DIR}/{TEST_CASES_DIR}` directory, not in the `{TESTS_DIR}` directory" assert not py_files_present, error_message From 307524e85712f2913b67026100814d1ec1d17ae7 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Tue, 6 May 2025 12:35:08 +0700 Subject: [PATCH 5/6] Correct doc typo --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60ac72a9ad05..47c40eb5e175 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -233,7 +233,7 @@ This has the following keys: when running stubtest. For example: `mypy_plugins = ["mypy_django_plugin.main"]` * `mypy_plugins_config` (default: `{}`): A dictionary mapping plugin names to their configuration dictionaries for use by mypy plugins. For example: -`mypy_plugins_config = {"django-stubs" = {"django_settings_module" = "@tests.jango_settings"}}` +`mypy_plugins_config = {"django-stubs" = {"django_settings_module" = "@tests.django_settings"}}` `*_dependencies` are usually packages needed to `pip install` the implementation From a6f32f4823b15348f58f7d6bdc09fea47ee8cf49 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Tue, 6 May 2025 20:18:55 +0700 Subject: [PATCH 6/6] Improve nested dict type check in _is_nested_dict --- lib/ts_utils/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ts_utils/metadata.py b/lib/ts_utils/metadata.py index fd21a5bbe4a3..2cf093ffc4a4 100644 --- a/lib/ts_utils/metadata.py +++ b/lib/ts_utils/metadata.py @@ -43,7 +43,7 @@ def _is_list_of_strings(obj: object) -> TypeGuard[list[str]]: def _is_nested_dict(obj: object) -> TypeGuard[dict[str, dict[str, Any]]]: - return isinstance(obj, dict) and all(isinstance(item, dict) for item in obj.values()) + return isinstance(obj, dict) and all(isinstance(k, str) and isinstance(v, dict) for k, v in obj.items()) @functools.cache