Skip to content
Draft
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
117 changes: 110 additions & 7 deletions mypy_django_plugin/django/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,117 @@ def __init__(self, django_settings_module: str) -> None:
self.django_settings_module = django_settings_module

apps, settings = initialize_django(self.django_settings_module)
self.apps_registry = apps
self.settings = settings
self._apps_registry = apps
self._settings = settings

# ---- Registry wrappers ----

def get_model_class_by_label(self, label: str) -> type[Model] | None:
try:
return self._apps_registry.get_model(label)
except LookupError:
return None

def is_app_installed(self, dotted_or_label: str) -> bool:
return self._apps_registry.is_installed(dotted_or_label)

# ---- Settings wrappers ----

def get_setting(self, name: str, default: Any = None) -> Any:
return getattr(self._settings, name, default)

def has_setting(self, name: str) -> bool:
return hasattr(self._settings, name)

@cached_property
def auth_user_model_label(self) -> str:
return self._settings.AUTH_USER_MODEL

@cached_property
def default_auto_field_path(self) -> str:
return self._settings.DEFAULT_AUTO_FIELD
Comment on lines +135 to +147

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is odd:

I think we should remove the cached_property, looks like premature optimization.
If settings lookup are expensive, then we should be doing caching at the get_setting layer, and if they are not, we should not do caching


# ---- Single-model meta wrappers ----

def get_field_on_model(self, model_cls: type[Model], field_name: str) -> Field[Any, Any] | ForeignObjectRel | None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

naming is not super consistant, get_field_on_model but get_auto_field get_pk_field later.

Again this is a bit confusing on usage, maybe use somthing like:

  • get_model_field
  • get_model_auto_field
  • get_model_pk_field
  • ...

try:
return model_cls._meta.get_field(field_name)
except FieldDoesNotExist:
return None

def is_model_abstract(self, model_cls: type[Model]) -> bool:
return model_cls._meta.abstract
Comment on lines +157 to +158

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of these are static (they don't use self) maybe add @staticmethod to these ?

You can enable the ruff rule plr6301 to catch them

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's please model our APIs without @staticmethod :)
This way we have more flexibility to use self, when we would need it. Moreover, we don't want to provide DjangoContext.is_model_abstract(model) API.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with @sobolevn here, the fact that they don't use self is just accidental here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But they will never need self, they don't need coupling with django_context.
I'm not sure I really see the advantage versus regular helper functions, or is it that you explicitely want to make them less easy to use in other contexts ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not if all they do is access _meta on the model class, but if we move forward with the hygienic plugin roadmap as I see it, they will probably read from an internal cache or maybe even rpc to a separate process. They will also not take a type[Model] but a pure mypy type but that's a refactor for later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main point of this phase is to surface what queries we are asking from the Django runtime and consolidating them in a single place so we can design a better abstraction.


def get_proxy_target(self, model_cls: type[Model]) -> type[Model] | None:
return model_cls._meta.proxy_for_model

def get_auto_field(self, model_cls: type[Model]) -> Field[Any, Any] | None:
return model_cls._meta.auto_field

def get_pk_field_or_none(self, model_cls: type[Model]) -> Field[Any, Any] | None:
# Non-raising parallel of `get_primary_key_field`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not correct, get_primary_key_field is trying to the same thing in a different way and the raise ValueError seem to be to satisfy the typechecker mostly. Otherwise it's a crash, we don't seem to catch the possible valueerror anywhere.

The typing also suggest this can only return Field, never None

class Options(Generic[_M]):
    pk: Field

We should probably investigate django runtime for this and remove one of the two helpers

return model_cls._meta.pk

def get_pk_fields(self, model_cls: type[Model]) -> tuple[Field[Any, Any], ...]:
# Composite-PK ready (Django 5.2+); falls back to the single PK on older Django.
pk_fields = getattr(model_cls._meta, "pk_fields", None)
if pk_fields is not None:
return tuple(pk_fields)
pk = self.get_pk_field_or_none(model_cls)
return (pk,) if pk is not None else ()

def get_model_parents(self, model_cls: type[Model]) -> Sequence[type[Model]]:
# Wraps `_meta.all_parents` (Django 5.2+) / `_meta.get_parent_list()` for older Django.
opts = model_cls._meta
parents = getattr(opts, "all_parents", None)
if parents is not None:
return list(parents)
return opts.get_parent_list()

def iter_reverse_relations(self, model_cls: type[Model]) -> Iterator[ForeignObjectRel]:
# Public-API equivalent of `_meta.related_objects` (private API).
# Matches `include_hidden=True` to preserve behaviour for hidden M2M etc.
for field in model_cls._meta.get_fields(include_hidden=True):
if isinstance(field, ForeignObjectRel):
yield field
Comment on lines +186 to +191

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation changed, is it expected ?
I think it's better to use an internal api than duplicating one that will drift silently.


# ---- Manager wrappers ----

def iter_managers(self, model_cls: type[Model]) -> Iterator[tuple[str, models.Manager[Any]]]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design question: is it fine for a separate mypy plugin to return models.Manager? Can we theoretically construct a manager with importing Django and app models?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most likely not, but the intent of this phase is to make those leaks more visible via the single DjangoContext chokepoint, so we can later refactor the API to avoid returning live Django objects.

yield from model_cls._meta.managers_map.items()

def get_default_manager_class(self, model_cls: type[Model]) -> type[models.Manager[Any]]:
return model_cls._meta.default_manager.__class__ # type: ignore[return-value]

# ---- Query helpers ----

def resolve_names_to_path(self, model_cls: type[Model], parts: Sequence[str]) -> tuple[_AnyField, Sequence[str]]:
"""Wrap `Query(model).names_to_path(parts, _meta)` so callers don't touch `_meta`.

Returns `(final_field, remainder)`. Raises Django's `FieldError` for the caller
to translate into a user-facing diagnostic.
"""
_, final_field, _, remainder = Query(model_cls).names_to_path(parts, model_cls._meta)
return final_field, remainder

def iter_select_related_choices(self, model_cls: type[Model]) -> Iterator[str]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only use it in _get_select_related_field_choices, does it really make sense to have this intermediary version ?

What about just having a DjagnoContext.get_select_related_choices hook that replaces both ?

"""Names valid for `QuerySet.select_related(...)` on this model.

Mirrors Django's `SQLCompiler.get_related_selections._get_field_choices`:
forward FK/O2O field names plus reverse-unique relation query names.
"""
for field in model_cls._meta.fields:
if field.is_relation:
yield field.name
for rel in model_cls._meta.related_objects:
if rel.field.unique:
yield rel.field.related_query_name()

@cached_property
def model_modules(self) -> dict[str, dict[str, type[Model]]]:
"""All modules that contain Django models."""
modules: dict[str, dict[str, type[Model]]] = defaultdict(dict)
for concrete_model_cls in self.apps_registry.get_models(include_auto_created=True, include_swapped=True):
for concrete_model_cls in self._apps_registry.get_models(include_auto_created=True, include_swapped=True):
modules[concrete_model_cls.__module__][concrete_model_cls.__name__] = concrete_model_cls
# collect abstract=True models
for model_cls in concrete_model_cls.mro()[1:]:
Expand Down Expand Up @@ -197,7 +300,7 @@ def get_primary_key_field(self, model_cls: type[Model]) -> Field[Any, Any]:
raise ValueError("No primary key defined")

def get_expected_types(self, api: TypeChecker, model_cls: type[Model], *, method: str) -> dict[str, MypyType]:
contenttypes_in_apps = self.apps_registry.is_installed("django.contrib.contenttypes")
contenttypes_in_apps = self._apps_registry.is_installed("django.contrib.contenttypes")

expected_types = {}
# add pk if not abstract=True
Expand Down Expand Up @@ -272,7 +375,7 @@ def get_expected_types(self, api: TypeChecker, model_cls: type[Model], *, method

@cached_property
def all_registered_model_classes(self) -> set[type[models.Model]]:
model_classes = self.apps_registry.get_models()
model_classes = self._apps_registry.get_models()

all_model_bases = set()
for model_cls in model_classes:
Expand Down Expand Up @@ -387,7 +490,7 @@ def get_field_related_model_cls(self, field: RelatedField[Any, Any] | ForeignObj
raise UnregisteredModelError
else:
try:
related_model_cls = self.apps_registry.get_model(related_model_cls)
related_model_cls = self._apps_registry.get_model(related_model_cls)
except LookupError as e:
raise UnregisteredModelError from e

Expand Down Expand Up @@ -577,4 +680,4 @@ def resolve_f_expression_type(self, f_expression_type: Instance) -> ProperType:

@cached_property
def is_contrib_auth_installed(self) -> bool:
return "django.contrib.auth" in self.settings.INSTALLED_APPS
return "django.contrib.auth" in self._settings.INSTALLED_APPS
6 changes: 3 additions & 3 deletions mypy_django_plugin/lib/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,8 +606,8 @@ def resolve_string_attribute_value(attr_expr: Expression, django_context: Django
if isinstance(attr_expr, MemberExpr):
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[no-any-return]
if django_context.has_setting(member_name):
return django_context.get_setting(member_name) # type: ignore[no-any-return]
Comment on lines +609 to +610

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if django_context.has_setting(member_name):
return django_context.get_setting(member_name) # type: ignore[no-any-return]
if (setting_value := django_context.get_setting(member_name)):
return setting_value # type: ignore[no-any-return]

return None


Expand Down Expand Up @@ -736,7 +736,7 @@ def get_model_from_expression(
and isinstance(expr.expr, NameExpr)
and f"{expr.expr.fullname}.{expr.name}" == fullnames.AUTH_USER_MODEL_FULLNAME
):
lazy_reference = django_context.settings.AUTH_USER_MODEL
lazy_reference = django_context.auth_user_model_label

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels weird to me to stuff everything on django_context, django_context.settings.AUTH_USER_MODEL is a lot more clear to me about what we are fetching than this random attribute


if lazy_reference is not None:
model_info = resolve_lazy_reference(lazy_reference, api=api, django_context=django_context, ctx=expr)
Expand Down
15 changes: 7 additions & 8 deletions mypy_django_plugin/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,12 @@ def get_additional_deps(self, file: MypyFile) -> list[tuple[int, str, int]]:

# for `get_user_model()`
if file.fullname == "django.contrib.auth" or file.fullname in {"django.http", "django.http.request"}:
auth_user_model_name = self.django_context.settings.AUTH_USER_MODEL
try:
auth_user_module = self.django_context.apps_registry.get_model(auth_user_model_name).__module__
except LookupError:
auth_user_model_name = self.django_context.auth_user_model_label
auth_user_model = self.django_context.get_model_class_by_label(auth_user_model_name)
if auth_user_model is None:
# get_user_model() model app is not installed
return []
return [self._new_dependency(auth_user_module), self._new_dependency("django_stubs_ext")]
return [self._new_dependency(auth_user_model.__module__), self._new_dependency("django_stubs_ext")]

# ensure that all mentions to='someapp.SomeModel' are loaded with corresponding related Fields
defined_model_classes = self.django_context.model_modules.get(file.fullname)
Expand All @@ -122,8 +121,8 @@ def get_additional_deps(self, file: MypyFile) -> list[tuple[int, str, int]]:
for field in itertools.chain(
# forward relations
self.django_context.get_model_related_fields(model_class),
# reverse relations - `related_objects` is private API (according to docstring)
model_class._meta.related_objects,
# reverse relations
self.django_context.iter_reverse_relations(model_class),
):
try:
related_model_cls = self.django_context.get_field_related_model_cls(field) # type: ignore[arg-type]
Expand Down Expand Up @@ -349,7 +348,7 @@ 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 = {
"AUTH_USER_MODEL": self.django_context.settings.AUTH_USER_MODEL,
"AUTH_USER_MODEL": self.django_context.auth_user_model_label,
"django_version": importlib.metadata.version("django"),
"django_stubs_version": importlib.metadata.version("django-stubs"),
}
Expand Down
2 changes: 1 addition & 1 deletion mypy_django_plugin/transformers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def get_user_model(ctx: AnalyzeTypeContext, django_context: DjangoContext) -> My
if not django_context.is_contrib_auth_installed:
return _get_abstract_base_user(ctx.api.api)

auth_user_model = django_context.settings.AUTH_USER_MODEL
auth_user_model = django_context.auth_user_model_label
model_info = helpers.resolve_lazy_reference(
auth_user_model, api=ctx.api.api, django_context=django_context, ctx=ctx.context
)
Expand Down
14 changes: 5 additions & 9 deletions mypy_django_plugin/transformers/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from typing import TYPE_CHECKING, Any, NamedTuple, cast

from django.core.exceptions import FieldDoesNotExist
from django.db.models.fields import AutoField, Field
from django.db.models.fields.related import RelatedField
from mypy.nodes import AssignmentStmt, NameExpr, TypeInfo
Expand Down Expand Up @@ -42,10 +41,7 @@ def _get_current_field_from_assignment(
if model_cls is None:
return None

try:
return model_cls._meta.get_field(field_name)
except FieldDoesNotExist:
return None
return django_context.get_field_on_model(model_cls, field_name)


def reparametrize_related_field_type(related_field_type: Instance, set_type: MypyType, get_type: MypyType) -> Instance:
Expand All @@ -72,11 +68,11 @@ def fill_descriptor_types_for_related_field(ctx: FunctionContext, django_context

# self reference with abstract=True on the model where ForeignKey is defined
current_model_cls = current_field.model
if current_model_cls._meta.abstract and current_model_cls == related_model_cls:
if django_context.is_model_abstract(current_model_cls) and current_model_cls == related_model_cls:
# for all derived non-abstract classes, set variable with this name to
# __get__/__set__ of ForeignKey of derived model
for model_cls in django_context.all_registered_model_classes:
if issubclass(model_cls, current_model_cls) and not model_cls._meta.abstract:
if issubclass(model_cls, current_model_cls) and not django_context.is_model_abstract(model_cls):
derived_model_info = helpers.lookup_class_typeinfo(helpers.get_typechecker_api(ctx), model_cls)
if derived_model_info is not None:
fk_ref_type = Instance(derived_model_info, [])
Expand All @@ -87,8 +83,8 @@ def fill_descriptor_types_for_related_field(ctx: FunctionContext, django_context

related_model = related_model_cls
related_model_to_set = related_model_cls
if related_model_to_set._meta.proxy_for_model is not None:
related_model_to_set = related_model_to_set._meta.proxy_for_model
if (proxy_target := django_context.get_proxy_target(related_model_to_set)) is not None:
related_model_to_set = proxy_target

typechecker_api = helpers.get_typechecker_api(ctx)

Expand Down
13 changes: 6 additions & 7 deletions mypy_django_plugin/transformers/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from typing import TYPE_CHECKING

from django.core.exceptions import FieldDoesNotExist
from mypy.types import AnyType, Instance, TypeOfAny, get_proper_type
from mypy.types import Type as MypyType

Expand Down Expand Up @@ -31,12 +30,12 @@ def return_proper_field_type_from_get_field(ctx: MethodContext, django_context:
if (django_model := DjangoModel.from_model_type(model_type, django_context)) is None:
return ctx.default_return_type

try:
field = django_model.cls._meta.get_field(field_name)
if field_info := helpers.lookup_class_typeinfo(helpers.get_typechecker_api(ctx), field.__class__):
return Instance(field_info, [])
except FieldDoesNotExist as e:
ctx.api.fail(str(e), ctx.context)
field = django_context.get_field_on_model(django_model.cls, field_name)
if field is None:
ctx.api.fail(f"{django_model.cls.__name__} has no field named {field_name!r}", ctx.context)
return AnyType(TypeOfAny.from_error)

if field_info := helpers.lookup_class_typeinfo(helpers.get_typechecker_api(ctx), field.__class__):
return Instance(field_info, [])

return ctx.default_return_type
14 changes: 7 additions & 7 deletions mypy_django_plugin/transformers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ def run(self) -> None:
class AddDefaultPrimaryKey(ModelClassInitializer):
@override
def run_with_model_cls(self, model_cls: type[Model]) -> None:
auto_field = model_cls._meta.auto_field
auto_field = self.django_context.get_auto_field(model_cls)
if auto_field:
self.create_autofield(
auto_field=auto_field,
Expand Down Expand Up @@ -309,8 +309,8 @@ class AddPrimaryKeyAlias(AddDefaultPrimaryKey):
@override
def run_with_model_cls(self, model_cls: type[Model]) -> None:
# We also need to override existing `pk` definition from `stubs`:
auto_field = model_cls._meta.pk
if auto_field is not None: # type: ignore[comparison-overlap]
auto_field = self.django_context.get_pk_field_or_none(model_cls)
if auto_field is not None:
self.create_autofield(
auto_field=auto_field,
dest_name="pk",
Expand All @@ -336,7 +336,7 @@ def run_with_model_cls(self, model_cls: type[Model]) -> None:
self.add_new_var_to_model_class(field.attname, AnyType(TypeOfAny.explicit))
continue

if related_model_cls._meta.abstract:
if self.django_context.is_model_abstract(related_model_cls):
continue

rel_target_field = self.django_context.get_related_target_field(related_model_cls, field)
Expand Down Expand Up @@ -383,7 +383,7 @@ def run_with_model_cls(self, model_cls: type[Model]) -> None:
manager_info: TypeInfo | None

incomplete_manager_defs = set()
for manager_name, manager in model_cls._meta.managers_map.items():
for manager_name, manager in self.django_context.iter_managers(model_cls):
manager_node = self.model_classdef.info.get(manager_name)
manager_fullname = helpers.get_class_fullname(manager.__class__)
manager_info = self.lookup_manager(manager_fullname, manager)
Expand Down Expand Up @@ -483,7 +483,7 @@ def run_with_model_cls(self, model_cls: type[Model]) -> None:
if "_default_manager" in self.model_classdef.info.names:
return None

default_manager_cls = model_cls._meta.default_manager.__class__
default_manager_cls = self.django_context.get_default_manager_class(model_cls)
default_manager_fullname = helpers.get_class_fullname(default_manager_cls)

try:
Expand Down Expand Up @@ -767,7 +767,7 @@ def run(self) -> None:

@cached_property
def default_pk_instance(self) -> Instance:
default_pk_field = self.lookup_typeinfo(self.django_context.settings.DEFAULT_AUTO_FIELD)
default_pk_field = self.lookup_typeinfo(self.django_context.default_auto_field_path)
if default_pk_field is None:
raise helpers.IncompleteDefnException()
return Instance(
Expand Down
Loading
Loading