Repository: typeddjango/django-stubs

Issue:
Configure django-stubs with pyproject.toml
Hello,

The django-stubs README includes an example on how to configure it using the `mypy.ini` or `setup.cfg` files:

```
[mypy]
plugins =
    mypy_django_plugin.main

[mypy.plugins.django-stubs]
django_settings_module = "myproject.settings"
```

But since mypy recently added support for pyproject.toml files I think that it would be nice to include a pyproject.toml example too. 


Discussion:
Edit: I wrongly assumed that the plugins configurations were handled by mypy itself and not implemented in the plugins themselves. But I just noticed that the [extract_django_settings_module](https://github.com/typeddjango/django-stubs/blob/47f1700a8b1f5d0ec279ebaf27fd59c84254d1ff/mypy_django_plugin/main.py#L55) is parsing the pyproject.toml file as a config file and not as TOML file.

I believe that django-stubs itself has to be updated in order to handle both .cfg/.ini files or .toml files. Is that correct?


I think so, yes. 

PRs are welcome (in case it won't make our config-code way too complex).
a note, according to the the pyproject.toml spec django-stubs should look in

```toml
[tool.django-stubs]
settings_module = "some.settings.module"
```

and not for example:

```toml
[tool.mypy.plugins.django-stubs]
settings_module = "some.settings.module"
```

Resolving patch:
diff --git a/mypy_django_plugin/lib/helpers.py b/mypy_django_plugin/lib/helpers.py
--- 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
--- 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 exit(error_type: int) -> NoReturn:
         """
         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 exit(error_type: int) -> NoReturn:
         }
         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)
-
-    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("'\"")
+    def exit_toml(error_type: int) -> NoReturn:
+        from mypy.main import CapturableArgumentParser
+
+        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
--- a/setup.py
+++ b/setup.py
@@ -26,6 +26,7 @@ def find_stub_files(name: str) -> List[str]:
     "django",
     "django-stubs-ext",
     "types-pytz",
+    "toml",
 ]
 
 setup(


Test patch:
diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py
--- a/tests/test_error_handling.py
+++ b/tests/test_error_handling.py
@@ -5,7 +5,8 @@
 
 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 @@
 (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 = [