Adds support for pyproject.toml files (#639)

* Adds support for pyproject.toml files

Since mypy 0.900 the pyproject.toml files are supported.

This PR adds a support for it. It searchs for a `tool.django-stubs` section. This is an example configuration:

```
[tool.django-stubs]
django_settings_module = "config.settings.local"
```

Fixes #638

* Added TOML tests

* Use textwrap.dedent instead of trying to manually replace spaces
This commit is contained in:
Cesar Canassa
2021-06-15 00:50:31 +02:00
committed by GitHub
parent 8c387e85fe
commit 397e3f3dac
6 changed files with 135 additions and 19 deletions

View File

@@ -30,7 +30,17 @@ django_settings_module = "myproject.settings"
in your `mypy.ini` or `setup.cfg` [file](https://mypy.readthedocs.io/en/latest/config_file.html). in your `mypy.ini` or `setup.cfg` [file](https://mypy.readthedocs.io/en/latest/config_file.html).
Two things happeining here: [pyproject.toml](https://mypy.readthedocs.io/en/stable/config_file.html#using-a-pyproject-toml-file) configurations are also supported:
```toml
[tool.mypy]
plugins = ["mypy_django_plugin.main"]
[tool.django-stubs]
django_settings_module = "myproject.settings"
```
Two things happening here:
1. We need to explicitly list our plugin to be loaded by `mypy` 1. We need to explicitly list our plugin to be loaded by `mypy`
2. Our plugin also requires `django` settings module (what you put into `DJANGO_SETTINGS_MODULE` variable) to be specified 2. Our plugin also requires `django` settings module (what you put into `DJANGO_SETTINGS_MODULE` variable) to be specified

View File

@@ -6,5 +6,6 @@ pre-commit==2.7.1
pytest==6.1.1 pytest==6.1.1
pytest-mypy-plugins==1.6.1 pytest-mypy-plugins==1.6.1
psycopg2-binary psycopg2-binary
types-toml==0.1.1
-e ./django_stubs_ext -e ./django_stubs_ext
-e . -e .

View File

@@ -54,6 +54,10 @@ class IncompleteDefnException(Exception):
pass pass
def is_toml(filename: str) -> bool:
return filename.lower().endswith(".toml")
def lookup_fully_qualified_sym(fullname: str, all_modules: Dict[str, MypyFile]) -> Optional[SymbolTableNode]: def lookup_fully_qualified_sym(fullname: str, all_modules: Dict[str, MypyFile]) -> Optional[SymbolTableNode]:
if "." not in fullname: if "." not in fullname:
return None return None

View File

@@ -1,8 +1,10 @@
import configparser import configparser
import sys import sys
import textwrap
from functools import partial from functools import partial
from typing import Callable, Dict, List, NoReturn, Optional, Tuple, cast from typing import Callable, Dict, List, NoReturn, Optional, Tuple, cast
import toml
from django.db.models.fields.related import RelatedField from django.db.models.fields.related import RelatedField
from mypy.modulefinder import mypy_path from mypy.modulefinder import mypy_path
from mypy.nodes import MypyFile, TypeInfo from mypy.nodes import MypyFile, TypeInfo
@@ -63,15 +65,14 @@ def extract_django_settings_module(config_file_path: Optional[str]) -> str:
""" """
from mypy.main import CapturableArgumentParser from mypy.main import CapturableArgumentParser
usage = """(config) usage = """
(config)
... ...
[mypy.plugins.django_stubs] [mypy.plugins.django_stubs]
django_settings_module: str (required) django_settings_module: str (required)
... ...
""".replace( """
"\n" + 8 * " ", "\n" handler = CapturableArgumentParser(prog="(django-stubs) mypy", usage=textwrap.dedent(usage))
)
handler = CapturableArgumentParser(prog="(django-stubs) mypy", usage=usage)
messages = { messages = {
1: "mypy config file is not specified or found", 1: "mypy config file is not specified or found",
2: "no section [mypy.plugins.django-stubs]", 2: "no section [mypy.plugins.django-stubs]",
@@ -79,18 +80,53 @@ def extract_django_settings_module(config_file_path: Optional[str]) -> str:
} }
handler.error("'django_settings_module' is not set: " + messages[error_type]) handler.error("'django_settings_module' is not set: " + messages[error_type])
parser = configparser.ConfigParser() def exit_toml(error_type: int) -> NoReturn:
try: from mypy.main import CapturableArgumentParser
with open(cast(str, config_file_path)) as handle:
parser.read_file(handle, source=config_file_path)
except (IsADirectoryError, OSError):
exit(1)
section = "mypy.plugins.django-stubs" usage = """
if not parser.has_section(section): (config)
exit(2) ...
settings = parser.get(section, "django_settings_module", fallback=None) or exit(3) [tool.django-stubs]
return cast(str, settings).strip("'\"") django_settings_module = str (required)
...
"""
handler = CapturableArgumentParser(prog="(django-stubs) mypy", usage=textwrap.dedent(usage))
messages = {
1: "mypy config file is not specified or found",
2: "no section [tool.django-stubs]",
3: "the setting is not provided",
4: "the setting must be a string",
}
handler.error("'django_settings_module' not found or invalid: " + messages[error_type])
if config_file_path and helpers.is_toml(config_file_path):
toml_data = toml.load(config_file_path)
try:
config = toml_data["tool"]["django-stubs"]
except KeyError:
exit_toml(2)
if "django_settings_module" not in config:
exit_toml(3)
if not isinstance(config["django_settings_module"], str):
exit_toml(4)
return config["django_settings_module"]
else:
parser = configparser.ConfigParser()
try:
with open(cast(str, config_file_path)) as handle:
parser.read_file(handle, source=config_file_path)
except (IsADirectoryError, OSError):
exit(1)
section = "mypy.plugins.django-stubs"
if not parser.has_section(section):
exit(2)
settings = parser.get(section, "django_settings_module", fallback=None) or exit(3)
return cast(str, settings).strip("'\"")
class NewSemanalDjangoPlugin(Plugin): class NewSemanalDjangoPlugin(Plugin):

View File

@@ -26,6 +26,7 @@ dependencies = [
"django", "django",
"django-stubs-ext", "django-stubs-ext",
"types-pytz", "types-pytz",
"toml",
] ]
setup( setup(

View File

@@ -5,7 +5,8 @@ import pytest
from mypy_django_plugin.main import extract_django_settings_module from mypy_django_plugin.main import extract_django_settings_module
TEMPLATE = """usage: (config) TEMPLATE = """
(config)
... ...
[mypy.plugins.django_stubs] [mypy.plugins.django_stubs]
django_settings_module: str (required) django_settings_module: str (required)
@@ -13,6 +14,15 @@ TEMPLATE = """usage: (config)
(django-stubs) mypy: error: 'django_settings_module' is not set: {} (django-stubs) mypy: error: 'django_settings_module' is not set: {}
""" """
TEMPLATE_TOML = """
(config)
...
[tool.django-stubs]
django_settings_module = str (required)
...
(django-stubs) mypy: error: 'django_settings_module' not found or invalid: {}
"""
@pytest.mark.parametrize( @pytest.mark.parametrize(
"config_file_contents,message_part", "config_file_contents,message_part",
@@ -52,10 +62,64 @@ def test_misconfiguration_handling(capsys, config_file_contents, message_part):
with pytest.raises(SystemExit, match="2"): with pytest.raises(SystemExit, match="2"):
extract_django_settings_module(config_file.name) extract_django_settings_module(config_file.name)
error_message = TEMPLATE.format(message_part) error_message = "usage: " + TEMPLATE.format(message_part)
assert error_message == capsys.readouterr().err assert error_message == capsys.readouterr().err
@pytest.mark.parametrize(
"config_file_contents,message_part",
[
(
"""
[tool.django-stubs]
django_settings_module = 123
""",
"the setting must be a string",
),
(
"""
[tool.not-really-django-stubs]
django_settings_module = "my.module"
""",
"no section [tool.django-stubs]",
),
(
"""
[tool.django-stubs]
not_django_not_settings_module = "badbadmodule"
""",
"the setting is not provided",
),
],
)
def test_toml_misconfiguration_handling(capsys, config_file_contents, message_part):
with tempfile.NamedTemporaryFile(mode="w+", suffix=".toml") as config_file:
config_file.write(config_file_contents)
config_file.seek(0)
with pytest.raises(SystemExit, match="2"):
extract_django_settings_module(config_file.name)
error_message = "usage: " + TEMPLATE_TOML.format(message_part)
assert error_message == capsys.readouterr().err
def test_correct_toml_configuration() -> None:
config_file_contents = """
[tool.django-stubs]
some_other_setting = "setting"
django_settings_module = "my.module"
"""
with tempfile.NamedTemporaryFile(mode="w+", suffix=".toml") as config_file:
config_file.write(config_file_contents)
config_file.seek(0)
extracted = extract_django_settings_module(config_file.name)
assert extracted == "my.module"
def test_correct_configuration() -> None: def test_correct_configuration() -> None:
"""Django settings module gets extracted given valid configuration.""" """Django settings module gets extracted given valid configuration."""
config_file_contents = [ config_file_contents = [