Skip to content

Commit e7b7a9d

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 e7b7a9d

File tree

8 files changed

+209
-21
lines changed

8 files changed

+209
-21
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

+16
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)

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+
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)

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+
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(
+1-1
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
+10
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'

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

+110
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,71 @@ 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(
100+
execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str
101+
) -> None:
102+
filepath = join_toml_configs(_PYPROJECT1, additional_config, execution_path, _MYPY_PLUGINS_CONFIG)
103+
104+
assert_file_contents(
105+
filepath,
106+
"""
107+
[tool.mypy]
108+
pretty = true
109+
show_column_numbers = true
110+
warn_unused_ignores = true
111+
show_error_codes = false
112+
show_traceback = true
113+
""",
114+
)
115+
116+
117+
@pytest.mark.parametrize(
118+
"additional_config",
119+
[
120+
_ADDITIONAL_CONFIG,
121+
_ADDITIONAL_CONFIG_NO_TABLE,
122+
],
123+
)
124+
def test_join_existing_config3(
125+
execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str
126+
) -> None:
127+
filepath = join_toml_configs(_PYPROJECT1, additional_config, execution_path, _MYPY_PLUGINS_CONFIG)
128+
129+
assert_file_contents(
130+
filepath,
131+
"""
132+
[tool.mypy]
133+
pretty = true
134+
show_column_numbers = true
135+
warn_unused_ignores = true
136+
show_error_codes = false
137+
show_traceback = true
138+
""",
139+
)
140+
141+
71142
@pytest.mark.parametrize(
72143
"additional_config",
73144
[
@@ -112,3 +183,42 @@ def test_join_missing_config2(execution_path: Path, assert_file_contents: _Asser
112183
filepath,
113184
"[tool.mypy]",
114185
)
186+
187+
188+
def test_join_missing_config3(execution_path: Path, assert_file_contents: _AssertFileContents) -> None:
189+
filepath = join_toml_configs(_PYPROJECT2, "", execution_path, _MYPY_PLUGINS_CONFIG)
190+
191+
assert_file_contents(
192+
filepath,
193+
"""
194+
[tool.mypy]
195+
pretty = false
196+
show_column_numbers = true
197+
warn_unused_ignores = false
198+
""",
199+
)
200+
201+
202+
@pytest.mark.parametrize(
203+
"additional_config",
204+
[
205+
_ADDITIONAL_CONFIG,
206+
_ADDITIONAL_CONFIG_NO_TABLE,
207+
],
208+
)
209+
def test_join_missing_config4(
210+
execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str
211+
) -> None:
212+
filepath = join_toml_configs(_PYPROJECT2, additional_config, execution_path, _MYPY_PLUGINS_CONFIG)
213+
214+
assert_file_contents(
215+
filepath,
216+
"""
217+
[tool.mypy]
218+
pretty = true
219+
show_column_numbers = true
220+
warn_unused_ignores = false
221+
show_error_codes = false
222+
show_traceback = true
223+
""",
224+
)
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)