diff --git a/README.md b/README.md index b3fdec973..0c0f3d331 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,53 @@ If you encounter this error in your own code, you can either cast the `Promise` If this is reported on Django code, please report an issue or open a pull request to fix the type hints. +### How to use a custom library to handle Django settings? + +Using something like [`django-split-settings`](https://github.com/wemake-services/django-split-settings) or [`django-configurations`](https://github.com/jazzband/django-configurations) will make it hard for mypy to infer your settings. + +This might also be the case when using something like: + +```python +try: + from .local_settings import * +except Exception: + pass +``` + +So, mypy would not like this code: + +```python +from django.conf import settings + +settings.CUSTOM_VALUE # E: 'Settings' object has no attribute 'CUSTOM_SETTING' +``` + +To handle this corner case we have a special setting `strict_settings` (`True` by default), +you can switch it to `False` to always return `Any` and not raise any errors if runtime settings module has the given value, +for example `pyproject.toml`: + +```toml +[tool.django-stubs] +strict_settings = false +``` + +or `mypy.ini`: + +```ini +[mypy.plugins.django-stubs] +strict_settings = false +``` + +And then: + +```python +# Works: +reveal_type(settings.EXISTS_IN_RUNTIME) # N: Any + +# Errors: +reveal_type(settings.MISSING) # E: 'Settings' object has no attribute 'MISSING' +``` + ## Related projects - [`awesome-python-typing`](https://github.com/typeddjango/awesome-python-typing) - Awesome list of all typing-related things in Python. diff --git a/mypy_django_plugin/config.py b/mypy_django_plugin/config.py index 28d52e0b6..de68c40cb 100644 --- a/mypy_django_plugin/config.py +++ b/mypy_django_plugin/config.py @@ -14,7 +14,8 @@ (config) ... [mypy.plugins.django-stubs] - django_settings_module: str (required) +django_settings_module = str (required) +strict_settings = bool (default: true) ... """ TOML_USAGE = """ @@ -22,13 +23,14 @@ ... [tool.django-stubs] django_settings_module = str (required) +strict_settings = bool (default: true) ... """ INVALID_FILE = "mypy config file is not specified or found" COULD_NOT_LOAD_FILE = "could not load configuration file" -MISSING_SECTION = "no section [{section}] found".format +MISSING_SECTION = "no section [{section}] found" MISSING_DJANGO_SETTINGS = "missing required 'django_settings_module' config" -INVALID_SETTING = "invalid {key!r}: the setting must be a boolean".format +INVALID_BOOL_SETTING = "invalid {key!r}: the setting must be a boolean" def exit_with_error(msg: str, is_toml: bool = False) -> NoReturn: @@ -48,8 +50,9 @@ def exit_with_error(msg: str, is_toml: bool = False) -> NoReturn: class DjangoPluginConfig: - __slots__ = ("django_settings_module",) + __slots__ = ("django_settings_module", "strict_settings") django_settings_module: str + strict_settings: bool def __init__(self, config_file: Optional[str]) -> None: if not config_file: @@ -75,7 +78,7 @@ def parse_toml_file(self, filepath: Path) -> None: try: config: Dict[str, Any] = data["tool"]["django-stubs"] except KeyError: - toml_exit(MISSING_SECTION(section="tool.django-stubs")) + toml_exit(MISSING_SECTION.format(section="tool.django-stubs")) if "django_settings_module" not in config: toml_exit(MISSING_DJANGO_SETTINGS) @@ -84,6 +87,10 @@ def parse_toml_file(self, filepath: Path) -> None: if not isinstance(self.django_settings_module, str): toml_exit("invalid 'django_settings_module': the setting must be a string") + self.strict_settings = config.get("strict_settings", True) + if not isinstance(self.strict_settings, bool): + toml_exit(INVALID_BOOL_SETTING.format(key="strict_settings")) + def parse_ini_file(self, filepath: Path) -> None: parser = configparser.ConfigParser() try: @@ -94,9 +101,14 @@ def parse_ini_file(self, filepath: Path) -> None: section = "mypy.plugins.django-stubs" if not parser.has_section(section): - exit_with_error(MISSING_SECTION(section=section)) + exit_with_error(MISSING_SECTION.format(section=section)) if not parser.has_option(section, "django_settings_module"): exit_with_error(MISSING_DJANGO_SETTINGS) self.django_settings_module = parser.get(section, "django_settings_module").strip("'\"") + + try: + self.strict_settings = parser.getboolean(section, "strict_settings", fallback=True) + except ValueError: + exit_with_error(INVALID_BOOL_SETTING.format(key="strict_settings")) diff --git a/mypy_django_plugin/lib/helpers.py b/mypy_django_plugin/lib/helpers.py index c20c16426..6f859ec9b 100644 --- a/mypy_django_plugin/lib/helpers.py +++ b/mypy_django_plugin/lib/helpers.py @@ -333,7 +333,7 @@ def resolve_string_attribute_value(attr_expr: Expression, django_context: "Djang member_name = attr_expr.name if isinstance(attr_expr.expr, NameExpr) and attr_expr.expr.fullname == "django.conf.settings": if hasattr(django_context.settings, member_name): - return getattr(django_context.settings, member_name) # type: ignore + return getattr(django_context.settings, member_name) # type: ignore[no-any-return] return None diff --git a/mypy_django_plugin/main.py b/mypy_django_plugin/main.py index a47c655d9..5ea3bb4f5 100644 --- a/mypy_django_plugin/main.py +++ b/mypy_django_plugin/main.py @@ -274,7 +274,11 @@ def get_attribute_hook(self, fullname: str) -> Optional[Callable[[AttributeConte # Lookup of a settings variable if class_name == fullnames.DUMMY_SETTINGS_BASE_CLASS: - return partial(settings.get_type_of_settings_attribute, django_context=self.django_context) + return partial( + settings.get_type_of_settings_attribute, + django_context=self.django_context, + plugin_config=self.plugin_config, + ) info = self._get_typeinfo_or_none(class_name) diff --git a/mypy_django_plugin/transformers/settings.py b/mypy_django_plugin/transformers/settings.py index 67caa6a50..f821b971e 100644 --- a/mypy_django_plugin/transformers/settings.py +++ b/mypy_django_plugin/transformers/settings.py @@ -3,6 +3,7 @@ from mypy.types import AnyType, Instance, TypeOfAny, TypeType from mypy.types import Type as MypyType +from mypy_django_plugin.config import DjangoPluginConfig from mypy_django_plugin.django.context import DjangoContext from mypy_django_plugin.lib import helpers @@ -19,7 +20,9 @@ def get_user_model_hook(ctx: FunctionContext, django_context: DjangoContext) -> return TypeType(Instance(model_info, [])) -def get_type_of_settings_attribute(ctx: AttributeContext, django_context: DjangoContext) -> MypyType: +def get_type_of_settings_attribute( + ctx: AttributeContext, django_context: DjangoContext, plugin_config: DjangoPluginConfig +) -> MypyType: if not isinstance(ctx.context, MemberExpr): return ctx.default_attr_type @@ -42,5 +45,12 @@ def get_type_of_settings_attribute(ctx: AttributeContext, django_context: Django return ctx.default_attr_type return sym.type + # Now, we want to check if this setting really exist in runtime. + # If it does, we just return `Any`, not to raise any false-positives. + # But, we cannot reconstruct the exact runtime type. + # See https://github.com/typeddjango/django-stubs/pull/1163 + if not plugin_config.strict_settings and hasattr(django_context.settings, setting_name): + return AnyType(TypeOfAny.implementation_artifact) + ctx.api.fail(f"'Settings' object has no attribute {setting_name!r}", ctx.context) return ctx.default_attr_type diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index 34c578f22..b25f07b48 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -11,7 +11,8 @@ (config) ... [mypy.plugins.django-stubs] - django_settings_module: str (required) +django_settings_module = str (required) +strict_settings = bool (default: true) ... (django-stubs) mypy: error: {} """ @@ -21,6 +22,7 @@ ... [tool.django-stubs] django_settings_module = str (required) +strict_settings = bool (default: true) ... (django-stubs) mypy: error: {} """ @@ -52,6 +54,11 @@ def write_to_file(file_contents: str, suffix: Optional[str] = None) -> Generator "missing required 'django_settings_module' config", id="no-settings-given", ), + pytest.param( + ["[mypy.plugins.django-stubs]", "django_settings_module = some.module", "strict_settings = bad"], + "invalid 'strict_settings': the setting must be a boolean", + id="missing-settings-module", + ), ], ) def test_misconfiguration_handling(capsys: Any, config_file_contents: List[str], message_part: str) -> None: @@ -113,6 +120,15 @@ def test_handles_filename(capsys: Any, filename: str) -> None: "could not load configuration file", id="invalid toml", ), + pytest.param( + """ + [tool.django-stubs] + django_settings_module = "some.module" + strict_settings = "a" + """, + "invalid 'strict_settings': the setting must be a boolean", + id="invalid strict_settings type", + ), ], ) def test_toml_misconfiguration_handling(capsys: Any, config_file_contents, message_part) -> None: @@ -124,29 +140,37 @@ def test_toml_misconfiguration_handling(capsys: Any, config_file_contents, messa assert error_message == capsys.readouterr().err -def test_correct_toml_configuration() -> None: +@pytest.mark.parametrize("boolean_value", ["true", "false"]) +def test_correct_toml_configuration(boolean_value: str) -> None: config_file_contents = """ [tool.django-stubs] some_other_setting = "setting" django_settings_module = "my.module" - """ + strict_settings = {} + """.format( + boolean_value + ) with write_to_file(config_file_contents, suffix=".toml") as filename: config = DjangoPluginConfig(filename) assert config.django_settings_module == "my.module" + assert config.strict_settings is (boolean_value == "true") -def test_correct_configuration() -> None: +@pytest.mark.parametrize("boolean_value", ["true", "True", "false", "False"]) +def test_correct_configuration(boolean_value) -> None: """Django settings module gets extracted given valid configuration.""" config_file_contents = "\n".join( [ "[mypy.plugins.django-stubs]", - "\tsome_other_setting = setting", - "\tdjango_settings_module = my.module", + "some_other_setting = setting", + "django_settings_module = my.module", + f"strict_settings = {boolean_value}", ] - ).expandtabs(4) + ) with write_to_file(config_file_contents) as filename: config = DjangoPluginConfig(filename) assert config.django_settings_module == "my.module" + assert config.strict_settings is (boolean_value.lower() == "true") diff --git a/tests/typecheck/test_settings.yml b/tests/typecheck/test_settings.yml index ee4e69958..50ebfe0be 100644 --- a/tests/typecheck/test_settings.yml +++ b/tests/typecheck/test_settings.yml @@ -58,3 +58,42 @@ main:4: error: 'Settings' object has no attribute 'NON_EXISTANT_SETTING' main:5: error: 'Settings' object has no attribute 'NON_EXISTANT_SETTING' main:5: note: Revealed type is "Any" + + +- case: settings_loaded_from_runtime_magic + disable_cache: true + main: | + from django.conf import settings + + # Global: + reveal_type(settings.SECRET_KEY) # N: Revealed type is "builtins.str" + + # Custom: + reveal_type(settings.A) # N: Revealed type is "Any" + reveal_type(settings.B) # E: 'Settings' object has no attribute 'B' # N: Revealed type is "Any" + custom_settings: | + # Some code that mypy cannot analyze, but values exist in runtime: + exec('A = 1') + mypy_config: | + [mypy.plugins.django-stubs] + django_settings_module = mysettings + strict_settings = false + + +- case: settings_loaded_from_runtime_magic_strict_default + disable_cache: true + main: | + from django.conf import settings + + # Global: + reveal_type(settings.SECRET_KEY) # N: Revealed type is "builtins.str" + + # Custom: + reveal_type(settings.A) # E: 'Settings' object has no attribute 'A' # N: Revealed type is "Any" + reveal_type(settings.B) # E: 'Settings' object has no attribute 'B' # N: Revealed type is "Any" + custom_settings: | + # Some code that mypy cannot analyze, but values exist in runtime: + exec('A = 1') + mypy_config: | + [mypy.plugins.django-stubs] + django_settings_module = mysettings