Skip to content

Commit 82c99e8

Browse files
sobolevnpre-commit-ci[bot]asottile-sentry
authored
Add strict_model_abstract_attrs setting to allow models.Model.objects access (#2830)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: anthony sottile <103459774+asottile-sentry@users.noreply.github.com>
1 parent ed5046e commit 82c99e8

File tree

7 files changed

+267
-81
lines changed

7 files changed

+267
-81
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ The supported settings are:
124124

125125
Set to `false` if using dynamic settings, as [described below](https://github.com/typeddjango/django-stubs#how-to-use-a-custom-library-to-handle-django-settings).
126126

127+
- `strict_model_abstract_attrs`, a boolean, default `true`.
128+
129+
Set to `false` if you want to keep `.objects`, `.DoesNotExist`,
130+
and `.MultipleObjectsReturned` attributes on `models.Model` type.
131+
[See here why](https://github.com/typeddjango/django-stubs?tab=readme-ov-file#how-to-use-typemodel-annotation-with-objects-attribute)
132+
this is dangerous to do by default.
133+
134+
127135
## FAQ
128136

129137
### Is this an official Django project?
@@ -367,6 +375,11 @@ def assert_zero_count(model_type: type[models.Model]) -> None:
367375
assert model_type._default_manager.count() == 0
368376
```
369377

378+
Configurable with `strict_model_abstract_attrs = false`
379+
to skip removing `.objects`, `.DoesNotExist`, and `.MultipleObjectsReturned`
380+
attributes from `model.Model` if you are using our mypy plugin.
381+
382+
Use this setting on your own risk, because it can hide valid errors.
370383

371384
### How to type a custom `models.Field`?
372385

mypy_django_plugin/config.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
[mypy.plugins.django-stubs]
1919
django_settings_module = str (default: `os.getenv("DJANGO_SETTINGS_MODULE")`)
2020
strict_settings = bool (default: true)
21+
strict_model_abstract_attrs = bool (default: true)
2122
...
2223
"""
2324
TOML_USAGE = """
@@ -26,14 +27,17 @@
2627
[tool.django-stubs]
2728
django_settings_module = str (default: `os.getenv("DJANGO_SETTINGS_MODULE")`)
2829
strict_settings = bool (default: true)
30+
strict_model_abstract_attrs = bool (default: true)
2931
...
3032
"""
3133
INVALID_FILE = "mypy config file is not specified or found"
3234
COULD_NOT_LOAD_FILE = "could not load configuration file"
3335
MISSING_SECTION = "no section [{section}] found"
3436
DJANGO_SETTINGS_ENV_VAR = "DJANGO_SETTINGS_MODULE"
35-
MISSING_DJANGO_SETTINGS = f"missing required 'django_settings_module' config.\
36-
Either specify this config or set your `{DJANGO_SETTINGS_ENV_VAR}` env var"
37+
MISSING_DJANGO_SETTINGS = (
38+
"missing required 'django_settings_module' config.\n"
39+
f"Either specify this config or set your `{DJANGO_SETTINGS_ENV_VAR}` env var"
40+
)
3741
INVALID_BOOL_SETTING = "invalid {key!r}: the setting must be a boolean"
3842

3943

@@ -54,7 +58,8 @@ def exit_with_error(msg: str, is_toml: bool = False) -> NoReturn:
5458

5559

5660
class DjangoPluginConfig:
57-
__slots__ = ("django_settings_module", "strict_settings")
61+
__slots__ = ("django_settings_module", "strict_settings", "strict_model_abstract_attrs")
62+
5863
django_settings_module: str
5964
strict_settings: bool
6065

@@ -96,6 +101,9 @@ def parse_toml_file(self, filepath: Path) -> None:
96101
self.strict_settings = config.get("strict_settings", True)
97102
if not isinstance(self.strict_settings, bool):
98103
toml_exit(INVALID_BOOL_SETTING.format(key="strict_settings"))
104+
self.strict_model_abstract_attrs = config.get("strict_model_abstract_attrs", True)
105+
if not isinstance(self.strict_model_abstract_attrs, bool):
106+
toml_exit(INVALID_BOOL_SETTING.format(key="strict_model_abstract_attrs"))
99107

100108
def parse_ini_file(self, filepath: Path) -> None:
101109
parser = configparser.ConfigParser()
@@ -124,10 +132,16 @@ def parse_ini_file(self, filepath: Path) -> None:
124132
except ValueError:
125133
exit_with_error(INVALID_BOOL_SETTING.format(key="strict_settings"))
126134

135+
try:
136+
self.strict_model_abstract_attrs = parser.getboolean(section, "strict_model_abstract_attrs", fallback=True)
137+
except ValueError:
138+
exit_with_error(INVALID_BOOL_SETTING.format(key="strict_model_abstract_attrs"))
139+
127140
def to_json(self, extra_data: dict[str, Any]) -> dict[str, Any]:
128141
"""We use this method to reset mypy cache via `report_config_data` hook."""
129142
return {
130143
"django_settings_module": self.django_settings_module,
131144
"strict_settings": self.strict_settings,
145+
"strict_model_abstract_attrs": self.strict_model_abstract_attrs,
132146
**dict(sorted(extra_data.items())),
133147
}

mypy_django_plugin/main.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ def get_customize_class_mro_hook(self, fullname: str) -> Callable[[ClassDefConte
212212

213213
def get_metaclass_hook(self, fullname: str) -> Callable[[ClassDefContext], None] | None:
214214
if fullname == fullnames.MODEL_METACLASS_FULLNAME:
215-
return MetaclassAdjustments.adjust_model_class
215+
return partial(MetaclassAdjustments.adjust_model_class, plugin_config=self.plugin_config)
216216
return None
217217

218218
def get_base_class_hook(self, fullname: str) -> Callable[[ClassDefContext], None] | None:
@@ -292,11 +292,9 @@ def get_dynamic_class_hook(self, fullname: str) -> Callable[[DynamicClassDefCont
292292

293293
def report_config_data(self, ctx: ReportConfigContext) -> dict[str, Any]:
294294
# Cache would be cleared if any settings do change.
295-
extra_data = {}
296-
# In all places we use '_User' alias as a type we want to clear cache if
297-
# AUTH_USER_MODEL setting changes
298-
if ctx.id.startswith("django.contrib.auth") or ctx.id in {"django.http.request", "django.test.client"}:
299-
extra_data["AUTH_USER_MODEL"] = self.django_context.settings.AUTH_USER_MODEL
295+
extra_data = {
296+
"AUTH_USER_MODEL": self.django_context.settings.AUTH_USER_MODEL,
297+
}
300298
return self.plugin_config.to_json(extra_data)
301299

302300

mypy_django_plugin/transformers/models.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from mypy.types import Type as MypyType
4141
from mypy.typevars import fill_typevars, fill_typevars_with_any
4242

43+
from mypy_django_plugin.config import DjangoPluginConfig
4344
from mypy_django_plugin.django.context import DjangoContext
4445
from mypy_django_plugin.errorcodes import MANAGER_MISSING
4546
from mypy_django_plugin.exceptions import UnregisteredModelError
@@ -991,13 +992,35 @@ def create_many_related_manager(self, model: Instance) -> None:
991992

992993
class MetaclassAdjustments(ModelClassInitializer):
993994
@classmethod
994-
def adjust_model_class(cls, ctx: ClassDefContext) -> None:
995+
def adjust_model_class(cls, ctx: ClassDefContext, plugin_config: DjangoPluginConfig) -> None:
995996
"""
996997
For the sake of type checkers other than mypy, some attributes that are
997998
dynamically added by Django's model metaclass has been annotated on
998999
`django.db.models.base.Model`. We remove those attributes and will handle them
9991000
through the plugin.
1001+
1002+
Configurable with `strict_model_abstract_attrs = false` to skip removing any objects from models.
1003+
1004+
Basically, this code::
1005+
1006+
from django.db import models
1007+
1008+
def get_model_count(model_type: type[models.Model]) -> int:
1009+
return model_type.objects.count()
1010+
1011+
- Will raise an error
1012+
`"type[models.Model]" has no attribute "objects" [attr-defined]`
1013+
when `strict_model_abstract_attrs = true` (default)
1014+
- Return without any type errors
1015+
when `strict_model_abstract_attrs = false`
1016+
1017+
But, beware that the code above can fail in runtime, as mypy correctly shows.
1018+
Example: `get_model_count(models.Model)`
1019+
1020+
Turn this setting off at your own risk.
10001021
"""
1022+
if not plugin_config.strict_model_abstract_attrs:
1023+
return
10011024
if ctx.cls.fullname != fullnames.MODEL_CLASS_FULLNAME:
10021025
return
10031026

@@ -1006,8 +1029,6 @@ def adjust_model_class(cls, ctx: ClassDefContext) -> None:
10061029
if attr is not None and isinstance(attr.node, Var) and not attr.plugin_generated:
10071030
del ctx.cls.info.names[attr_name]
10081031

1009-
return
1010-
10111032
def get_exception_bases(self, name: str) -> list[Instance]:
10121033
bases = []
10131034
for model_base in self.model_classdef.info.direct_base_classes():

tests/test_error_handling.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
[mypy.plugins.django-stubs]
1717
django_settings_module = str (default: `os.getenv("DJANGO_SETTINGS_MODULE")`)
1818
strict_settings = bool (default: true)
19+
strict_model_abstract_attrs = bool (default: true)
1920
...
2021
(django-stubs) mypy: error: {}
2122
"""
@@ -26,6 +27,7 @@
2627
[tool.django-stubs]
2728
django_settings_module = str (default: `os.getenv("DJANGO_SETTINGS_MODULE")`)
2829
strict_settings = bool (default: true)
30+
strict_model_abstract_attrs = bool (default: true)
2931
...
3032
(django-stubs) mypy: error: {}
3133
"""
@@ -49,20 +51,33 @@ def write_to_file(file_contents: str, suffix: str | None = None) -> Generator[st
4951
),
5052
pytest.param(
5153
["[mypy.plugins.django-stubs]", "\tnot_django_not_settings_module = badbadmodule"],
52-
"missing required 'django_settings_module' config.\
53-
Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var",
54+
(
55+
"missing required 'django_settings_module' config.\n"
56+
"Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var"
57+
),
5458
id="missing-settings-module",
5559
),
5660
pytest.param(
5761
["[mypy.plugins.django-stubs]"],
58-
"missing required 'django_settings_module' config.\
59-
Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var",
62+
(
63+
"missing required 'django_settings_module' config.\n"
64+
"Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var"
65+
),
6066
id="no-settings-given",
6167
),
6268
pytest.param(
6369
["[mypy.plugins.django-stubs]", "django_settings_module = some.module", "strict_settings = bad"],
6470
"invalid 'strict_settings': the setting must be a boolean",
65-
id="missing-settings-module",
71+
id="invalid-strict_settings",
72+
),
73+
pytest.param(
74+
[
75+
"[mypy.plugins.django-stubs]",
76+
"django_settings_module = some.module",
77+
"strict_model_abstract_attrs = bad",
78+
],
79+
"invalid 'strict_model_abstract_attrs': the setting must be a boolean",
80+
id="invalid-strict_model_abstract_attrs",
6681
),
6782
],
6883
)
@@ -117,8 +132,10 @@ def test_handles_filename(capsys: Any, filename: str) -> None:
117132
[tool.django-stubs]
118133
not_django_not_settings_module = "badbadmodule"
119134
""",
120-
"missing required 'django_settings_module' config.\
121-
Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var",
135+
(
136+
"missing required 'django_settings_module' config.\n"
137+
"Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var"
138+
),
122139
id="missing django_settings_module",
123140
),
124141
pytest.param(
@@ -135,6 +152,15 @@ def test_handles_filename(capsys: Any, filename: str) -> None:
135152
"invalid 'strict_settings': the setting must be a boolean",
136153
id="invalid strict_settings type",
137154
),
155+
pytest.param(
156+
"""
157+
[tool.django-stubs]
158+
django_settings_module = "some.module"
159+
strict_model_abstract_attrs = "a"
160+
""",
161+
"invalid 'strict_model_abstract_attrs': the setting must be a boolean",
162+
id="invalid strict_model_abstract_attrs type",
163+
),
138164
],
139165
)
140166
def test_toml_misconfiguration_handling(capsys: Any, config_file_contents: str, message_part: str) -> None:

0 commit comments

Comments
 (0)