From ea61ad5d30bd68aed9a256d0aaa64247de56e564 Mon Sep 17 00:00:00 2001 From: Delgan Date: Sat, 4 Nov 2023 14:16:50 +0100 Subject: [PATCH] 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. --- .github/workflows/test.yml | 2 + README.md | 10 +++ pytest_mypy_plugins/configs.py | 51 +++++++---- pytest_mypy_plugins/item.py | 19 ++++- .../tests/test_configs/pyproject1.toml | 1 + .../tests/test_configs/root_pyproject1.toml | 10 +++ .../tests/test_configs/root_pyproject2.toml | 1 + .../test_configs/test_join_toml_configs.py | 84 +++++++++++++++++-- .../test_load_mypy_plugins_config.py | 20 +++++ 9 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 pytest_mypy_plugins/tests/test_configs/root_pyproject1.toml create mode 100644 pytest_mypy_plugins/tests/test_configs/root_pyproject2.toml create mode 100644 pytest_mypy_plugins/tests/test_configs/test_load_mypy_plugins_config.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8a127d..69cc994 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/README.md b/README.md index 7ef2449..4c37fd6 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/pytest_mypy_plugins/configs.py b/pytest_mypy_plugins/configs.py index 7595500..fcdee6d 100644 --- a/pytest_mypy_plugins/configs.py +++ b/pytest_mypy_plugins/configs.py @@ -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: @@ -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) diff --git a/pytest_mypy_plugins/item.py b/pytest_mypy_plugins/item.py index 8c3de2d..f90f187 100644 --- a/pytest_mypy_plugins/item.py +++ b/pytest_mypy_plugins/item.py @@ -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: @@ -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( diff --git a/pytest_mypy_plugins/tests/test_configs/pyproject1.toml b/pytest_mypy_plugins/tests/test_configs/pyproject1.toml index 18da4f3..8bc7dc7 100644 --- a/pytest_mypy_plugins/tests/test_configs/pyproject1.toml +++ b/pytest_mypy_plugins/tests/test_configs/pyproject1.toml @@ -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: diff --git a/pytest_mypy_plugins/tests/test_configs/root_pyproject1.toml b/pytest_mypy_plugins/tests/test_configs/root_pyproject1.toml new file mode 100644 index 0000000..eedd1fa --- /dev/null +++ b/pytest_mypy_plugins/tests/test_configs/root_pyproject1.toml @@ -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' diff --git a/pytest_mypy_plugins/tests/test_configs/root_pyproject2.toml b/pytest_mypy_plugins/tests/test_configs/root_pyproject2.toml new file mode 100644 index 0000000..268c7ce --- /dev/null +++ b/pytest_mypy_plugins/tests/test_configs/root_pyproject2.toml @@ -0,0 +1 @@ +# This file has no `[tool.pytest-mypy-plugins.mypy-config]` existing config diff --git a/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py b/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py index d1f2ab4..689c0ee 100644 --- a/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py +++ b/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py @@ -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 @@ -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") @@ -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, @@ -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 """, ) @@ -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, @@ -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, @@ -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 + """, + ) diff --git a/pytest_mypy_plugins/tests/test_configs/test_load_mypy_plugins_config.py b/pytest_mypy_plugins/tests/test_configs/test_load_mypy_plugins_config.py new file mode 100644 index 0000000..ee6388f --- /dev/null +++ b/pytest_mypy_plugins/tests/test_configs/test_load_mypy_plugins_config.py @@ -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