Skip to content

Add Mypy configuration through root "pyproject.toml" file #135

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unrelated to the other changes I made.
Current master branch is failing with latest version of Mypy for a reason I'm not sure to understand.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please provide a traceback?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sobolevn Sure:

$ pytest pytest_mypy_plugins/tests/test-mypy-config.yml -vvv
============================================================================== test session starts ==============================================================================
platform linux -- Python 3.11.5, pytest-7.4.3, pluggy-1.3.0 -- /home/delgan/programming/pytest-mypy-plugins/env/bin/python
cachedir: .pytest_cache
rootdir: /home/delgan/programming/pytest-mypy-plugins
configfile: pyproject.toml
plugins: mypy-plugins-3.0.0
collected 1 item                                                                                                                                                                

pytest_mypy_plugins/tests/test-mypy-config.yml::custom_mypy_config_strict_optional_true_set FAILED

=================================================================================== FAILURES ====================================================================================
__________________________________________________________________ custom_mypy_config_strict_optional_true_set __________________________________________________________________
/home/delgan/programming/pytest-mypy-plugins/pytest_mypy_plugins/tests/test-mypy-config.yml:4: 
E   pytest_mypy_plugins.utils.TypecheckAssertionError: Output is not expected: 
E   Actual:
E     ../../home/delgan/programming/pytest-mypy-plugins/env/lib/python3.11/site-packages/mypy/typeshed/stdlib/builtins.pyi:154: error: Overloaded function signature 2 will never be matched: signature 1's parameter type(s) are the same or broader  [misc] (diff)
E   Expected:
E     (empty)
============================================================================ short test summary info ============================================================================
FAILED pytest_mypy_plugins/tests/test-mypy-config.yml::custom_mypy_config_strict_optional_true_set - 
=============================================================================== 1 failed in 3.14s ===============================================================================

This can be reproduced by executing mypy --no-silence-site-packages --no-strict-optional on the following file:

from typing import Optional
a: Optional[int] = None
a + 1  # should not raise an error

Mypy v1.6.0+ flags this snippet as invalid and exits with non-zero:

env/lib/python3.11/site-packages/mypy/typeshed/stdlib/builtins.pyi:154: error: Overloaded function signature 2 will never be matched: signature 1's parameter type(s) are the same or broader  [misc]
Found 1 error in 1 file (checked 1 source file)

This doesn't happen when --no-silence-site-packages is omitted.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--no-strict-optional is not tested much in mypy (and for a good reason - it should not be used).

How does it affect us here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is used to verify that inline mypy_config works as expected: https://github.com/typeddjango/pytest-mypy-plugins/blob/master/pytest_mypy_plugins/tests/test-mypy-config.yml

We can probably test it with another option, though.

Copy link
Contributor

@pHlt7 pHlt7 Jan 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I stumbled upon this pr because tests failed with mypy-1.8.0 (https://bugs.gentoo.org/921901). (Where) has the regression been reported upstream?

# Force correct `pytest` version for different envs:
pip install -U "pytest${{ matrix.pytest-version }}"
- name: Run tests
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, also describe what priority this setting has over CLI flags / etc.


```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)
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 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:
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
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)
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
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(
Expand Down
2 changes: 1 addition & 1 deletion pytest_mypy_plugins/tests/test_configs/pyproject2.toml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions pytest_mypy_plugins/tests/test_configs/pyproject3.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 = false
show_column_numbers = true
warn_unused_ignores = false

[tool.other]
# This section should not be copied:
key = 'value'
85 changes: 85 additions & 0 deletions pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -68,6 +74,46 @@ 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",
[
Expand Down Expand Up @@ -112,3 +158,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
""",
)
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 / "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