Search code examples
pythondjangorecursionfactory-boy

Using a composition pattern leads to infinite recursion with Django Factory Boy?


I have a model Profile which has a one-to-one field with a User:

from django.db import models


class Profile(models.Model):
    user = models.OneToOneField(User, related_name='user_profile', on_delete=models.CASCADE)

I'm trying to update the User model with a __getattr__ method that delegates to the Profile model, similar to http://blog.thedigitalcatonline.com/blog/2014/08/20/python-3-oop-part-3-delegation-composition-and-inheritance/#enter-the-composition:

from django.contrib.auth.models import User


def user__getattr__(self, attr):
    return getattr(self.user_profile, attr)


User.add_to_class('__getattr__', user__getattr__)

This seems to work as expected. For example, one of the fields defined on the Profile model is a timezone, and now I can access it like

In [4]: from lucy_web.models import *

In [5]: User.objects.first().timezone
Out[5]: 'America/Los_Angeles'

The problem occurs when I try to generate users using factory_boy. I have a UserFactory with a RelatedFactory referring to the ProfileFactory:

import factory
from lucy_web.models import User
from .profile_factory import ProfileFactory



class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = User

    user_profile = factory.RelatedFactory(ProfileFactory, 'user')

    @classmethod
    def _create(cls, model_class, *args, **kwargs):
        """Override the default ``_create`` with create_user."""
        manager = cls._get_manager(model_class)
        # The default would use ``manager.create(*args, **kwargs)``
        return manager.create_user(*args, **kwargs)

The ProfileFactory is quite simple, similar to

class ProfileFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Profile

The problem is that if I now try to create a user using the UserFactory, I get an infinite recursion error:

(lucy-web-CVxkrCFK) bash-3.2$ python manage.py shell
Python 3.7.0 (v3.7.0:1bf9cc5093, Jun 26 2018, 23:26:24) 
Type 'copyright', 'credits' or 'license' for more information
IPython 6.4.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: from lucy_web.test_factories import *

In [2]: UserFactory()
(0.034) SELECT t.oid, typarray FROM pg_type t JOIN pg_namespace ns ON typnamespace = ns.oid WHERE typname = 'hstore'; args=None
(0.001) SELECT typarray FROM pg_type WHERE typname = 'citext'; args=None
(0.073) INSERT INTO "auth_user" ("password", "last_login", "is_superuser", "username", "first_name", "last_name", "email", "is_staff", "is_active", "date_joined") VALUES ('pbkdf2_sha256$100000$QuDq3QCL8zp7$Ru1O4K6I/KaMZZXDj2WVY8TV8/7rQNIL9OYL+1hWvTI=', NULL, false, '[email protected]', 'Jeffrey', 'Kim', '[email protected]', false, true, '2018-07-31T21:43:35.374902+00:00'::timestamptz) RETURNING "auth_user"."id"; args=('pbkdf2_sha256$100000$QuDq3QCL8zp7$Ru1O4K6I/KaMZZXDj2WVY8TV8/7rQNIL9OYL+1hWvTI=', None, False, '[email protected]', 'Jeffrey', 'Kim', '[email protected]', False, True, datetime.datetime(2018, 7, 31, 21, 43, 35, 374902, tzinfo=<UTC>))
(0.002) SELECT "lucy_web_profile"."id", "lucy_web_profile"."created_at", "lucy_web_profile"."updated_at", "lucy_web_profile"."user_id", "lucy_web_profile"."using_app", "lucy_web_profile"."phone", "lucy_web_profile"."phone_country", "lucy_web_profile"."street", "lucy_web_profile"."street2", "lucy_web_profile"."city", "lucy_web_profile"."state", "lucy_web_profile"."country", "lucy_web_profile"."zip_code", "lucy_web_profile"."timezone", "lucy_web_profile"."phone_type", "lucy_web_profile"."alternate_email", "lucy_web_profile"."activation_code" FROM "lucy_web_profile" WHERE "lucy_web_profile"."user_id" = 2159; args=(2159,)
---------------------------------------------------------------------------
RecursionError                            Traceback (most recent call last)
<ipython-input-2-96e87501585e> in <module>()
----> 1 UserFactory()

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/factory/base.py in __call__(cls, **kwargs)
     44             return cls.build(**kwargs)
     45         elif cls._meta.strategy == enums.CREATE_STRATEGY:
---> 46             return cls.create(**kwargs)
     47         elif cls._meta.strategy == enums.STUB_STRATEGY:
     48             return cls.stub(**kwargs)

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/factory/base.py in create(cls, **kwargs)
    561     def create(cls, **kwargs):
    562         """Create an instance of the associated class, with overriden attrs."""
--> 563         return cls._generate(enums.CREATE_STRATEGY, kwargs)
    564 
    565     @classmethod

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/factory/base.py in _generate(cls, strategy, params)
    498 
    499         step = builder.StepBuilder(cls._meta, params, strategy)
--> 500         return step.build()
    501 
    502     @classmethod

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/factory/builder.py in build(self, parent_step, force_sequence)
    277             step=step,
    278             args=args,
--> 279             kwargs=kwargs,
    280         )
    281 

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/factory/base.py in instantiate(self, step, args, kwargs)
    312             return self.factory._build(model, *args, **kwargs)
    313         elif step.builder.strategy == enums.CREATE_STRATEGY:
--> 314             return self.factory._create(model, *args, **kwargs)
    315         else:
    316             assert step.builder.strategy == enums.STUB_STRATEGY

~/Documents/Dev/lucy2/lucy-web/lucy_web/test_factories/user_factory.py in _create(cls, model_class, *args, **kwargs)
     48         manager = cls._get_manager(model_class)
     49         # The default would use ``manager.create(*args, **kwargs)``
---> 50         return manager.create_user(*args, **kwargs)

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/django/contrib/auth/models.py in create_user(self, username, email, password, **extra_fields)
    148         extra_fields.setdefault('is_staff', False)
    149         extra_fields.setdefault('is_superuser', False)
--> 150         return self._create_user(username, email, password, **extra_fields)
    151 
    152     def create_superuser(self, username, email, password, **extra_fields):

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/django/contrib/auth/models.py in _create_user(self, username, email, password, **extra_fields)
    142         user = self.model(username=username, email=email, **extra_fields)
    143         user.set_password(password)
--> 144         user.save(using=self._db)
    145         return user
    146 

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/django/contrib/auth/base_user.py in save(self, *args, **kwargs)
     71 
     72     def save(self, *args, **kwargs):
---> 73         super().save(*args, **kwargs)
     74         if self._password is not None:
     75             password_validation.password_changed(self._password, self)

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/django/db/models/base.py in save(self, force_insert, force_update, using, update_fields)
    727 
    728         self.save_base(using=using, force_insert=force_insert,
--> 729                        force_update=force_update, update_fields=update_fields)
    730     save.alters_data = True
    731 

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/django/db/models/base.py in save_base(self, raw, force_insert, force_update, using, update_fields)
    767             post_save.send(
    768                 sender=origin, instance=self, created=(not updated),
--> 769                 update_fields=update_fields, raw=raw, using=using,
    770             )
    771 

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/django/dispatch/dispatcher.py in send(self, sender, **named)
    176         return [
    177             (receiver, receiver(signal=self, sender=sender, **named))
--> 178             for receiver in self._live_receivers(sender)
    179         ]
    180 

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/django/dispatch/dispatcher.py in <listcomp>(.0)
    176         return [
    177             (receiver, receiver(signal=self, sender=sender, **named))
--> 178             for receiver in self._live_receivers(sender)
    179         ]
    180 

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/auditlog/receivers.py in log_create(sender, instance, created, **kwargs)
     14     """
     15     if created:
---> 16         changes = model_instance_diff(None, instance)
     17 
     18         log_entry = LogEntry.objects.log_create(

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/auditlog/diff.py in model_instance_diff(old, new)
    133     for field in fields:
    134         old_value = get_field_value(old, field)
--> 135         new_value = get_field_value(new, field)
    136 
    137         if old_value != new_value:

~/.local/share/virtualenvs/lucy-web-CVxkrCFK/lib/python3.7/site-packages/auditlog/diff.py in get_field_value(obj, field)
     76     else:
     77         try:
---> 78             value = smart_text(getattr(obj, field.name, None))
     79         except ObjectDoesNotExist:
     80             value = field.default if field.default is not NOT_PROVIDED else None

~/Documents/Dev/lucy2/lucy-web/lucy_web/models/user.py in user__getattr__(self, attr)
     86     In accordance with PEP 562, we cannot name it '__getattr__' here as that refers to the module __getattr__ method.
     87     """
---> 88     return getattr(self.user_profile, attr)
     89     # if self.user_profile:
     90     #     return self.user_profile.__getattribute__(attr)

... last 1 frames repeated, from the frame below ...

~/Documents/Dev/lucy2/lucy-web/lucy_web/models/user.py in user__getattr__(self, attr)
     86     In accordance with PEP 562, we cannot name it '__getattr__' here as that refers to the module __getattr__ method.
     87     """
---> 88     return getattr(self.user_profile, attr)
     89     # if self.user_profile:
     90     #     return self.user_profile.__getattribute__(attr)

RecursionError: maximum recursion depth exceeded

I'm a bit nonplussed as to why only the UserFactory is giving rise to this error, whereas 'normal' attribute lookups seem to work fine. Any ideas on how to fix this?


Solution

  • This does not seem to be related to factory_boy: with your factories, the code is strictly equivalent to:

    user = User.objects.create_user()
    profile = Profile.objects.create(user=user)
    

    However, from your stacktrace, it seems that you have installed a signal handler and connected it to your post_save() signal (in auditlog/receivers.py).

    That signal handler seems to be computing the list of updated fields; and thus tries to access some fields of the User object. However, since the Profile has not been created yet, the calls to your custom __getattr__ fail.

    The proper way to fix this would be to alter your user__getattr__ to check properly whether self.user_profile is defined before attempting to read from it; the following is an example of code that could work in your case:

    def user__getattr__(user, attr):
        try:
            profile = user.user_profile
        except ObjectDoesNotExist:  # Generated by Django if the lookup fails
            raise
        return getattr(profile, attr)
    

    Note: although you could also "mute" the signal using factory_boy helpers, this would only hide the problem: from a database point of view, there is no guarantee that a UserProfile exists for a given User object — your code should be prepared to handle that case.