Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,35 @@ jobs:
- name: Run mypy on the test cases
run: uv run mypy --strict tests

test:
timeout-minutes: 15
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13']
shard: [0, 1, 2, 3]
fail-fast: false
steps:
- uses: actions/checkout@v5
- name: Setup system dependencies
run: |
sudo apt-get update
sudo apt-get install binutils libproj-dev gdal-bin
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: uv sync --no-dev --group tests

# Must match `shard` definition in the test matrix:
- name: Run pytest tests
run: uv run pytest --num-shards=4 --shard-id=${{ matrix.shard }} -n auto tests


stubtest:
timeout-minutes: 10
runs-on: ubuntu-latest
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,14 @@ The supported settings are:

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

- `strict_model_abstract_attrs`, a boolean, default `true`.

Set to `false` if you want to keep `.objects`, `.DoesNotExist`,
and `.MultipleObjectsReturned` attributes on `models.Model` type.
[See here why](https://github.com/typeddjango/django-stubs?tab=readme-ov-file#how-to-use-typemodel-annotation-with-objects-attribute)
this is dangerous to do by default.


## FAQ

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

Configurable with `strict_model_abstract_attrs = false`
to skip removing `.objects`, `.DoesNotExist`, and `.MultipleObjectsReturned`
attributes from `model.Model` if you are using our mypy plugin.

Use this setting on your own risk, because it can hide valid errors.

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

Expand Down
20 changes: 17 additions & 3 deletions mypy_django_plugin/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
[mypy.plugins.django-stubs]
django_settings_module = str (default: `os.getenv("DJANGO_SETTINGS_MODULE")`)
strict_settings = bool (default: true)
strict_model_abstract_attrs = bool (default: true)
...
"""
TOML_USAGE = """
Expand All @@ -26,14 +27,17 @@
[tool.django-stubs]
django_settings_module = str (default: `os.getenv("DJANGO_SETTINGS_MODULE")`)
strict_settings = bool (default: true)
strict_model_abstract_attrs = 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"
DJANGO_SETTINGS_ENV_VAR = "DJANGO_SETTINGS_MODULE"
MISSING_DJANGO_SETTINGS = f"missing required 'django_settings_module' config.\
Either specify this config or set your `{DJANGO_SETTINGS_ENV_VAR}` env var"
MISSING_DJANGO_SETTINGS = (
"missing required 'django_settings_module' config.\n"
f"Either specify this config or set your `{DJANGO_SETTINGS_ENV_VAR}` env var"
)
INVALID_BOOL_SETTING = "invalid {key!r}: the setting must be a boolean"


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


class DjangoPluginConfig:
__slots__ = ("django_settings_module", "strict_settings")
__slots__ = ("django_settings_module", "strict_settings", "strict_model_abstract_attrs")

django_settings_module: str
strict_settings: bool

Expand Down Expand Up @@ -96,6 +101,9 @@ def parse_toml_file(self, filepath: Path) -> None:
self.strict_settings = config.get("strict_settings", True)
if not isinstance(self.strict_settings, bool):
toml_exit(INVALID_BOOL_SETTING.format(key="strict_settings"))
self.strict_model_abstract_attrs = config.get("strict_model_abstract_attrs", True)
if not isinstance(self.strict_model_abstract_attrs, bool):
toml_exit(INVALID_BOOL_SETTING.format(key="strict_model_abstract_attrs"))

def parse_ini_file(self, filepath: Path) -> None:
parser = configparser.ConfigParser()
Expand Down Expand Up @@ -124,10 +132,16 @@ def parse_ini_file(self, filepath: Path) -> None:
except ValueError:
exit_with_error(INVALID_BOOL_SETTING.format(key="strict_settings"))

try:
self.strict_model_abstract_attrs = parser.getboolean(section, "strict_model_abstract_attrs", fallback=True)
except ValueError:
exit_with_error(INVALID_BOOL_SETTING.format(key="strict_model_abstract_attrs"))

def to_json(self, extra_data: dict[str, Any]) -> dict[str, Any]:
"""We use this method to reset mypy cache via `report_config_data` hook."""
return {
"django_settings_module": self.django_settings_module,
"strict_settings": self.strict_settings,
"strict_model_abstract_attrs": self.strict_model_abstract_attrs,
**dict(sorted(extra_data.items())),
}
11 changes: 5 additions & 6 deletions mypy_django_plugin/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
resolve_manager_method,
)
from mypy_django_plugin.transformers.models import (
MetaclassAdjustments,
handle_annotated_type,
process_model_class,
set_auth_user_model_boolean_fields,
Expand Down Expand Up @@ -211,7 +212,7 @@ def get_customize_class_mro_hook(self, fullname: str) -> Callable[[ClassDefConte

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

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

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


Expand Down
27 changes: 24 additions & 3 deletions mypy_django_plugin/transformers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from mypy.types import Type as MypyType
from mypy.typevars import fill_typevars, fill_typevars_with_any

from mypy_django_plugin.config import DjangoPluginConfig
from mypy_django_plugin.django.context import DjangoContext
from mypy_django_plugin.errorcodes import MANAGER_MISSING
from mypy_django_plugin.exceptions import UnregisteredModelError
Expand Down Expand Up @@ -991,13 +992,35 @@ def create_many_related_manager(self, model: Instance) -> None:

class MetaclassAdjustments(ModelClassInitializer):
@classmethod
def adjust_model_class(cls, ctx: ClassDefContext) -> None:
def adjust_model_class(cls, ctx: ClassDefContext, plugin_config: DjangoPluginConfig) -> None:
"""
For the sake of type checkers other than mypy, some attributes that are
dynamically added by Django's model metaclass has been annotated on
`django.db.models.base.Model`. We remove those attributes and will handle them
through the plugin.

Configurable with `strict_model_abstract_attrs = false` to skip removing any objects from models.

Basically, this code::

from django.db import models

def get_model_count(model_type: type[models.Model]) -> int:
return model_type.objects.count()

- Will raise an error
`"type[models.Model]" has no attribute "objects" [attr-defined]`
when `strict_model_abstract_attrs = true` (default)
- Return without any type errors
when `strict_model_abstract_attrs = false`

But, beware that the code above can fail in runtime, as mypy correctly shows.
Example: `get_model_count(models.Model)`

Turn this setting off at your own risk.
"""
if not plugin_config.strict_model_abstract_attrs:
return
if ctx.cls.fullname != fullnames.MODEL_CLASS_FULLNAME:
return

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

return

def get_exception_bases(self, name: str) -> list[Instance]:
bases = []
for model_base in self.model_classdef.info.direct_base_classes():
Expand Down
40 changes: 33 additions & 7 deletions tests/test_error_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
[mypy.plugins.django-stubs]
django_settings_module = str (default: `os.getenv("DJANGO_SETTINGS_MODULE")`)
strict_settings = bool (default: true)
strict_model_abstract_attrs = bool (default: true)
...
(django-stubs) mypy: error: {}
"""
Expand All @@ -26,6 +27,7 @@
[tool.django-stubs]
django_settings_module = str (default: `os.getenv("DJANGO_SETTINGS_MODULE")`)
strict_settings = bool (default: true)
strict_model_abstract_attrs = bool (default: true)
...
(django-stubs) mypy: error: {}
"""
Expand All @@ -49,20 +51,33 @@ def write_to_file(file_contents: str, suffix: str | None = None) -> Generator[st
),
pytest.param(
["[mypy.plugins.django-stubs]", "\tnot_django_not_settings_module = badbadmodule"],
"missing required 'django_settings_module' config.\
Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var",
(
"missing required 'django_settings_module' config.\n"
"Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var"
),
id="missing-settings-module",
),
pytest.param(
["[mypy.plugins.django-stubs]"],
"missing required 'django_settings_module' config.\
Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var",
(
"missing required 'django_settings_module' config.\n"
"Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var"
),
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",
id="invalid-strict_settings",
),
pytest.param(
[
"[mypy.plugins.django-stubs]",
"django_settings_module = some.module",
"strict_model_abstract_attrs = bad",
],
"invalid 'strict_model_abstract_attrs': the setting must be a boolean",
id="invalid-strict_model_abstract_attrs",
),
],
)
Expand Down Expand Up @@ -117,8 +132,10 @@ def test_handles_filename(capsys: Any, filename: str) -> None:
[tool.django-stubs]
not_django_not_settings_module = "badbadmodule"
""",
"missing required 'django_settings_module' config.\
Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var",
(
"missing required 'django_settings_module' config.\n"
"Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var"
),
id="missing django_settings_module",
),
pytest.param(
Expand All @@ -135,6 +152,15 @@ def test_handles_filename(capsys: Any, filename: str) -> None:
"invalid 'strict_settings': the setting must be a boolean",
id="invalid strict_settings type",
),
pytest.param(
"""
[tool.django-stubs]
django_settings_module = "some.module"
strict_model_abstract_attrs = "a"
""",
"invalid 'strict_model_abstract_attrs': the setting must be a boolean",
id="invalid strict_model_abstract_attrs type",
),
],
)
def test_toml_misconfiguration_handling(capsys: Any, config_file_contents: str, message_part: str) -> None:
Expand Down
Loading
Loading