Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit a8fa1ab

Browse files
Add mypy plugin support to stubtest configuration (#13948)
1 parent 0933303 commit a8fa1ab

6 files changed

Lines changed: 61 additions & 5 deletions

File tree

CONTRIBUTING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,12 @@ This has the following keys:
229229
If not specified, stubtest is run only on `linux`.
230230
Only add extra OSes to the test
231231
if there are platform-specific branches in a stubs package.
232+
* `mypy_plugins` (default: `[]`): A list of Python modules to use as mypy plugins
233+
when running stubtest. For example: `mypy_plugins = ["mypy_django_plugin.main"]`
234+
* `mypy_plugins_config` (default: `{}`): A dictionary mapping plugin names to their
235+
configuration dictionaries for use by mypy plugins. For example:
236+
`mypy_plugins_config = {"django-stubs" = {"django_settings_module" = "@tests.django_settings"}}`
237+
232238

233239
`*_dependencies` are usually packages needed to `pip install` the implementation
234240
distribution.

lib/ts_utils/metadata.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from collections.abc import Mapping
1212
from dataclasses import dataclass
1313
from pathlib import Path
14-
from typing import Annotated, Final, NamedTuple, final
14+
from typing import Annotated, Any, Final, NamedTuple, final
1515
from typing_extensions import TypeGuard
1616

1717
import tomli
@@ -42,6 +42,10 @@ def _is_list_of_strings(obj: object) -> TypeGuard[list[str]]:
4242
return isinstance(obj, list) and all(isinstance(item, str) for item in obj)
4343

4444

45+
def _is_nested_dict(obj: object) -> TypeGuard[dict[str, dict[str, Any]]]:
46+
return isinstance(obj, dict) and all(isinstance(k, str) and isinstance(v, dict) for k, v in obj.items())
47+
48+
4549
@functools.cache
4650
def _get_oldest_supported_python() -> str:
4751
with PYPROJECT_PATH.open("rb") as config:
@@ -71,6 +75,8 @@ class StubtestSettings:
7175
ignore_missing_stub: bool
7276
platforms: list[str]
7377
stubtest_requirements: list[str]
78+
mypy_plugins: list[str]
79+
mypy_plugins_config: dict[str, dict[str, Any]]
7480

7581
def system_requirements_for_platform(self, platform: str) -> list[str]:
7682
assert platform in _STUBTEST_PLATFORM_MAPPING, f"Unrecognised platform {platform!r}"
@@ -93,6 +99,8 @@ def read_stubtest_settings(distribution: str) -> StubtestSettings:
9399
ignore_missing_stub: object = data.get("ignore_missing_stub", False)
94100
specified_platforms: object = data.get("platforms", ["linux"])
95101
stubtest_requirements: object = data.get("stubtest_requirements", [])
102+
mypy_plugins: object = data.get("mypy_plugins", [])
103+
mypy_plugins_config: object = data.get("mypy_plugins_config", {})
96104

97105
assert type(skip) is bool
98106
assert type(ignore_missing_stub) is bool
@@ -104,6 +112,8 @@ def read_stubtest_settings(distribution: str) -> StubtestSettings:
104112
assert _is_list_of_strings(choco_dependencies)
105113
assert _is_list_of_strings(extras)
106114
assert _is_list_of_strings(stubtest_requirements)
115+
assert _is_list_of_strings(mypy_plugins)
116+
assert _is_nested_dict(mypy_plugins_config)
107117

108118
unrecognised_platforms = set(specified_platforms) - _STUBTEST_PLATFORM_MAPPING.keys()
109119
assert not unrecognised_platforms, f"Unrecognised platforms specified for {distribution!r}: {unrecognised_platforms}"
@@ -124,6 +134,8 @@ def read_stubtest_settings(distribution: str) -> StubtestSettings:
124134
ignore_missing_stub=ignore_missing_stub,
125135
platforms=specified_platforms,
126136
stubtest_requirements=stubtest_requirements,
137+
mypy_plugins=mypy_plugins,
138+
mypy_plugins_config=mypy_plugins_config,
127139
)
128140

129141

@@ -179,6 +191,8 @@ def is_obsolete(self) -> bool:
179191
"ignore_missing_stub",
180192
"platforms",
181193
"stubtest_requirements",
194+
"mypy_plugins",
195+
"mypy_plugins_config",
182196
}
183197
}
184198
_DIST_NAME_RE: Final = re.compile(r"^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$", re.IGNORECASE)

lib/ts_utils/mypy.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import tomli
88

9-
from ts_utils.metadata import metadata_path
9+
from ts_utils.metadata import StubtestSettings, metadata_path
1010
from ts_utils.utils import NamedTemporaryFile, TemporaryFileWrapper
1111

1212

@@ -50,14 +50,27 @@ def validate_configuration(section_name: str, mypy_section: dict[str, Any]) -> M
5050

5151

5252
@contextmanager
53-
def temporary_mypy_config_file(configurations: Iterable[MypyDistConf]) -> Generator[TemporaryFileWrapper[str]]:
53+
def temporary_mypy_config_file(
54+
configurations: Iterable[MypyDistConf], stubtest_settings: StubtestSettings | None = None
55+
) -> Generator[TemporaryFileWrapper[str]]:
5456
temp = NamedTemporaryFile("w+")
5557
try:
5658
for dist_conf in configurations:
5759
temp.write(f"[mypy-{dist_conf.module_name}]\n")
5860
for k, v in dist_conf.values.items():
5961
temp.write(f"{k} = {v}\n")
6062
temp.write("[mypy]\n")
63+
64+
if stubtest_settings:
65+
if stubtest_settings.mypy_plugins:
66+
temp.write(f"plugins = {'.'.join(stubtest_settings.mypy_plugins)}\n")
67+
68+
if stubtest_settings.mypy_plugins_config:
69+
for plugin_name, plugin_dict in stubtest_settings.mypy_plugins_config.items():
70+
temp.write(f"[mypy.plugins.{plugin_name}]\n")
71+
for k, v in plugin_dict.items():
72+
temp.write(f"{k} = {v}\n")
73+
6174
temp.flush()
6275
yield temp
6376
finally:

tests/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,23 @@ that stubtest reports to be missing should necessarily be added to the stub.
196196
For some implementation details, it is often better to add allowlist entries
197197
for missing objects rather than trying to match the runtime in every detail.
198198

199+
### Support for mypy plugins in stubtest
200+
201+
For stubs that require mypy plugins to check correctly (such as Django), stubtest
202+
supports configuring mypy plugins through the METADATA.toml file. This allows stubtest to
203+
leverage type information provided by these plugins when validating stubs.
204+
205+
To use this feature, add the following configuration to the `tool.stubtest` section in your METADATA.toml:
206+
207+
```toml
208+
mypy_plugins = ["mypy_django_plugin.main"]
209+
mypy_plugins_config = { "django-stubs" = { "django_settings_module" = "@tests.django_settings" } }
210+
```
211+
212+
For Django stubs specifically, you'll need to create a `django_settings.py` file in your `@tests` directory
213+
that contains the Django settings required by the plugin. This file will be referenced by the plugin
214+
configuration to properly validate Django-specific types during stubtest execution.
215+
199216
## typecheck\_typeshed.py
200217

201218
Run using

tests/check_typeshed_structure.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
# consistent CI runs.
2828
linters = {"mypy", "pyright", "pytype", "ruff"}
2929

30+
ALLOWED_PY_FILES_IN_TESTS_DIR = {
31+
"django_settings.py" # This file contains Django settings used by the mypy_django_plugin during stubtest execution.
32+
}
33+
3034

3135
def assert_consistent_filetypes(
3236
directory: Path, *, kind: str, allowed: set[str], allow_nonidentifier_filenames: bool = False
@@ -81,7 +85,9 @@ def check_stubs() -> None:
8185

8286

8387
def check_tests_dir(tests_dir: Path) -> None:
84-
py_files_present = any(file.suffix == ".py" for file in tests_dir.iterdir())
88+
py_files_present = any(
89+
file.suffix == ".py" and file.name not in ALLOWED_PY_FILES_IN_TESTS_DIR for file in tests_dir.iterdir()
90+
)
8591
error_message = f"Test-case files must be in an `{TESTS_DIR}/{TEST_CASES_DIR}` directory, not in the `{TESTS_DIR}` directory"
8692
assert not py_files_present, error_message
8793

tests/stubtest_third_party.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def run_stubtest(
9797
return False
9898

9999
mypy_configuration = mypy_configuration_from_distribution(dist_name)
100-
with temporary_mypy_config_file(mypy_configuration) as temp:
100+
with temporary_mypy_config_file(mypy_configuration, stubtest_settings) as temp:
101101
ignore_missing_stub = ["--ignore-missing-stub"] if stubtest_settings.ignore_missing_stub else []
102102
packages_to_check = [d.name for d in dist.iterdir() if d.is_dir() and d.name.isidentifier()]
103103
modules_to_check = [d.stem for d in dist.iterdir() if d.is_file() and d.suffix == ".pyi"]

0 commit comments

Comments
 (0)