From e7b7a9dab3559f02406401e105312c295e32ada4 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 | 16 +++ pytest_mypy_plugins/configs.py | 51 +++++--- pytest_mypy_plugins/item.py | 19 ++- .../tests/test_configs/pyproject2.toml | 2 +- .../tests/test_configs/pyproject3.toml | 10 ++ .../test_configs/test_join_toml_configs.py | 110 ++++++++++++++++++ .../test_load_mypy_plugins_config.py | 20 ++++ 8 files changed, 209 insertions(+), 21 deletions(-) create mode 100644 pytest_mypy_plugins/tests/test_configs/pyproject3.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..7e38f83 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,22 @@ 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 +``` + +The ultimate `mypy` configuration applied during a test is derived by merging the following sources (if they exist), in order: + +1. The `mypy-config` table in the root `pyproject.toml` of the project. +2. The configuration file provided via `--mypy-pyproject-toml-file` or `--mypy-ini-file`. +3. The `config_mypy` field of the test case. + ## 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..7fe2c2f 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 Any, Dict, Final, Optional 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( + base_ini_fpath: Optional[str], + additional_mypy_config: str, + execution_path: Path, + mypy_plugins_config: Optional[Dict[str, Any]] = None, +) -> 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 + base_pyproject_toml_fpath: str, + additional_mypy_config: str, + execution_path: Path, + mypy_plugins_config: Optional[Dict[str, Any]] = None, ) -> 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..65e88dd 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 + self.base_pyproject_toml_fpath, self.additional_mypy_config, execution_path, mypy_plugins_config ) - 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( + self.base_ini_fpath, self.additional_mypy_config, execution_path, mypy_plugins_config + ) return None def repr_failure( diff --git a/pytest_mypy_plugins/tests/test_configs/pyproject2.toml b/pytest_mypy_plugins/tests/test_configs/pyproject2.toml index 5733143..c51eeba 100644 --- a/pytest_mypy_plugins/tests/test_configs/pyproject2.toml +++ b/pytest_mypy_plugins/tests/test_configs/pyproject2.toml @@ -1 +1 @@ -# This file has no `[tool.mypy]` existing config +# This file has no `[tool.mypy]` nor `[tool.pytest-mypy-plugins.mypy-config]` existing config diff --git a/pytest_mypy_plugins/tests/test_configs/pyproject3.toml b/pytest_mypy_plugins/tests/test_configs/pyproject3.toml new file mode 100644 index 0000000..b603b4d --- /dev/null +++ b/pytest_mypy_plugins/tests/test_configs/pyproject3.toml @@ -0,0 +1,10 @@ +# This file has `[tool.pytest-mypy-plugins.mypy-config]` existing config + +[tool.pytest-mypy-plugins.mypy-config] +pretty = false +show_column_numbers = true +warn_unused_ignores = false + +[tool.other] +# This section should not be copied: +key = 'value' 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..32e6923 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 @@ -19,6 +19,12 @@ show_traceback = true """ +_MYPY_PLUGINS_CONFIG: Final = { + "pretty": False, + "show_column_numbers": True, + "warn_unused_ignores": False, +} + _PYPROJECT1: Final = str(Path(__file__).parent / "pyproject1.toml") _PYPROJECT2: Final = str(Path(__file__).parent / "pyproject2.toml") @@ -68,6 +74,71 @@ def test_join_existing_config( ) +def test_join_existing_config1(execution_path: Path, assert_file_contents: _AssertFileContents) -> None: + filepath = join_toml_configs(_PYPROJECT1, "", execution_path, _MYPY_PLUGINS_CONFIG) + + assert_file_contents( + filepath, + """ + [tool.mypy] + pretty = true + show_column_numbers = true + warn_unused_ignores = true + show_error_codes = true + """, + ) + + +@pytest.mark.parametrize( + "additional_config", + [ + _ADDITIONAL_CONFIG, + _ADDITIONAL_CONFIG_NO_TABLE, + ], +) +def test_join_existing_config2( + execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str +) -> None: + filepath = join_toml_configs(_PYPROJECT1, additional_config, execution_path, _MYPY_PLUGINS_CONFIG) + + assert_file_contents( + filepath, + """ + [tool.mypy] + pretty = true + show_column_numbers = true + warn_unused_ignores = true + show_error_codes = false + show_traceback = true + """, + ) + + +@pytest.mark.parametrize( + "additional_config", + [ + _ADDITIONAL_CONFIG, + _ADDITIONAL_CONFIG_NO_TABLE, + ], +) +def test_join_existing_config3( + execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str +) -> None: + filepath = join_toml_configs(_PYPROJECT1, additional_config, execution_path, _MYPY_PLUGINS_CONFIG) + + assert_file_contents( + filepath, + """ + [tool.mypy] + pretty = true + show_column_numbers = true + warn_unused_ignores = true + show_error_codes = false + show_traceback = true + """, + ) + + @pytest.mark.parametrize( "additional_config", [ @@ -112,3 +183,42 @@ def test_join_missing_config2(execution_path: Path, assert_file_contents: _Asser filepath, "[tool.mypy]", ) + + +def test_join_missing_config3(execution_path: Path, assert_file_contents: _AssertFileContents) -> None: + filepath = join_toml_configs(_PYPROJECT2, "", execution_path, _MYPY_PLUGINS_CONFIG) + + assert_file_contents( + filepath, + """ + [tool.mypy] + pretty = false + show_column_numbers = true + warn_unused_ignores = false + """, + ) + + +@pytest.mark.parametrize( + "additional_config", + [ + _ADDITIONAL_CONFIG, + _ADDITIONAL_CONFIG_NO_TABLE, + ], +) +def test_join_missing_config4( + execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str +) -> None: + filepath = join_toml_configs(_PYPROJECT2, additional_config, execution_path, _MYPY_PLUGINS_CONFIG) + + assert_file_contents( + filepath, + """ + [tool.mypy] + pretty = true + show_column_numbers = true + warn_unused_ignores = false + show_error_codes = false + show_traceback = true + """, + ) 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..b6ac906 --- /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 / "pyproject3.toml") + result = load_mypy_plugins_config(root_pyproject1) + assert result == { + "pretty": False, + "show_column_numbers": True, + "warn_unused_ignores": False, + } + + +def test_load_missing_config() -> None: + root_pyproject2: Final = str(Path(__file__).parent / "pyproject2.toml") + result = load_mypy_plugins_config(root_pyproject2) + assert result is None