djangodjango-rest-frameworkpython-3.11

Django ModelDiffMixin: Maximum recursion depth exceeded


I am getting a weird error after upgrading the Django version from 4.0 to 4.2.15. The error being encountered is: RecursionError: maximum recursion depth exceeded while calling a Python object.

The ModelDiffMixin looks something like below:

from django.forms.models import model_to_dict

class ModelDiffMixin(object):

def __init__(self, *args, **kwargs):
    super(ModelDiffMixin, self).__init__(*args, **kwargs)
    self.__initial = self._dict

@property
def diff(self):
    d1 = self.__initial
    d2 = self._dict
    diffs = [(k, (v, d2[k])) for k, v in d1.items() if v != d2[k]]
    return dict(diffs)

@property
def has_changed(self):
    return bool(self.diff)

@property
def changed_fields(self):
    return self.diff.keys()

def get_field_diff(self, field_name):
    """
    Returns a diff for field if it's changed and None otherwise.
    """
    return self.diff.get(field_name, None)

def save(self, *args, **kwargs):
    """
    Saves model and set initial state.
    """
    super(ModelDiffMixin, self).save(*args, **kwargs)
    self.__initial = self._dict

@property
def _dict(self):
    return model_to_dict(self, fields=[field.name for field in
                         self._meta.fields])

Referenced from the gist here: https://gist.github.com/goloveychuk/72499a7251e070742f00

Attaching the stack trace here. I have gone through the django docs as well and it seems like accessing django fields in the init method of model might cause issues.

  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/query.py", line 122, in __iter__
    obj = model_cls.from_db(
          ^^^^^^^^^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/base.py", line 582, in from_db
    new = cls(*values)
          ^^^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/base.py", line 571, in __init__
    super().__init__()
  File "/Users/bm/Desktop/Django/bapi/core/utils.py", line 2146, in __init__
    self.__initial = self._dict
                     ^^^^^^^^^^
  File "/Users/bm/Desktop/Django/bapi/core/utils.py", line 2201, in _dict
    return model_to_dict(self, fields=[field.name for field in self._meta.fields])
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/forms/models.py", line 115, in model_to_dict
    data[f.name] = f.value_from_object(instance)
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/fields/__init__.py", line 1088, in value_from_object
    return getattr(obj, self.attname)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/query_utils.py", line 178, in __get__
    instance.refresh_from_db(fields=[field_name])
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/base.py", line 724, in refresh_from_db
    db_instance = db_instance_qs.get()
                  ^^^^^^^^^^^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/query.py", line 633, in get
    num = len(clone)
          ^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/query.py", line 380, in __len__
    self._fetch_all()
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/query.py", line 1881, in _fetch_all
    self._result_cache = list(self._iterable_class(self))
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/query.py", line 91, in __iter__
    results = compiler.execute_sql(
              ^^^^^^^^^^^^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/sql/compiler.py", line 1547, in execute_sql
    sql, params = self.as_sql()
                  ^^^^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/psqlextra/compiler.py", line 76, in as_sql
    sql, params = super().as_sql(*args, **kwargs)
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/sql/compiler.py", line 734, in as_sql
    extra_select, order_by, group_by = self.pre_sql_setup(
                                       ^^^^^^^^^^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/sql/compiler.py", line 85, in pre_sql_setup
    order_by = self.get_order_by()
               ^^^^^^^^^^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/sql/compiler.py", line 457, in get_order_by
    for expr, is_ref in self._order_by_pairs():
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/sql/compiler.py", line 339, in _order_by_pairs
    selected_exprs[expr] = pos_expr
    ~~~~~~~~~~~~~~^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/expressions.py", line 502, in __hash__
    return hash(self.identity)
                ^^^^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/utils/functional.py", line 57, in __get__
    res = instance.__dict__[self.name] = self.func(instance)
                                         ^^^^^^^^^^^^^^^^^^^
  File "/Users/bm/.virtualenvs/django4.2/lib/python3.11/site-packages/django/db/models/expressions.py", line 479, in identity
    constructor_signature = inspect.signature(self.__init__)
                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.11/3.11.9/Frameworks/Python.framework/Versions/3.11/lib/python3.11/inspect.py", line 3263, in signature
    return Signature.from_callable(obj, follow_wrapped=follow_wrapped,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.11/3.11.9/Frameworks/Python.framework/Versions/3.11/lib/python3.11/inspect.py", line 3011, in from_callable
    return _signature_from_callable(obj, sigcls=cls,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.11/3.11.9/Frameworks/Python.framework/Versions/3.11/lib/python3.11/inspect.py", line 2447, in _signature_from_callable
    _get_signature_of = functools.partial(_signature_from_callable,
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    RecursionError: maximum recursion depth exceeded while calling a Python object
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Accessing in Django models like:

class Member(ModelDiffMixin):
   class Meta:
    index_together = [['facebook_id'], ['enrollment_referrer']]
    indexes = [models.Index(fields=['-created_ts'])]

   member_name = models.CharField(max_length=100, null=True, blank=True)
   middle_name = models.CharField(max_length=100, null=True, blank=True)

There is no change made in the code and the same code seems to work with Django 4.0 but not with Django 4.2.15. Would really appreciate if someone can help out with this.

---- Edited ------

Figured out the actual cause. The stack trace was misleading, and debugged bit deeper to find the piece of code that is making this happen. The error is because of serializing model instance:

from django.core import serializers
def get_serialized_instance(instance):
    data = serializers.serialize('json', [instance])
    result = json.loads(data)[0].get('fields')
    result['id'] = instance.id
    return result

This piece of code is the actual cause of recursion. It works with Django 4.0 but not with Django 4.2.15. Wonder if Django has dropped supporting serializing model instances.


Solution

  • Figured out the issue:

    The dependency between two models caused the recursion issue.

    class Offer(A, ModelDiffMixin):
       offer_name = models.CharField(max_length=50)
       locations = models.ManyToManyField(SponsorLocation)
    
    class SponsorLocation(B, ModelDiffMixin):
       sponsor = models.ForeignKey(Sponsor, on_delete=models.CASCADE)
    

    When trying to serialize Offer model, the issue occured because of this particular line of code:

    data = serializers.serialize('json', [instance])
    

    ModelDiffMixin might be trying to track changes in the locations field by traversing the ManyToMany relationship between Offer and SponsorLocation. As it checks the changes in Offer, it is likely navigating to SponsorLocation, and since SponsorLocation also has ModelDiffMixin, it might be traversing back to Offer, causing a recursive loop.

    The solution is sort of a hack, where the locations field is excluded from the serialization process and then appended later in the result.

    def get_serialized_instance(instance):
        if type(instance) == Offer:
            fields = [
                field.name
                for field in instance._meta.get_fields()
                if field.name not in ['locations']
            ]
            data = serializers.serialize('json', [instance], fields=fields)
            result = json.loads(data)[0].get('fields')
            # Serialize the locations field separately for Offer model
            result['locations'] = list(instance.locations.values_list('id', flat=True))
        else:
            data = serializers.serialize('json', [instance])
            result = json.loads(data)[0].get('fields')
        result['id'] = instance.id
        return result