Skip to content

Commit 050fb9b

Browse files
committed
Add Mypy configuration through root "pyproject.toml" file
It is not uncommon to require a Mypy configuration that differs from the project's main configuration and is specific to tests, such as enabling the 'force_uppercase_builtins' option. Currently, the argument '--mypy-pyproject-toml-file' can be used via the command line, but this approach has two drawbacks: - It requires an additional file in the codebase, whereas it is more pleasant to group all configurations in the root 'pyproject.toml' file. - It confines the invocation of 'pytest' to a fixed location, as the path is resolved relative to the current working directory. However, there are situations where it is useful to call 'pytest' from a different directory. The solution implemented here allows for configuring the Mypy parameters used by 'pytest-mypy-plugins' directly within the project's 'pyproject.toml' file, addressing both of the aforementioned points.
1 parent c80f1eb commit 050fb9b

File tree

9 files changed

+173
-25
lines changed

9 files changed

+173
-25
lines changed

Diff for: .github/workflows/test.yml

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ jobs:
2626
run: |
2727
pip install -U pip setuptools wheel
2828
pip install -e .
29+
# Workaround until Mypy regression is fixed.
30+
pip install mypy==1.5.1
2931
# Force correct `pytest` version for different envs:
3032
pip install -U "pytest${{ matrix.pytest-version }}"
3133
- name: Run tests

Diff for: README.md

+10
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,16 @@ mypy-tests:
195195

196196
```
197197
198+
## Configuration
199+
200+
For convenience, it is also possible to define a default `mypy` configuration in the root `pyproject.toml` file of your project:
201+
202+
```toml
203+
[tool.pytest-mypy-plugins.mypy-config]
204+
force_uppercase_builtins = true
205+
force_union_syntax = true
206+
```
207+
198208
## Further reading
199209

200210
- [Testing mypy stubs, plugins, and types](https://sobolevn.me/2019/08/testing-mypy-types)

Diff for: pytest_mypy_plugins/configs.py

+34-17
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
11
from configparser import ConfigParser
22
from pathlib import Path
33
from textwrap import dedent
4-
from typing import Final, Optional
4+
from typing import Any, Dict, Final, Optional
55

66
import tomlkit
77

88
_TOML_TABLE_NAME: Final = "[tool.mypy]"
99

1010

11-
def join_ini_configs(base_ini_fpath: Optional[str], additional_mypy_config: str, execution_path: Path) -> Optional[str]:
11+
def load_mypy_plugins_config(config_pyproject_toml_path: str) -> Optional[Dict[str, Any]]:
12+
with open(config_pyproject_toml_path) as f:
13+
toml_config = tomlkit.parse(f.read())
14+
return toml_config.get("tool", {}).get("pytest-mypy-plugins", {}).get("mypy-config")
15+
16+
17+
def join_ini_configs(
18+
mypy_plugins_config: Optional[Dict[str, Any]],
19+
base_ini_fpath: Optional[str],
20+
additional_mypy_config: str,
21+
execution_path: Path,
22+
) -> Optional[str]:
1223
mypy_ini_config = ConfigParser()
24+
if mypy_plugins_config:
25+
mypy_ini_config.read_dict({"mypy": mypy_plugins_config})
1326
if base_ini_fpath:
1427
mypy_ini_config.read(base_ini_fpath)
1528
if additional_mypy_config:
@@ -26,34 +39,38 @@ def join_ini_configs(base_ini_fpath: Optional[str], additional_mypy_config: str,
2639

2740

2841
def join_toml_configs(
29-
base_pyproject_toml_fpath: str, additional_mypy_config: str, execution_path: Path
42+
mypy_plugins_config: Optional[Dict[str, Any]],
43+
base_pyproject_toml_fpath: str,
44+
additional_mypy_config: str,
45+
execution_path: Path,
3046
) -> Optional[str]:
47+
# Empty document with `[tool.mypy]` empty table, useful for overrides further.
48+
toml_document = tomlkit.document()
49+
tool = tomlkit.table(is_super_table=True)
50+
tool.append("mypy", tomlkit.table())
51+
toml_document.append("tool", tool)
52+
53+
if mypy_plugins_config:
54+
toml_document["tool"]["mypy"].update(mypy_plugins_config.items()) # type: ignore[index, union-attr]
55+
3156
if base_pyproject_toml_fpath:
3257
with open(base_pyproject_toml_fpath) as f:
3358
toml_config = tomlkit.parse(f.read())
34-
else:
35-
# Emtpy document with `[tool.mypy` empty table,
36-
# useful for overrides further.
37-
toml_config = tomlkit.document()
38-
39-
if "tool" not in toml_config or "mypy" not in toml_config["tool"]: # type: ignore[operator]
40-
tool = tomlkit.table(is_super_table=True)
41-
tool.append("mypy", tomlkit.table())
42-
toml_config.append("tool", tool)
59+
# We don't want the whole config file, because it can contain
60+
# other sections like `[tool.isort]`, we only need `[tool.mypy]` part.
61+
if "tool" in toml_config and "mypy" in toml_config["tool"]: # type: ignore[operator]
62+
toml_document["tool"]["mypy"].update(toml_config["tool"]["mypy"].value.items()) # type: ignore[index, union-attr]
4363

4464
if additional_mypy_config:
4565
if _TOML_TABLE_NAME not in additional_mypy_config:
4666
additional_mypy_config = f"{_TOML_TABLE_NAME}\n{dedent(additional_mypy_config)}"
4767

4868
additional_data = tomlkit.parse(additional_mypy_config)
49-
toml_config["tool"]["mypy"].update( # type: ignore[index, union-attr]
69+
toml_document["tool"]["mypy"].update( # type: ignore[index, union-attr]
5070
additional_data["tool"]["mypy"].value.items(), # type: ignore[index]
5171
)
5272

5373
mypy_config_file_path = execution_path / "pyproject.toml"
5474
with mypy_config_file_path.open("w") as f:
55-
# We don't want the whole config file, because it can contain
56-
# other sections like `[tool.isort]`, we only need `[tool.mypy]` part.
57-
f.write(f"{_TOML_TABLE_NAME}\n")
58-
f.write(dedent(toml_config["tool"]["mypy"].as_string())) # type: ignore[index]
75+
f.write(toml_document.as_string())
5976
return str(mypy_config_file_path)

Diff for: pytest_mypy_plugins/item.py

+16-3
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ def __init__(
141141
if self.config.option.mypy_ini_file and self.config.option.mypy_pyproject_toml_file:
142142
raise ValueError("Cannot specify both `--mypy-ini-file` and `--mypy-pyproject-toml-file`")
143143

144+
# Optionally retrieve plugin configuration through the root `pyproject.toml` file.
145+
if (self.config.rootpath / "pyproject.toml").exists():
146+
self.config_pyproject_toml_fpath: Optional[str] = str(self.config.rootpath / "pyproject.toml")
147+
else:
148+
self.config_pyproject_toml_fpath = None
149+
144150
if self.config.option.mypy_ini_file:
145151
self.base_ini_fpath = os.path.abspath(self.config.option.mypy_ini_file)
146152
else:
@@ -318,18 +324,25 @@ def prepare_mypy_cmd_options(self, execution_path: Path) -> List[str]:
318324
return mypy_cmd_options
319325

320326
def prepare_config_file(self, execution_path: Path) -> Optional[str]:
327+
# We allow a default Mypy config in root `pyproject.toml` file. This is useful to define
328+
# options that are specific to the tests without requiring an additional file.
329+
if self.config_pyproject_toml_fpath:
330+
mypy_plugins_config = configs.load_mypy_plugins_config(self.config_pyproject_toml_fpath)
331+
321332
# Merge (`self.base_ini_fpath` or `base_pyproject_toml_fpath`)
322333
# and `self.additional_mypy_config`
323334
# into one file and copy to the typechecking folder:
324335
if self.base_pyproject_toml_fpath:
325336
return configs.join_toml_configs(
326-
self.base_pyproject_toml_fpath, self.additional_mypy_config, execution_path
337+
mypy_plugins_config, self.base_pyproject_toml_fpath, self.additional_mypy_config, execution_path
327338
)
328-
elif self.base_ini_fpath or self.additional_mypy_config:
339+
elif self.base_ini_fpath or self.additional_mypy_config or self.config_pyproject_toml_fpath:
329340
# We might have `self.base_ini_fpath` set as well.
330341
# Or this might be a legacy case: only `mypy_config:` is set in the `yaml` test case.
331342
# This means that no real file is provided.
332-
return configs.join_ini_configs(self.base_ini_fpath, self.additional_mypy_config, execution_path)
343+
return configs.join_ini_configs(
344+
mypy_plugins_config, self.base_ini_fpath, self.additional_mypy_config, execution_path
345+
)
333346
return None
334347

335348
def repr_failure(

Diff for: pytest_mypy_plugins/tests/test_configs/pyproject1.toml

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
warn_unused_ignores = true
55
pretty = true
66
show_error_codes = true
7+
show_error_context = true
78

89
[tool.other]
910
# This section should not be copied:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# This file has `[tool.pytest-mypy-plugins.mypy-config]` existing config
2+
3+
[tool.pytest-mypy-plugins.mypy-config]
4+
pretty = true
5+
show_error_codes = true
6+
warn_unused_ignores = true
7+
8+
[tool.other]
9+
# This section should not be copied:
10+
key = 'value'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# This file has no `[tool.pytest-mypy-plugins.mypy-config]` existing config

Diff for: pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py

+79-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from pathlib import Path
22
from textwrap import dedent
3-
from typing import Callable, Final, Optional
3+
from typing import Any, Callable, Dict, Final, Optional
44

55
import pytest
66

@@ -19,6 +19,13 @@
1919
show_traceback = true
2020
"""
2121

22+
_MYPY_PLUGINS_CONFIG1: Final = {
23+
"pretty": False,
24+
"show_column_numbers": True,
25+
"show_error_context": False,
26+
}
27+
_MYPY_PLUGINS_CONFIG2: Final = None
28+
2229
_PYPROJECT1: Final = str(Path(__file__).parent / "pyproject1.toml")
2330
_PYPROJECT2: Final = str(Path(__file__).parent / "pyproject2.toml")
2431

@@ -54,7 +61,7 @@ def factory(filename: Optional[str], expected: str) -> None:
5461
def test_join_existing_config(
5562
execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str
5663
) -> None:
57-
filepath = join_toml_configs(_PYPROJECT1, additional_config, execution_path)
64+
filepath = join_toml_configs(_MYPY_PLUGINS_CONFIG2, _PYPROJECT1, additional_config, execution_path)
5865

5966
assert_file_contents(
6067
filepath,
@@ -63,6 +70,33 @@ def test_join_existing_config(
6370
warn_unused_ignores = true
6471
pretty = true
6572
show_error_codes = false
73+
show_error_context = true
74+
show_traceback = true
75+
""",
76+
)
77+
78+
79+
@pytest.mark.parametrize(
80+
"additional_config",
81+
[
82+
_ADDITIONAL_CONFIG,
83+
_ADDITIONAL_CONFIG_NO_TABLE,
84+
],
85+
)
86+
def test_join_existing_config1(
87+
execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str
88+
) -> None:
89+
filepath = join_toml_configs(_MYPY_PLUGINS_CONFIG1, _PYPROJECT1, additional_config, execution_path)
90+
91+
assert_file_contents(
92+
filepath,
93+
"""
94+
[tool.mypy]
95+
pretty = true
96+
show_column_numbers = true
97+
show_error_context = true
98+
warn_unused_ignores = true
99+
show_error_codes = false
66100
show_traceback = true
67101
""",
68102
)
@@ -78,7 +112,7 @@ def test_join_existing_config(
78112
def test_join_missing_config(
79113
execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str
80114
) -> None:
81-
filepath = join_toml_configs(_PYPROJECT2, additional_config, execution_path)
115+
filepath = join_toml_configs(_MYPY_PLUGINS_CONFIG2, _PYPROJECT2, additional_config, execution_path)
82116

83117
assert_file_contents(
84118
filepath,
@@ -92,7 +126,7 @@ def test_join_missing_config(
92126

93127

94128
def test_join_missing_config1(execution_path: Path, assert_file_contents: _AssertFileContents) -> None:
95-
filepath = join_toml_configs(_PYPROJECT1, "", execution_path)
129+
filepath = join_toml_configs(_MYPY_PLUGINS_CONFIG2, _PYPROJECT1, "", execution_path)
96130

97131
assert_file_contents(
98132
filepath,
@@ -101,14 +135,54 @@ def test_join_missing_config1(execution_path: Path, assert_file_contents: _Asser
101135
warn_unused_ignores = true
102136
pretty = true
103137
show_error_codes = true
138+
show_error_context = true
104139
""",
105140
)
106141

107142

108143
def test_join_missing_config2(execution_path: Path, assert_file_contents: _AssertFileContents) -> None:
109-
filepath = join_toml_configs(_PYPROJECT2, "", execution_path)
144+
filepath = join_toml_configs(_MYPY_PLUGINS_CONFIG2, _PYPROJECT2, "", execution_path)
110145

111146
assert_file_contents(
112147
filepath,
113148
"[tool.mypy]",
114149
)
150+
151+
152+
@pytest.mark.parametrize(
153+
"additional_config",
154+
[
155+
_ADDITIONAL_CONFIG,
156+
_ADDITIONAL_CONFIG_NO_TABLE,
157+
],
158+
)
159+
def test_join_missing_config3(
160+
execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str
161+
) -> None:
162+
filepath = join_toml_configs(_MYPY_PLUGINS_CONFIG1, _PYPROJECT2, additional_config, execution_path)
163+
164+
assert_file_contents(
165+
filepath,
166+
"""
167+
[tool.mypy]
168+
pretty = true
169+
show_column_numbers = true
170+
show_error_context = false
171+
show_error_codes = false
172+
show_traceback = true
173+
""",
174+
)
175+
176+
177+
def test_join_missing_config4(execution_path: Path, assert_file_contents: _AssertFileContents) -> None:
178+
filepath = join_toml_configs(_MYPY_PLUGINS_CONFIG1, _PYPROJECT2, "", execution_path)
179+
180+
assert_file_contents(
181+
filepath,
182+
"""
183+
[tool.mypy]
184+
pretty = false
185+
show_column_numbers = true
186+
show_error_context = false
187+
""",
188+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from pathlib import Path
2+
from typing import Final
3+
4+
from pytest_mypy_plugins.configs import load_mypy_plugins_config
5+
6+
7+
def test_load_existing_config() -> None:
8+
root_pyproject1: Final = str(Path(__file__).parent / "root_pyproject1.toml")
9+
result = load_mypy_plugins_config(root_pyproject1)
10+
assert result == {
11+
"pretty": True,
12+
"show_error_codes": True,
13+
"warn_unused_ignores": True,
14+
}
15+
16+
17+
def test_load_missing_config() -> None:
18+
root_pyproject2: Final = str(Path(__file__).parent / "root_pyproject2.toml")
19+
result = load_mypy_plugins_config(root_pyproject2)
20+
assert result is None

0 commit comments

Comments
 (0)