Opened 2 days ago
Last modified 9 hours ago
#36846 new Cleanup/optimization
ContentType.get_object_for_this_type() raises inappropriate exception type for removed models
| Reported by: | Maarten ter Huurne | Owned by: | Vishy Algo |
|---|---|---|---|
| Component: | contrib.contenttypes | Version: | dev |
| Severity: | Normal | Keywords: | |
| Cc: | Maarten ter Huurne | Triage Stage: | Accepted |
| Has patch: | no | Needs documentation: | no |
| Needs tests: | no | Patch needs improvement: | no |
| Easy pickings: | no | UI/UX: | no |
Description
When asked for an object for which the model no longer exists, ContentType.get_object_for_this_type() will raise this exception:
AttributeError: 'NoneType' object has no attribute '_base_manager'
The reason for this is that the model_class() method will return None when the model cannot be found and the implementation of get_object_for_this_type() does not check for that:
return self.model_class()._base_manager.using(using).get(**kwargs)
The behavior I'd expect is that ObjectDoesNotExist would be raised, similar to the situation where the model does exist but the object does not.
I first noticed this bug in Django 4.2.27, but the latest code in Git has the same issue.
If raising ObjectDoesNotExist is indeed the desired solution, I can provide a patch for that.
Change History (6)
comment:1 by , 44 hours ago
| Owner: | set to |
|---|---|
| Status: | new → assigned |
comment:2 by , 29 hours ago
| Resolution: | → wontfix |
|---|---|
| Status: | assigned → closed |
| Type: | Bug → Cleanup/optimization |
comment:3 by , 11 hours ago
Maybe I should add some context to explain why I think this is an actual bug and not an API cleanup proposal:
We have an application where samples can be registered taken from various kinds of goods. As those goods use different models, we use GenericForeignKey to link the sample to the good being sampled. At some point, I removed the models for some types of goods that we don't need anymore, but didn't remove the old samples. Then the admin site (made using django.contrib.admin) started crashing (uncaught exception on page generation) whenever a sample linking to a removed model was being displayed in an index table, because the lookup of the GenericForeignKey hit an unhandled None model.
When get_object_for_this_type() raises ObjectDoesNotExist, the admin site will display "-" instead, which for this use case is the desired behavior. There is a subtle difference between the situation where all instances of a model have been removed and the model itself having been removed, but I'm not sure that distinction is important enough to require a separate exception. In what scenario would the caller of get_object_for_this_type() handle "model does not exist" differently from "instance does not exist"?
If you insist on having separate exception types, having get_object_for_this_type() raise LookupError without changing model_class() would also be an option, as model_class() explicitly swallows LookupError:
def model_class(self): """Return the model class for this type of content.""" try: return apps.get_model(self.app_label, self.model) except LookupError: return None
So if get_object_for_this_type() would call apps.get_model() directly instead of going through model_class(), it would raise LookupError on removed models. This would avoid a deprecation cycle. However, the admin site would also have to be updated then to catch LookupError in addition to ObjectDoesNotExist.
In any case, the current behavior of raising AttributeError seems like the worst alternative: it is not the right exception type and it's not documented either. In my opinion, any of the alternatives (get_object_for_this_type() raising ObjectDoesNotExist, get_object_for_this_type() raising LookupError, or model_class() raising LookupError) would be preferable over keeping the current behavior.
comment:4 by , 9 hours ago
Thanks for the extra details.
So if get_object_for_this_type() would call apps.get_model() directly instead of going through model_class(), it would raise LookupError on removed models. This would avoid a deprecation cycle.
This occurred to me as well, but I glossed over it because it felt like a smell showing that model_class() is not doing what it should if we have to stop delegating to it. So I was trying to weigh doing something that feels wrong against some concrete added value.
In what scenario would the caller of get_object_for_this_type() handle "model does not exist" differently from "instance does not exist"?
Well, at first blush I thought one is an expected error and the other is a programming error, so we should only catch one. We have a command to remove stale content types that I would expect to be run when models are deleted or uninstalled.
Just to be sure all can follow, I sketched out a unit test implementing your suggestion, it passes on main and fails on your proposal, but I take it you question the premise of the test:
-
django/contrib/contenttypes/models.py
diff --git a/django/contrib/contenttypes/models.py b/django/contrib/contenttypes/models.py index 1ae45dea95..71f2655bad 100644
a b 1 1 from collections import defaultdict 2 2 3 3 from django.apps import apps 4 from django.core.exceptions import ObjectDoesNotExist 4 5 from django.db import models 5 6 from django.db.models import Q 6 7 from django.utils.translation import gettext_lazy as _ … … class ContentType(models.Model): 176 177 method. The ObjectNotExist exception, if thrown, will not be caught, 177 178 so code that calls this method should catch it. 178 179 """ 179 return self.model_class()._base_manager.using(using).get(**kwargs) 180 if (model_class := self.model_class()) is None: 181 raise ObjectDoesNotExist 182 return model_class._base_manager.using(using).get(**kwargs) 180 183 181 184 def get_all_objects_for_this_type(self, **kwargs): 182 185 """ -
tests/contenttypes_tests/test_fields.py
diff --git a/tests/contenttypes_tests/test_fields.py b/tests/contenttypes_tests/test_fields.py index 76830c3af3..d772a3d717 100644
a b import json 2 2 3 3 from django.contrib.contenttypes.fields import GenericForeignKey 4 4 from django.contrib.contenttypes.prefetch import GenericPrefetch 5 from django.contrib.contenttypes.models import ContentType 5 6 from django.db import models 6 7 from django.test import TestCase 7 8 from django.test.utils import isolate_apps … … class GenericForeignKeyTests(TestCase): 26 27 ): 27 28 field.get_content_type() 28 29 30 def test_generic_relation_distinguishes_missing_object_from_missing_model(self): 31 question = Question.objects.create(text="test") 32 answer = Answer.objects.create(question=question) 33 34 answer.question.delete() 35 self.assertIsNone(answer.question) 36 37 # Start over. 38 question = Question.objects.create(text="test") 39 answer = Answer.objects.create(question=question) 40 41 # Make the content type stale. 42 ct = ContentType.objects.get_for_model(question) 43 ct.model = "badquestion" 44 ct.save() 45 46 answer = Answer.objects.get(pk=answer.pk) 47 with self.assertRaises(Exception): 48 answer.question 49 29 50 def test_get_object_cache_respects_deleted_objects(self): 30 51 question = Question.objects.create(text="Who?") 31 52 post = Post.objects.create(title="Answer", parent=question)
Wouldn't you rather have this feedback (admin crashing) instead of glossing over the data problem?
comment:5 by , 9 hours ago
| Resolution: | wontfix |
|---|---|
| Status: | closed → new |
| Triage Stage: | Unreviewed → Accepted |
In other words, I'd be happy to consider a PR that changed AttributeError to LookupError, but I don't think I'd catch it in the admin without a wider discussion.
-
django/contrib/contenttypes/models.py
diff --git a/django/contrib/contenttypes/models.py b/django/contrib/contenttypes/models.py index 1ae45dea95..cba86ac689 100644
a b 1 1 from collections import defaultdict 2 2 3 3 from django.apps import apps 4 from django.core.exceptions import ObjectDoesNotExist 4 5 from django.db import models 5 6 from django.db.models import Q 6 7 from django.utils.translation import gettext_lazy as _ … … class ContentType(models.Model): 176 177 method. The ObjectNotExist exception, if thrown, will not be caught, 177 178 so code that calls this method should catch it. 178 179 """ 179 return self.model_class()._base_manager.using(using).get(**kwargs) 180 if (model_class := self.model_class()) is None: 181 # Raise the LookupError. 182 return apps.get_model(self.app_label, self.model) 183 return model_class._base_manager.using(using).get(**kwargs) 180 184 181 185 def get_all_objects_for_this_type(self, **kwargs): 182 186 """
comment:6 by , 9 hours ago
| Summary: | ContentType.get_object_for_this_type() does not handle removed models → ContentType.get_object_for_this_type() raises inappropriate exception type for removed models |
|---|
Thanks for the proposal. An issue, however, is that the two failures would become indistinguishable. For instance, we intentionally swallow
ObjectNotFoundErrorinfetch_one():... but I doubt we want to intentionally swallow the case where there is a stale content type.
If
model_class()letLookupErrorrise instead of returningNone, we'd have something nicer, but the method is documented, so we'd need a deprecation cycle to change it.Given those reservations, I'm having a hard time seeing the value. If you think this is worth a deprecation cycle to change, please gather consensus on the Forum (internals) first. Thanks.