Skip to content

Commit

Permalink
Add Mypy configuration through root "pyproject.toml" file
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Delgan committed Nov 4, 2023
1 parent c80f1eb commit ea61ad5
Show file tree
Hide file tree
Showing 9 changed files with 173 additions and 25 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jobs:
run: |
pip install -U pip setuptools wheel
pip install -e .
# Workaround until Mypy regression is fixed.
pip install mypy==1.5.1
# Force correct `pytest` version for different envs:
pip install -U "pytest${{ matrix.pytest-version }}"
- name: Run tests
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,16 @@ mypy-tests:
```

## Configuration

For convenience, it is also possible to define a default `mypy` configuration in the root `pyproject.toml` file of your project:

```toml
[tool.pytest-mypy-plugins.mypy-config]
force_uppercase_builtins = true
force_union_syntax = true
```

## Further reading

- [Testing mypy stubs, plugins, and types](https://sobolevn.me/2019/08/testing-mypy-types)
Expand Down
51 changes: 34 additions & 17 deletions pytest_mypy_plugins/configs.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
from configparser import ConfigParser
from pathlib import Path
from textwrap import dedent
from typing import Final, Optional
from typing import Final, Optional, Any, Dict

import tomlkit

_TOML_TABLE_NAME: Final = "[tool.mypy]"


def join_ini_configs(base_ini_fpath: Optional[str], additional_mypy_config: str, execution_path: Path) -> Optional[str]:
def load_mypy_plugins_config(config_pyproject_toml_path: str) -> Optional[Dict[str, Any]]:
with open(config_pyproject_toml_path) as f:
toml_config = tomlkit.parse(f.read())
return toml_config.get("tool", {}).get("pytest-mypy-plugins", {}).get("mypy-config")


def join_ini_configs(
mypy_plugins_config: Optional[Dict[str, Any]],
base_ini_fpath: Optional[str],
additional_mypy_config: str,
execution_path: Path,
) -> Optional[str]:
mypy_ini_config = ConfigParser()
if mypy_plugins_config:
mypy_ini_config.read_dict({"mypy": mypy_plugins_config})
if base_ini_fpath:
mypy_ini_config.read(base_ini_fpath)
if additional_mypy_config:
Expand All @@ -26,34 +39,38 @@ def join_ini_configs(base_ini_fpath: Optional[str], additional_mypy_config: str,


def join_toml_configs(
base_pyproject_toml_fpath: str, additional_mypy_config: str, execution_path: Path
mypy_plugins_config: Optional[Dict[str, Any]],
base_pyproject_toml_fpath: str,
additional_mypy_config: str,
execution_path: Path,
) -> Optional[str]:
# Empty document with `[tool.mypy]` empty table, useful for overrides further.
toml_document = tomlkit.document()
tool = tomlkit.table(is_super_table=True)
tool.append("mypy", tomlkit.table())
toml_document.append("tool", tool)

if mypy_plugins_config:
toml_document["tool"]["mypy"].update(mypy_plugins_config.items()) # type: ignore[index, union-attr]

if base_pyproject_toml_fpath:
with open(base_pyproject_toml_fpath) as f:
toml_config = tomlkit.parse(f.read())
else:
# Emtpy document with `[tool.mypy` empty table,
# useful for overrides further.
toml_config = tomlkit.document()

if "tool" not in toml_config or "mypy" not in toml_config["tool"]: # type: ignore[operator]
tool = tomlkit.table(is_super_table=True)
tool.append("mypy", tomlkit.table())
toml_config.append("tool", tool)
# We don't want the whole config file, because it can contain
# other sections like `[tool.isort]`, we only need `[tool.mypy]` part.
if "tool" in toml_config and "mypy" in toml_config["tool"]: # type: ignore[operator]
toml_document["tool"]["mypy"].update(toml_config["tool"]["mypy"].value.items()) # type: ignore[index, union-attr]

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

additional_data = tomlkit.parse(additional_mypy_config)
toml_config["tool"]["mypy"].update( # type: ignore[index, union-attr]
toml_document["tool"]["mypy"].update( # type: ignore[index, union-attr]
additional_data["tool"]["mypy"].value.items(), # type: ignore[index]
)

mypy_config_file_path = execution_path / "pyproject.toml"
with mypy_config_file_path.open("w") as f:
# We don't want the whole config file, because it can contain
# other sections like `[tool.isort]`, we only need `[tool.mypy]` part.
f.write(f"{_TOML_TABLE_NAME}\n")
f.write(dedent(toml_config["tool"]["mypy"].as_string())) # type: ignore[index]
f.write(toml_document.as_string())
return str(mypy_config_file_path)
19 changes: 16 additions & 3 deletions pytest_mypy_plugins/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ def __init__(
if self.config.option.mypy_ini_file and self.config.option.mypy_pyproject_toml_file:
raise ValueError("Cannot specify both `--mypy-ini-file` and `--mypy-pyproject-toml-file`")

# Optionally retrieve plugin configuration through the root `pyproject.toml` file.
if (self.config.rootpath / "pyproject.toml").exists():
self.config_pyproject_toml_fpath: Optional[str] = str(self.config.rootpath / "pyproject.toml")
else:
self.config_pyproject_toml_fpath = None

if self.config.option.mypy_ini_file:
self.base_ini_fpath = os.path.abspath(self.config.option.mypy_ini_file)
else:
Expand Down Expand Up @@ -318,18 +324,25 @@ def prepare_mypy_cmd_options(self, execution_path: Path) -> List[str]:
return mypy_cmd_options

def prepare_config_file(self, execution_path: Path) -> Optional[str]:
# We allow a default Mypy config in root `pyproject.toml` file. This is useful to define
# options that are specific to the tests without requiring an additional file.
if self.config_pyproject_toml_fpath:
mypy_plugins_config = configs.load_mypy_plugins_config(self.config_pyproject_toml_fpath)

# Merge (`self.base_ini_fpath` or `base_pyproject_toml_fpath`)
# and `self.additional_mypy_config`
# into one file and copy to the typechecking folder:
if self.base_pyproject_toml_fpath:
return configs.join_toml_configs(
self.base_pyproject_toml_fpath, self.additional_mypy_config, execution_path
mypy_plugins_config, self.base_pyproject_toml_fpath, self.additional_mypy_config, execution_path
)
elif self.base_ini_fpath or self.additional_mypy_config:
elif self.base_ini_fpath or self.additional_mypy_config or self.config_pyproject_toml_fpath:
# We might have `self.base_ini_fpath` set as well.
# Or this might be a legacy case: only `mypy_config:` is set in the `yaml` test case.
# This means that no real file is provided.
return configs.join_ini_configs(self.base_ini_fpath, self.additional_mypy_config, execution_path)
return configs.join_ini_configs(
mypy_plugins_config, self.base_ini_fpath, self.additional_mypy_config, execution_path
)
return None

def repr_failure(
Expand Down
1 change: 1 addition & 0 deletions pytest_mypy_plugins/tests/test_configs/pyproject1.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
warn_unused_ignores = true
pretty = true
show_error_codes = true
show_error_context = true

[tool.other]
# This section should not be copied:
Expand Down
10 changes: 10 additions & 0 deletions pytest_mypy_plugins/tests/test_configs/root_pyproject1.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# This file has `[tool.pytest-mypy-plugins.mypy-config]` existing config

[tool.pytest-mypy-plugins.mypy-config]
pretty = true
show_error_codes = true
warn_unused_ignores = true

[tool.other]
# This section should not be copied:
key = 'value'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# This file has no `[tool.pytest-mypy-plugins.mypy-config]` existing config
84 changes: 79 additions & 5 deletions pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from pathlib import Path
from textwrap import dedent
from typing import Callable, Final, Optional
from typing import Callable, Final, Optional, Dict, Any

import pytest

Expand All @@ -19,6 +19,13 @@
show_traceback = true
"""

_MYPY_PLUGINS_CONFIG1: Final = {
"pretty": False,
"show_column_numbers": True,
"show_error_context": False,
}
_MYPY_PLUGINS_CONFIG2: Final = None

_PYPROJECT1: Final = str(Path(__file__).parent / "pyproject1.toml")
_PYPROJECT2: Final = str(Path(__file__).parent / "pyproject2.toml")

Expand Down Expand Up @@ -54,7 +61,7 @@ def factory(filename: Optional[str], expected: str) -> None:
def test_join_existing_config(
execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str
) -> None:
filepath = join_toml_configs(_PYPROJECT1, additional_config, execution_path)
filepath = join_toml_configs(_MYPY_PLUGINS_CONFIG2, _PYPROJECT1, additional_config, execution_path)

assert_file_contents(
filepath,
Expand All @@ -63,6 +70,33 @@ def test_join_existing_config(
warn_unused_ignores = true
pretty = true
show_error_codes = false
show_error_context = true
show_traceback = true
""",
)


@pytest.mark.parametrize(
"additional_config",
[
_ADDITIONAL_CONFIG,
_ADDITIONAL_CONFIG_NO_TABLE,
],
)
def test_join_existing_config1(
execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str
) -> None:
filepath = join_toml_configs(_MYPY_PLUGINS_CONFIG1, _PYPROJECT1, additional_config, execution_path)

assert_file_contents(
filepath,
"""
[tool.mypy]
pretty = true
show_column_numbers = true
show_error_context = true
warn_unused_ignores = true
show_error_codes = false
show_traceback = true
""",
)
Expand All @@ -78,7 +112,7 @@ def test_join_existing_config(
def test_join_missing_config(
execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str
) -> None:
filepath = join_toml_configs(_PYPROJECT2, additional_config, execution_path)
filepath = join_toml_configs(_MYPY_PLUGINS_CONFIG2, _PYPROJECT2, additional_config, execution_path)

assert_file_contents(
filepath,
Expand All @@ -92,7 +126,7 @@ def test_join_missing_config(


def test_join_missing_config1(execution_path: Path, assert_file_contents: _AssertFileContents) -> None:
filepath = join_toml_configs(_PYPROJECT1, "", execution_path)
filepath = join_toml_configs(_MYPY_PLUGINS_CONFIG2, _PYPROJECT1, "", execution_path)

assert_file_contents(
filepath,
Expand All @@ -101,14 +135,54 @@ def test_join_missing_config1(execution_path: Path, assert_file_contents: _Asser
warn_unused_ignores = true
pretty = true
show_error_codes = true
show_error_context = true
""",
)


def test_join_missing_config2(execution_path: Path, assert_file_contents: _AssertFileContents) -> None:
filepath = join_toml_configs(_PYPROJECT2, "", execution_path)
filepath = join_toml_configs(_MYPY_PLUGINS_CONFIG2, _PYPROJECT2, "", execution_path)

assert_file_contents(
filepath,
"[tool.mypy]",
)


@pytest.mark.parametrize(
"additional_config",
[
_ADDITIONAL_CONFIG,
_ADDITIONAL_CONFIG_NO_TABLE,
],
)
def test_join_missing_config3(
execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str
) -> None:
filepath = join_toml_configs(_MYPY_PLUGINS_CONFIG1, _PYPROJECT2, additional_config, execution_path)

assert_file_contents(
filepath,
"""
[tool.mypy]
pretty = true
show_column_numbers = true
show_error_context = false
show_error_codes = false
show_traceback = true
""",
)


def test_join_missing_config4(execution_path: Path, assert_file_contents: _AssertFileContents) -> None:
filepath = join_toml_configs(_MYPY_PLUGINS_CONFIG1, _PYPROJECT2, "", execution_path)

assert_file_contents(
filepath,
"""
[tool.mypy]
pretty = false
show_column_numbers = true
show_error_context = false
""",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from pathlib import Path
from typing import Final

from pytest_mypy_plugins.configs import load_mypy_plugins_config


def test_load_existing_config() -> None:
root_pyproject1: Final = str(Path(__file__).parent / "root_pyproject1.toml")
result = load_mypy_plugins_config(root_pyproject1)
assert result == {
"pretty": True,
"show_error_codes": True,
"warn_unused_ignores": True,
}


def test_load_missing_config() -> None:
root_pyproject2: Final = str(Path(__file__).parent / "root_pyproject2.toml")
result = load_mypy_plugins_config(root_pyproject2)
assert result is None

0 comments on commit ea61ad5

Please sign in to comment.