Skip to content

Commit 3ec2297

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 3ec2297

File tree

8 files changed

+205
-21
lines changed

8 files changed

+205
-21
lines changed

.github/workflows/test.yml

Lines changed: 2 additions & 0 deletions
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

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,22 @@ 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+
208+
The ultimate `mypy` configuration applied during a test is derived by merging the following sources (if they exist), in order:
209+
210+
1. The `mypy-config` table in the root `pyproject.toml` of the project.
211+
2. The configuration file provided via `--mypy-pyproject-toml-file` or `--mypy-ini-file`.
212+
3. The `config_mypy` field of the test case.
213+
198214
## Further reading
199215

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

pytest_mypy_plugins/configs.py

Lines changed: 34 additions & 17 deletions
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+
base_ini_fpath: Optional[str],
19+
additional_mypy_config: str,
20+
execution_path: Path,
21+
mypy_plugins_config: Optional[Dict[str, Any]] = None,
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+
base_pyproject_toml_fpath: str,
43+
additional_mypy_config: str,
44+
execution_path: Path,
45+
mypy_plugins_config: Optional[Dict[str, Any]] = None,
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)

pytest_mypy_plugins/item.py

Lines changed: 16 additions & 3 deletions
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+
self.base_pyproject_toml_fpath, self.additional_mypy_config, execution_path, mypy_plugins_config
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+
self.base_ini_fpath, self.additional_mypy_config, execution_path, mypy_plugins_config
345+
)
333346
return None
334347

335348
def repr_failure(
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
# This file has no `[tool.mypy]` existing config
1+
# This file has no `[tool.mypy]` nor `[tool.pytest-mypy-plugins.mypy-config]` existing config
Lines changed: 10 additions & 0 deletions
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 = false
5+
show_column_numbers = true
6+
warn_unused_ignores = false
7+
8+
[tool.other]
9+
# This section should not be copied:
10+
key = 'value'

pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919
show_traceback = true
2020
"""
2121

22+
_MYPY_PLUGINS_CONFIG: Final = {
23+
"pretty": False,
24+
"show_column_numbers": True,
25+
"warn_unused_ignores": False,
26+
}
27+
2228
_PYPROJECT1: Final = str(Path(__file__).parent / "pyproject1.toml")
2329
_PYPROJECT2: Final = str(Path(__file__).parent / "pyproject2.toml")
2430

@@ -68,6 +74,67 @@ def test_join_existing_config(
6874
)
6975

7076

77+
def test_join_existing_config1(execution_path: Path, assert_file_contents: _AssertFileContents) -> None:
78+
filepath = join_toml_configs(_PYPROJECT1, "", execution_path, _MYPY_PLUGINS_CONFIG)
79+
80+
assert_file_contents(
81+
filepath,
82+
"""
83+
[tool.mypy]
84+
pretty = true
85+
show_column_numbers = true
86+
warn_unused_ignores = true
87+
show_error_codes = true
88+
""",
89+
)
90+
91+
92+
@pytest.mark.parametrize(
93+
"additional_config",
94+
[
95+
_ADDITIONAL_CONFIG,
96+
_ADDITIONAL_CONFIG_NO_TABLE,
97+
],
98+
)
99+
def test_join_existing_config2(execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str):
100+
filepath = join_toml_configs(_PYPROJECT1, additional_config, execution_path, _MYPY_PLUGINS_CONFIG)
101+
102+
assert_file_contents(
103+
filepath,
104+
"""
105+
[tool.mypy]
106+
pretty = true
107+
show_column_numbers = true
108+
warn_unused_ignores = true
109+
show_error_codes = false
110+
show_traceback = true
111+
""",
112+
)
113+
114+
115+
@pytest.mark.parametrize(
116+
"additional_config",
117+
[
118+
_ADDITIONAL_CONFIG,
119+
_ADDITIONAL_CONFIG_NO_TABLE,
120+
],
121+
)
122+
def test_join_existing_config2(execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str):
123+
filepath = join_toml_configs(_PYPROJECT1, additional_config, execution_path, _MYPY_PLUGINS_CONFIG)
124+
125+
assert_file_contents(
126+
filepath,
127+
"""
128+
[tool.mypy]
129+
pretty = true
130+
show_column_numbers = true
131+
warn_unused_ignores = true
132+
show_error_codes = false
133+
show_traceback = true
134+
""",
135+
)
136+
137+
71138
@pytest.mark.parametrize(
72139
"additional_config",
73140
[
@@ -112,3 +179,42 @@ def test_join_missing_config2(execution_path: Path, assert_file_contents: _Asser
112179
filepath,
113180
"[tool.mypy]",
114181
)
182+
183+
184+
def test_join_missing_config3(execution_path: Path, assert_file_contents: _AssertFileContents) -> None:
185+
filepath = join_toml_configs(_PYPROJECT2, "", execution_path, _MYPY_PLUGINS_CONFIG)
186+
187+
assert_file_contents(
188+
filepath,
189+
"""
190+
[tool.mypy]
191+
pretty = false
192+
show_column_numbers = true
193+
warn_unused_ignores = false
194+
""",
195+
)
196+
197+
198+
@pytest.mark.parametrize(
199+
"additional_config",
200+
[
201+
_ADDITIONAL_CONFIG,
202+
_ADDITIONAL_CONFIG_NO_TABLE,
203+
],
204+
)
205+
def test_join_missing_config4(
206+
execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str
207+
) -> None:
208+
filepath = join_toml_configs(_PYPROJECT2, additional_config, execution_path, _MYPY_PLUGINS_CONFIG)
209+
210+
assert_file_contents(
211+
filepath,
212+
"""
213+
[tool.mypy]
214+
pretty = true
215+
show_column_numbers = true
216+
warn_unused_ignores = false
217+
show_error_codes = false
218+
show_traceback = true
219+
""",
220+
)
Lines changed: 20 additions & 0 deletions
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 / "pyproject3.toml")
9+
result = load_mypy_plugins_config(root_pyproject1)
10+
assert result == {
11+
"pretty": False,
12+
"show_column_numbers": True,
13+
"warn_unused_ignores": False,
14+
}
15+
16+
17+
def test_load_missing_config() -> None:
18+
root_pyproject2: Final = str(Path(__file__).parent / "pyproject2.toml")
19+
result = load_mypy_plugins_config(root_pyproject2)
20+
assert result is None

0 commit comments

Comments
 (0)