Skip to content

Commit 8b98a37

Browse files
asottile-sentrysobolevnpre-commit-ci[bot]
authored
unfork (#42)
* Revert "don't remove objects / DoesNotExist / etc. from model classes (#2)" This reverts commit 44e609b. * Add `strict_model_abstract_attrs` setting to allow `models.Model.objects` access (typeddjango#2830) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: anthony sottile <[email protected]> --------- Co-authored-by: sobolevn <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 3f7316c commit 8b98a37

File tree

8 files changed

+297
-81
lines changed

8 files changed

+297
-81
lines changed

.github/workflows/test.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,35 @@ jobs:
4949
- name: Run mypy on the test cases
5050
run: uv run mypy --strict tests
5151

52+
test:
53+
timeout-minutes: 15
54+
runs-on: ubuntu-latest
55+
strategy:
56+
matrix:
57+
python-version: ['3.10', '3.11', '3.12', '3.13']
58+
shard: [0, 1, 2, 3]
59+
fail-fast: false
60+
steps:
61+
- uses: actions/checkout@v5
62+
- name: Setup system dependencies
63+
run: |
64+
sudo apt-get update
65+
sudo apt-get install binutils libproj-dev gdal-bin
66+
- name: Install uv
67+
uses: astral-sh/setup-uv@v6
68+
- name: Set up Python ${{ matrix.python-version }}
69+
uses: actions/setup-python@v6
70+
with:
71+
python-version: ${{ matrix.python-version }}
72+
73+
- name: Install dependencies
74+
run: uv sync --no-dev --group tests
75+
76+
# Must match `shard` definition in the test matrix:
77+
- name: Run pytest tests
78+
run: uv run pytest --num-shards=4 --shard-id=${{ matrix.shard }} -n auto tests
79+
80+
5281
stubtest:
5382
timeout-minutes: 10
5483
runs-on: ubuntu-latest

README.md

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

147147
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).
148148

149+
- `strict_model_abstract_attrs`, a boolean, default `true`.
150+
151+
Set to `false` if you want to keep `.objects`, `.DoesNotExist`,
152+
and `.MultipleObjectsReturned` attributes on `models.Model` type.
153+
[See here why](https://github.com/typeddjango/django-stubs?tab=readme-ov-file#how-to-use-typemodel-annotation-with-objects-attribute)
154+
this is dangerous to do by default.
155+
156+
149157
## FAQ
150158

151159
### Is this an official Django project?
@@ -389,6 +397,11 @@ def assert_zero_count(model_type: type[models.Model]) -> None:
389397
assert model_type._default_manager.count() == 0
390398
```
391399

400+
Configurable with `strict_model_abstract_attrs = false`
401+
to skip removing `.objects`, `.DoesNotExist`, and `.MultipleObjectsReturned`
402+
attributes from `model.Model` if you are using our mypy plugin.
403+
404+
Use this setting on your own risk, because it can hide valid errors.
392405

393406
### How to type a custom `models.Field`?
394407

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: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
resolve_manager_method,
4646
)
4747
from mypy_django_plugin.transformers.models import (
48+
MetaclassAdjustments,
4849
handle_annotated_type,
4950
process_model_class,
5051
set_auth_user_model_boolean_fields,
@@ -211,7 +212,7 @@ def get_customize_class_mro_hook(self, fullname: str) -> Callable[[ClassDefConte
211212

212213
def get_metaclass_hook(self, fullname: str) -> Callable[[ClassDefContext], None] | None:
213214
if fullname == fullnames.MODEL_METACLASS_FULLNAME:
214-
return None
215+
return partial(MetaclassAdjustments.adjust_model_class, plugin_config=self.plugin_config)
215216
return None
216217

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

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

301300

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)