Repository: samuelcolvin/pydantic (python)

Issue:
Mypy plugin nags about "Missing named argument __config__" on BaseSettings.construct()
### Checks

* [X] I added a descriptive title to this issue
* [X] I have searched (google, github) for similar issues and couldn't find anything
* [X] I have read and followed [the docs](https://pydantic-docs.helpmanual.io/) and still think this is a bug

# Bug

Output of `python -c "import pydantic.utils; print(pydantic.utils.version_info())"`:
```
             pydantic version: 1.8.1
            pydantic compiled: True
                 install path: /home/musttu/.pyenv/versions/3.9.4/envs/py39/lib/python3.9/site-packages/pydantic
               python version: 3.9.4 (default, May  3 2021, 13:52:18)  [GCC 9.3.0]
                     platform: Linux-5.8.0-50-generic-x86_64-with-glibc2.31
     optional deps. installed: ['typing-extensions']
```

With `mypy.ini`:

```ini
[mypy]
plugins = pydantic.mypy
```

```py
import pydantic

class Foo(pydantic.BaseSettings):
    pass

Foo.construct()
```

```
$ mypy models.py
models.py:6: error: Missing named argument "__config__" for "construct" of "Foo"
Found 1 error in 1 file (checked 1 source file)
```

Only occurs if pydntic's mypy plugin is enabled. Why is that?

Pull request description:
Fix bug when mypy plugin fail on construct method call
<!-- Thank you for your contribution! -->
<!-- Unless your change is trivial, please create an issue to discuss the change before creating a PR -->
<!-- See https://pydantic-docs.helpmanual.io/contributing/ for help on Contributing -->

## Fix `mypy` plugin bug
Fix bug when `mypy` plugin fails on `construct` method call for `BaseSettings` derived classes.

<!-- Please give a short summary of the changes. -->

## Related issue number
Closes #2753

<!-- Are there any issues opened that will be resolved by merging this change? -->

## Checklist

* [x] Unit tests for the changes exist
* [x] Tests pass on CI and coverage remains at 100%
* [x] Documentation reflects the changes where applicable
* [x] `changes/<pull request or issue id>-<github username>.md` file added describing change
  (see [changes/README.md](https://github.com/samuelcolvin/pydantic/blob/master/changes/README.md) for details)
* [x] My PR is ready to review, **please add a comment including the phrase "please review" to assign reviewers**


Patch:
diff --git a/changes/2753-uriyyo.md b/changes/2753-uriyyo.md
new file mode 100644
index 000000000..d1137db91
--- /dev/null
+++ b/changes/2753-uriyyo.md
@@ -0,0 +1,1 @@
+Fix bug when `mypy` plugin fails on `construct` method call for `BaseSettings` derived classes.
\ No newline at end of file
diff --git a/changes/2908-jrwalk.md b/changes/2908-jrwalk.md
new file mode 100644
index 000000000..8a5096501
--- /dev/null
+++ b/changes/2908-jrwalk.md
@@ -0,0 +1,1 @@
+Make `pydantic-mypy` plugin compatible with `pyproject.toml` configuration, consistent with `mypy` changes. See the [doc](https://pydantic-docs.helpmanual.io/mypy_plugin/#configuring-the-plugin) for more information.
diff --git a/docs/mypy_plugin.md b/docs/mypy_plugin.md
index b444d2d55..842078056 100644
--- a/docs/mypy_plugin.md
+++ b/docs/mypy_plugin.md
@@ -138,3 +138,28 @@ init_typed = True
 warn_required_dynamic_aliases = True
 warn_untyped_fields = True
 ```
+
+As of `mypy>=0.900`, mypy config may also be included in the `pyproject.toml` file rather than `mypy.ini`.
+The same configuration as above would be:
+```toml
+[tool.mypy]
+plugins = [
+  "pydantic.mypy"
+]
+
+follow_imports = "silent"
+warn_redundant_casts = true
+warn_unused_ignores = true
+disallow_any_generics = true
+check_untyped_defs = true
+no_implicit_reexport = true
+
+# for strict mypy: (this is the tricky one :-))
+disallow_untyped_defs = true
+
+[tool.pydantic-mypy]
+init_forbid_extra = true
+init_typed = true
+warn_required_dynamic_aliases = true
+warn_untyped_fields = true
+```
diff --git a/pydantic/env_settings.py b/pydantic/env_settings.py
index e6d9ccca1..96d168fef 100644
--- a/pydantic/env_settings.py
+++ b/pydantic/env_settings.py
@@ -1,7 +1,7 @@
 import os
 import warnings
 from pathlib import Path
-from typing import AbstractSet, Any, Callable, Dict, List, Mapping, Optional, Tuple, Union
+from typing import AbstractSet, Any, Callable, ClassVar, Dict, List, Mapping, Optional, Tuple, Type, Union
 
 from .config import BaseConfig, Extra
 from .fields import ModelField
@@ -114,7 +114,7 @@ class BaseSettings(BaseModel):
         ) -> Tuple[SettingsSourceCallable, ...]:
             return init_settings, env_settings, file_secret_settings
 
-    __config__: Config  # type: ignore
+    __config__: ClassVar[Type[Config]]  # type: ignore
 
 
 class InitSettingsSource:
@@ -170,7 +170,7 @@ class EnvSettingsSource:
 
             if field.is_complex():
                 try:
-                    env_val = settings.__config__.json_loads(env_val)  # type: ignore
+                    env_val = settings.__config__.json_loads(env_val)
                 except ValueError as e:
                     raise SettingsError(f'error parsing JSON for "{env_name}"') from e
             elif (
@@ -179,7 +179,7 @@ class EnvSettingsSource:
                 and any(f.is_complex() for f in field.sub_fields)
             ):
                 try:
-                    env_val = settings.__config__.json_loads(env_val)  # type: ignore
+                    env_val = settings.__config__.json_loads(env_val)
                 except ValueError:
                     pass
             d[field.alias] = env_val
diff --git a/pydantic/mypy.py b/pydantic/mypy.py
index 8107b54b4..1dfd49e60 100644
--- a/pydantic/mypy.py
+++ b/pydantic/mypy.py
@@ -1,6 +1,17 @@
 from configparser import ConfigParser
 from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type as TypingType, Union
 
+try:
+    import toml
+except ImportError:  # pragma: no cover
+    # future-proofing for upcoming `mypy` releases which will switch dependencies
+    try:
+        import tomli as toml  # type: ignore
+    except ImportError:
+        import warnings
+
+        warnings.warn('No TOML parser installed, cannot read configuration from `pyproject.toml`.')
+        toml = None  # type: ignore
 from mypy.errorcodes import ErrorCode
 from mypy.nodes import (
     ARG_NAMED,
@@ -110,11 +121,21 @@ class PydanticPluginConfig:
     def __init__(self, options: Options) -> None:
         if options.config_file is None:  # pragma: no cover
             return
-        plugin_config = ConfigParser()
-        plugin_config.read(options.config_file)
-        for key in self.__slots__:
-            setting = plugin_config.getboolean(CONFIGFILE_KEY, key, fallback=False)
-            setattr(self, key, setting)
+
+        if toml and options.config_file.endswith('.toml'):
+            with open(options.config_file, 'r') as rf:
+                config = toml.load(rf).get('tool', {}).get('pydantic-mypy', {})
+            for key in self.__slots__:
+                setting = config.get(key, False)
+                if not isinstance(setting, bool):
+                    raise ValueError(f'Configuration value must be a boolean for key: {key}')
+                setattr(self, key, setting)
+        else:
+            plugin_config = ConfigParser()
+            plugin_config.read(options.config_file)
+            for key in self.__slots__:
+                setting = plugin_config.getboolean(CONFIGFILE_KEY, key, fallback=False)
+                setattr(self, key, setting)
 
 
 def from_orm_callback(ctx: MethodContext) -> Type:


Test patch:
diff --git a/tests/mypy/configs/pyproject-default.toml b/tests/mypy/configs/pyproject-default.toml
new file mode 100644
index 000000000..63efc7a93
--- /dev/null
+++ b/tests/mypy/configs/pyproject-default.toml
@@ -0,0 +1,27 @@
+[build-system]
+requires = ["poetry>=0.12"]
+build_backend = "poetry.masonry.api"
+
+[tool.poetry]
+name = "test"
+version = "0.0.1"
+readme = "README.md"
+authors = [
+    "author@example.com"
+]
+
+[tool.poetry.dependencies]
+python = "*"
+
+[tool.pytest.ini_options]
+addopts = "-v -p no:warnings"
+
+[tool.mypy]
+follow_imports = "silent"
+strict_optional = true
+warn_redundant_casts = true
+warn_unused_ignores = true
+disallow_any_generics = true
+check_untyped_defs = true
+no_implicit_reexport = true
+disallow_untyped_defs = true
diff --git a/tests/mypy/configs/pyproject-plugin-bad-param.toml b/tests/mypy/configs/pyproject-plugin-bad-param.toml
new file mode 100644
index 000000000..9e58a4b69
--- /dev/null
+++ b/tests/mypy/configs/pyproject-plugin-bad-param.toml
@@ -0,0 +1,33 @@
+[build-system]
+requires = ["poetry>=0.12"]
+build_backend = "poetry.masonry.api"
+
+[tool.poetry]
+name = "test"
+version = "0.0.1"
+readme = "README.md"
+authors = [
+    "author@example.com"
+]
+
+[tool.poetry.dependencies]
+python = "*"
+
+[tool.pytest.ini_options]
+addopts = "-v -p no:warnings"
+
+[tool.mypy]
+plugins = [
+    "pydantic.mypy"
+]
+follow_imports = "silent"
+strict_optional = true
+warn_redundant_casts = true
+warn_unused_ignores = true
+disallow_any_generics = true
+check_untyped_defs = true
+no_implicit_reexport = true
+disallow_untyped_defs = true
+
+[tool.pydantic-mypy]
+init_forbid_extra = "foo"  # this will raise a ValueError for the config
diff --git a/tests/mypy/configs/pyproject-plugin-strict.toml b/tests/mypy/configs/pyproject-plugin-strict.toml
new file mode 100644
index 000000000..99f5df074
--- /dev/null
+++ b/tests/mypy/configs/pyproject-plugin-strict.toml
@@ -0,0 +1,36 @@
+[build-system]
+requires = ["poetry>=0.12"]
+build_backend = "poetry.masonry.api"
+
+[tool.poetry]
+name = "test"
+version = "0.0.1"
+readme = "README.md"
+authors = [
+    "author@example.com"
+]
+
+[tool.poetry.dependencies]
+python = "*"
+
+[tool.pytest.ini_options]
+addopts = "-v -p no:warnings"
+
+[tool.mypy]
+plugins = [
+    "pydantic.mypy"
+]
+follow_imports = "silent"
+strict_optional = true
+warn_redundant_casts = true
+warn_unused_ignores = true
+disallow_any_generics = true
+check_untyped_defs = true
+no_implicit_reexport = true
+disallow_untyped_defs = true
+
+[tool.pydantic-mypy]
+init_forbid_extra = true
+init_typed = true
+warn_required_dynamic_aliases = true
+warn_untyped_fields = true
diff --git a/tests/mypy/configs/pyproject-plugin.toml b/tests/mypy/configs/pyproject-plugin.toml
new file mode 100644
index 000000000..e8e13525c
--- /dev/null
+++ b/tests/mypy/configs/pyproject-plugin.toml
@@ -0,0 +1,30 @@
+[build-system]
+requires = ["poetry>=0.12"]
+build_backend = "poetry.masonry.api"
+
+[tool.poetry]
+name = "test"
+version = "0.0.1"
+readme = "README.md"
+authors = [
+    "author@example.com"
+]
+
+[tool.poetry.dependencies]
+python = "*"
+
+[tool.pytest.ini_options]
+addopts = "-v -p no:warnings"
+
+[tool.mypy]
+plugins = [
+    "pydantic.mypy"
+]
+follow_imports = "silent"
+strict_optional = true
+warn_redundant_casts = true
+warn_unused_ignores = true
+disallow_any_generics = true
+check_untyped_defs = true
+no_implicit_reexport = true
+disallow_untyped_defs = true
diff --git a/tests/mypy/modules/plugin_success.py b/tests/mypy/modules/plugin_success.py
index 1f334eef8..6fb6d6336 100644
--- a/tests/mypy/modules/plugin_success.py
+++ b/tests/mypy/modules/plugin_success.py
@@ -1,6 +1,6 @@
 from typing import ClassVar, Optional, Union
 
-from pydantic import BaseModel, Field, create_model
+from pydantic import BaseModel, BaseSettings, Field, create_model
 from pydantic.dataclasses import dataclass
 
 
@@ -162,3 +162,10 @@ NotFrozenModel.from_orm(model)
 
 class ModelWithSelfField(BaseModel):
     self: str
+
+
+class SettingsModel(BaseSettings):
+    pass
+
+
+settings = SettingsModel.construct()
diff --git a/tests/mypy/test_mypy.py b/tests/mypy/test_mypy.py
index a81cfe0f8..ea472ba98 100644
--- a/tests/mypy/test_mypy.py
+++ b/tests/mypy/test_mypy.py
@@ -8,12 +8,12 @@ import pytest
 try:
     from mypy import api as mypy_api
 except ImportError:
-    mypy_api = None
+    mypy_api = None  # type: ignore
 
 try:
     import dotenv
 except ImportError:
-    dotenv = None
+    dotenv = None  # type: ignore
 
 # This ensures mypy can find the test files, no matter where tests are run from:
 os.chdir(Path(__file__).parent.parent.parent)
@@ -29,20 +29,29 @@ cases = [
     ('mypy-default.ini', 'fail3.py', 'fail3.txt'),
     ('mypy-default.ini', 'fail4.py', 'fail4.txt'),
     ('mypy-default.ini', 'plugin_success.py', 'plugin_success.txt'),
+    ('pyproject-default.toml', 'success.py', None),
+    ('pyproject-default.toml', 'fail1.py', 'fail1.txt'),
+    ('pyproject-default.toml', 'fail2.py', 'fail2.txt'),
+    ('pyproject-default.toml', 'fail3.py', 'fail3.txt'),
+    ('pyproject-default.toml', 'fail4.py', 'fail4.txt'),
+    ('pyproject-plugin.toml', 'plugin_success.py', None),
+    ('pyproject-plugin.toml', 'plugin_fail.py', 'plugin-fail.txt'),
+    ('pyproject-plugin-strict.toml', 'plugin_success.py', 'plugin-success-strict.txt'),
+    ('pyproject-plugin-strict.toml', 'plugin_fail.py', 'plugin-fail-strict.txt'),
 ]
 executable_modules = list({fname[:-3] for _, fname, out_fname in cases if out_fname is None})
 
 
 @pytest.mark.skipif(not (dotenv and mypy_api), reason='dotenv or mypy are not installed')
 @pytest.mark.parametrize('config_filename,python_filename,output_filename', cases)
-def test_mypy_results(config_filename, python_filename, output_filename):
+def test_mypy_results(config_filename: str, python_filename: str, output_filename: str) -> None:
     full_config_filename = f'tests/mypy/configs/{config_filename}'
     full_filename = f'tests/mypy/modules/{python_filename}'
     output_path = None if output_filename is None else Path(f'tests/mypy/outputs/{output_filename}')
 
     # Specifying a different cache dir for each configuration dramatically speeds up subsequent execution
     # It also prevents cache-invalidation-related bugs in the tests
-    cache_dir = f'.mypy_cache/test-{config_filename[:-4]}'
+    cache_dir = f'.mypy_cache/test-{os.path.splitext(config_filename)[0]}'
     command = [full_filename, '--config-file', full_config_filename, '--cache-dir', cache_dir, '--show-error-codes']
     print(f"\nExecuting: mypy {' '.join(command)}")  # makes it easier to debug as necessary
     actual_result = mypy_api.run(command)
@@ -66,15 +75,31 @@ def test_mypy_results(config_filename, python_filename, output_filename):
     assert actual_out == expected_out, actual_out
 
 
+@pytest.mark.skipif(not (dotenv and mypy_api), reason='dotenv or mypy are not installed')
+def test_bad_toml_config() -> None:
+    full_config_filename = 'tests/mypy/configs/pyproject-plugin-bad-param.toml'
+    full_filename = 'tests/mypy/modules/success.py'
+
+    # Specifying a different cache dir for each configuration dramatically speeds up subsequent execution
+    # It also prevents cache-invalidation-related bugs in the tests
+    cache_dir = '.mypy_cache/test-pyproject-plugin-bad-param'
+    command = [full_filename, '--config-file', full_config_filename, '--cache-dir', cache_dir, '--show-error-codes']
+    print(f"\nExecuting: mypy {' '.join(command)}")  # makes it easier to debug as necessary
+    with pytest.raises(ValueError) as e:
+        mypy_api.run(command)
+
+    assert str(e.value) == 'Configuration value must be a boolean for key: init_forbid_extra'
+
+
 @pytest.mark.parametrize('module', executable_modules)
-def test_success_cases_run(module):
+def test_success_cases_run(module: str) -> None:
     """
     Ensure the "success" files can actually be executed
     """
     importlib.import_module(f'tests.mypy.modules.{module}')
 
 
-def test_explicit_reexports():
+def test_explicit_reexports() -> None:
     from pydantic import __all__ as root_all
     from pydantic.main import __all__ as main
     from pydantic.networks import __all__ as networks
diff --git a/tests/requirements-linting.txt b/tests/requirements-linting.txt
index e99dad38f..b06f22806 100644
--- a/tests/requirements-linting.txt
+++ b/tests/requirements-linting.txt
@@ -4,6 +4,7 @@ flake8-quotes==3.3.0
 hypothesis==6.17.4
 isort==5.9.3
 mypy==0.910
+types-toml==0.1.5
 pycodestyle==2.7.0
 pyflakes==2.3.1
 twine==3.4.2
\ No newline at end of file