diff --git a/README.md b/README.md index bbcca9f..1591e62 100644 --- a/README.md +++ b/README.md @@ -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). -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` 2. Our plugin also requires `django` settings module (what you put into `DJANGO_SETTINGS_MODULE` variable) to be specified diff --git a/dev-requirements.txt b/dev-requirements.txt index d5dac17..2824eec 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -6,5 +6,6 @@ pre-commit==2.7.1 pytest==6.1.1 pytest-mypy-plugins==1.6.1 psycopg2-binary +types-toml==0.1.1 -e ./django_stubs_ext -e . diff --git a/mypy_django_plugin/lib/helpers.py b/mypy_django_plugin/lib/helpers.py index e13b0ec..3485ccd 100644 --- a/mypy_django_plugin/lib/helpers.py +++ b/mypy_django_plugin/lib/helpers.py @@ -54,6 +54,10 @@ class IncompleteDefnException(Exception): 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]: if "." not in fullname: return None diff --git a/mypy_django_plugin/main.py b/mypy_django_plugin/main.py index 2b2365f..c20f653 100644 --- a/mypy_django_plugin/main.py +++ b/mypy_django_plugin/main.py @@ -1,8 +1,10 @@ import configparser import sys +import textwrap from functools import partial from typing import Callable, Dict, List, NoReturn, Optional, Tuple, cast +import toml from django.db.models.fields.related import RelatedField from mypy.modulefinder import mypy_path 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 - usage = """(config) + usage = """ + (config) ... [mypy.plugins.django_stubs] django_settings_module: str (required) ... - """.replace( - "\n" + 8 * " ", "\n" - ) - handler = CapturableArgumentParser(prog="(django-stubs) mypy", usage=usage) + """ + handler = CapturableArgumentParser(prog="(django-stubs) mypy", usage=textwrap.dedent(usage)) messages = { 1: "mypy config file is not specified or found", 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]) - 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) + def exit_toml(error_type: int) -> NoReturn: + from mypy.main import CapturableArgumentParser - 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("'\"") + usage = """ + (config) + ... + [tool.django-stubs] + 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): diff --git a/setup.py b/setup.py index 6381f70..9cd9ef6 100644 --- a/setup.py +++ b/setup.py @@ -26,6 +26,7 @@ dependencies = [ "django", "django-stubs-ext", "types-pytz", + "toml", ] setup( diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index 1d02ce9..bb20081 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -5,7 +5,8 @@ import pytest from mypy_django_plugin.main import extract_django_settings_module -TEMPLATE = """usage: (config) +TEMPLATE = """ +(config) ... [mypy.plugins.django_stubs] django_settings_module: str (required) @@ -13,6 +14,15 @@ TEMPLATE = """usage: (config) (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( "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"): 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 +@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: """Django settings module gets extracted given valid configuration.""" config_file_contents = [