Skip to content

Commit 3caea3f

Browse files
authored
Add strict_settings option, allow runtime fallbacks for custom settings (#1557)
1 parent c029dd8 commit 3caea3f

File tree

7 files changed

+152
-16
lines changed

7 files changed

+152
-16
lines changed

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,53 @@ If you encounter this error in your own code, you can either cast the `Promise`
311311

312312
If this is reported on Django code, please report an issue or open a pull request to fix the type hints.
313313

314+
### How to use a custom library to handle Django settings?
315+
316+
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.
317+
318+
This might also be the case when using something like:
319+
320+
```python
321+
try:
322+
from .local_settings import *
323+
except Exception:
324+
pass
325+
```
326+
327+
So, mypy would not like this code:
328+
329+
```python
330+
from django.conf import settings
331+
332+
settings.CUSTOM_VALUE # E: 'Settings' object has no attribute 'CUSTOM_SETTING'
333+
```
334+
335+
To handle this corner case we have a special setting `strict_settings` (`True` by default),
336+
you can switch it to `False` to always return `Any` and not raise any errors if runtime settings module has the given value,
337+
for example `pyproject.toml`:
338+
339+
```toml
340+
[tool.django-stubs]
341+
strict_settings = false
342+
```
343+
344+
or `mypy.ini`:
345+
346+
```ini
347+
[mypy.plugins.django-stubs]
348+
strict_settings = false
349+
```
350+
351+
And then:
352+
353+
```python
354+
# Works:
355+
reveal_type(settings.EXISTS_IN_RUNTIME) # N: Any
356+
357+
# Errors:
358+
reveal_type(settings.MISSING) # E: 'Settings' object has no attribute 'MISSING'
359+
```
360+
314361
## Related projects
315362

316363
- [`awesome-python-typing`](https://github.com/typeddjango/awesome-python-typing) - Awesome list of all typing-related things in Python.

mypy_django_plugin/config.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,23 @@
1414
(config)
1515
...
1616
[mypy.plugins.django-stubs]
17-
django_settings_module: str (required)
17+
django_settings_module = str (required)
18+
strict_settings = bool (default: true)
1819
...
1920
"""
2021
TOML_USAGE = """
2122
(config)
2223
...
2324
[tool.django-stubs]
2425
django_settings_module = str (required)
26+
strict_settings = bool (default: true)
2527
...
2628
"""
2729
INVALID_FILE = "mypy config file is not specified or found"
2830
COULD_NOT_LOAD_FILE = "could not load configuration file"
29-
MISSING_SECTION = "no section [{section}] found".format
31+
MISSING_SECTION = "no section [{section}] found"
3032
MISSING_DJANGO_SETTINGS = "missing required 'django_settings_module' config"
31-
INVALID_SETTING = "invalid {key!r}: the setting must be a boolean".format
33+
INVALID_BOOL_SETTING = "invalid {key!r}: the setting must be a boolean"
3234

3335

3436
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:
4850

4951

5052
class DjangoPluginConfig:
51-
__slots__ = ("django_settings_module",)
53+
__slots__ = ("django_settings_module", "strict_settings")
5254
django_settings_module: str
55+
strict_settings: bool
5356

5457
def __init__(self, config_file: Optional[str]) -> None:
5558
if not config_file:
@@ -75,7 +78,7 @@ def parse_toml_file(self, filepath: Path) -> None:
7578
try:
7679
config: Dict[str, Any] = data["tool"]["django-stubs"]
7780
except KeyError:
78-
toml_exit(MISSING_SECTION(section="tool.django-stubs"))
81+
toml_exit(MISSING_SECTION.format(section="tool.django-stubs"))
7982

8083
if "django_settings_module" not in config:
8184
toml_exit(MISSING_DJANGO_SETTINGS)
@@ -84,6 +87,10 @@ def parse_toml_file(self, filepath: Path) -> None:
8487
if not isinstance(self.django_settings_module, str):
8588
toml_exit("invalid 'django_settings_module': the setting must be a string")
8689

90+
self.strict_settings = config.get("strict_settings", True)
91+
if not isinstance(self.strict_settings, bool):
92+
toml_exit(INVALID_BOOL_SETTING.format(key="strict_settings"))
93+
8794
def parse_ini_file(self, filepath: Path) -> None:
8895
parser = configparser.ConfigParser()
8996
try:
@@ -94,9 +101,14 @@ def parse_ini_file(self, filepath: Path) -> None:
94101

95102
section = "mypy.plugins.django-stubs"
96103
if not parser.has_section(section):
97-
exit_with_error(MISSING_SECTION(section=section))
104+
exit_with_error(MISSING_SECTION.format(section=section))
98105

99106
if not parser.has_option(section, "django_settings_module"):
100107
exit_with_error(MISSING_DJANGO_SETTINGS)
101108

102109
self.django_settings_module = parser.get(section, "django_settings_module").strip("'\"")
110+
111+
try:
112+
self.strict_settings = parser.getboolean(section, "strict_settings", fallback=True)
113+
except ValueError:
114+
exit_with_error(INVALID_BOOL_SETTING.format(key="strict_settings"))

mypy_django_plugin/lib/helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ def resolve_string_attribute_value(attr_expr: Expression, django_context: "Djang
333333
member_name = attr_expr.name
334334
if isinstance(attr_expr.expr, NameExpr) and attr_expr.expr.fullname == "django.conf.settings":
335335
if hasattr(django_context.settings, member_name):
336-
return getattr(django_context.settings, member_name) # type: ignore
336+
return getattr(django_context.settings, member_name) # type: ignore[no-any-return]
337337
return None
338338

339339

mypy_django_plugin/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,11 @@ def get_attribute_hook(self, fullname: str) -> Optional[Callable[[AttributeConte
274274

275275
# Lookup of a settings variable
276276
if class_name == fullnames.DUMMY_SETTINGS_BASE_CLASS:
277-
return partial(settings.get_type_of_settings_attribute, django_context=self.django_context)
277+
return partial(
278+
settings.get_type_of_settings_attribute,
279+
django_context=self.django_context,
280+
plugin_config=self.plugin_config,
281+
)
278282

279283
info = self._get_typeinfo_or_none(class_name)
280284

mypy_django_plugin/transformers/settings.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from mypy.types import AnyType, Instance, TypeOfAny, TypeType
44
from mypy.types import Type as MypyType
55

6+
from mypy_django_plugin.config import DjangoPluginConfig
67
from mypy_django_plugin.django.context import DjangoContext
78
from mypy_django_plugin.lib import helpers
89

@@ -19,7 +20,9 @@ def get_user_model_hook(ctx: FunctionContext, django_context: DjangoContext) ->
1920
return TypeType(Instance(model_info, []))
2021

2122

22-
def get_type_of_settings_attribute(ctx: AttributeContext, django_context: DjangoContext) -> MypyType:
23+
def get_type_of_settings_attribute(
24+
ctx: AttributeContext, django_context: DjangoContext, plugin_config: DjangoPluginConfig
25+
) -> MypyType:
2326
if not isinstance(ctx.context, MemberExpr):
2427
return ctx.default_attr_type
2528

@@ -42,5 +45,12 @@ def get_type_of_settings_attribute(ctx: AttributeContext, django_context: Django
4245
return ctx.default_attr_type
4346
return sym.type
4447

48+
# Now, we want to check if this setting really exist in runtime.
49+
# If it does, we just return `Any`, not to raise any false-positives.
50+
# But, we cannot reconstruct the exact runtime type.
51+
# See https://github.com/typeddjango/django-stubs/pull/1163
52+
if not plugin_config.strict_settings and hasattr(django_context.settings, setting_name):
53+
return AnyType(TypeOfAny.implementation_artifact)
54+
4555
ctx.api.fail(f"'Settings' object has no attribute {setting_name!r}", ctx.context)
4656
return ctx.default_attr_type

tests/test_error_handling.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
(config)
1212
...
1313
[mypy.plugins.django-stubs]
14-
django_settings_module: str (required)
14+
django_settings_module = str (required)
15+
strict_settings = bool (default: true)
1516
...
1617
(django-stubs) mypy: error: {}
1718
"""
@@ -21,6 +22,7 @@
2122
...
2223
[tool.django-stubs]
2324
django_settings_module = str (required)
25+
strict_settings = bool (default: true)
2426
...
2527
(django-stubs) mypy: error: {}
2628
"""
@@ -52,6 +54,11 @@ def write_to_file(file_contents: str, suffix: Optional[str] = None) -> Generator
5254
"missing required 'django_settings_module' config",
5355
id="no-settings-given",
5456
),
57+
pytest.param(
58+
["[mypy.plugins.django-stubs]", "django_settings_module = some.module", "strict_settings = bad"],
59+
"invalid 'strict_settings': the setting must be a boolean",
60+
id="missing-settings-module",
61+
),
5562
],
5663
)
5764
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:
113120
"could not load configuration file",
114121
id="invalid toml",
115122
),
123+
pytest.param(
124+
"""
125+
[tool.django-stubs]
126+
django_settings_module = "some.module"
127+
strict_settings = "a"
128+
""",
129+
"invalid 'strict_settings': the setting must be a boolean",
130+
id="invalid strict_settings type",
131+
),
116132
],
117133
)
118134
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
124140
assert error_message == capsys.readouterr().err
125141

126142

127-
def test_correct_toml_configuration() -> None:
143+
@pytest.mark.parametrize("boolean_value", ["true", "false"])
144+
def test_correct_toml_configuration(boolean_value: str) -> None:
128145
config_file_contents = """
129146
[tool.django-stubs]
130147
some_other_setting = "setting"
131148
django_settings_module = "my.module"
132-
"""
149+
strict_settings = {}
150+
""".format(
151+
boolean_value
152+
)
133153

134154
with write_to_file(config_file_contents, suffix=".toml") as filename:
135155
config = DjangoPluginConfig(filename)
136156

137157
assert config.django_settings_module == "my.module"
158+
assert config.strict_settings is (boolean_value == "true")
138159

139160

140-
def test_correct_configuration() -> None:
161+
@pytest.mark.parametrize("boolean_value", ["true", "True", "false", "False"])
162+
def test_correct_configuration(boolean_value) -> None:
141163
"""Django settings module gets extracted given valid configuration."""
142164
config_file_contents = "\n".join(
143165
[
144166
"[mypy.plugins.django-stubs]",
145-
"\tsome_other_setting = setting",
146-
"\tdjango_settings_module = my.module",
167+
"some_other_setting = setting",
168+
"django_settings_module = my.module",
169+
f"strict_settings = {boolean_value}",
147170
]
148-
).expandtabs(4)
171+
)
149172
with write_to_file(config_file_contents) as filename:
150173
config = DjangoPluginConfig(filename)
151174

152175
assert config.django_settings_module == "my.module"
176+
assert config.strict_settings is (boolean_value.lower() == "true")

tests/typecheck/test_settings.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,42 @@
5858
main:4: error: 'Settings' object has no attribute 'NON_EXISTANT_SETTING'
5959
main:5: error: 'Settings' object has no attribute 'NON_EXISTANT_SETTING'
6060
main:5: note: Revealed type is "Any"
61+
62+
63+
- case: settings_loaded_from_runtime_magic
64+
disable_cache: true
65+
main: |
66+
from django.conf import settings
67+
68+
# Global:
69+
reveal_type(settings.SECRET_KEY) # N: Revealed type is "builtins.str"
70+
71+
# Custom:
72+
reveal_type(settings.A) # N: Revealed type is "Any"
73+
reveal_type(settings.B) # E: 'Settings' object has no attribute 'B' # N: Revealed type is "Any"
74+
custom_settings: |
75+
# Some code that mypy cannot analyze, but values exist in runtime:
76+
exec('A = 1')
77+
mypy_config: |
78+
[mypy.plugins.django-stubs]
79+
django_settings_module = mysettings
80+
strict_settings = false
81+
82+
83+
- case: settings_loaded_from_runtime_magic_strict_default
84+
disable_cache: true
85+
main: |
86+
from django.conf import settings
87+
88+
# Global:
89+
reveal_type(settings.SECRET_KEY) # N: Revealed type is "builtins.str"
90+
91+
# Custom:
92+
reveal_type(settings.A) # E: 'Settings' object has no attribute 'A' # N: Revealed type is "Any"
93+
reveal_type(settings.B) # E: 'Settings' object has no attribute 'B' # N: Revealed type is "Any"
94+
custom_settings: |
95+
# Some code that mypy cannot analyze, but values exist in runtime:
96+
exec('A = 1')
97+
mypy_config: |
98+
[mypy.plugins.django-stubs]
99+
django_settings_module = mysettings

0 commit comments

Comments
 (0)