Django application import and missed class_prepared signals

Due to opaque application loading semantics within the Django i18n code, several model classes may be imported before a class_prepared signal listener connects, resulting in missing the signal entirely. Specifically, connecting to this signal from an application which appears first in INSTALLED_APPS, does not guarantee that it will execute when a class is first imported by any of the subsequent applications.

A class_prepared signal is dispatched when the model class is instantiated for the first time; this happens inside class Model's metaclass at django.db.models.base: 257:

1 signals.class_prepared.send(sender=cls)

Therefore, we would expect that connecting to this signal from the of an app which appears first in the INSTALLED_APPS list, would ensure that our listener receives the signal for all Models loaded in the remainder apps (following is the beginning of a typical such list):

 2   'class_prepared_listener_app',
 3   'django.contrib.auth',
 4   'django.contrib.contenttypes',
 5   'django.contrib.sessions',
 6   'django.contrib.sites',
 7   'django.contrib.messages',
 8   'django.contrib.admin',
 9   ...
10 )

However, this is not (always) the case. Tracing, for example, shell execution, we find that 45:

1 from django.db.models.loading import get_models
2 loaded_models = get_models()

requests all models from the app cache which first calls _populate to initialize itself at django.db.models.loading: 167:

1 self._populate()

which loads all apps into the cache the first time it's called and does nothing for subsequent calls at django.db.models.loading: 58:

1 for app_name in settings.INSTALLED_APPS:
2     if app_name in self.handled:
3         continue
4     self.load_app(app_name, True)
5 if not self.nesting_level:
6     for app_name in self.postponed:
7         self.load_app(app_name)
8     self.loaded = True

The apps are loaded in _populate in the order they're listed in INSTALLED_APPS; some apps can be postponed though if they can't be imported at the time of traversal at django.db.models.loading: 77:

 1 try:
 2     models = import_module('.models', app_name)
 3 except ImportError:
 4     self.nesting_level -= 1
 5     # If the app doesn't have a models module, we can just ignore the
 6     # ImportError and return no models for it.
 7     if not module_has_submodule(app_module, 'models'):
 8         return None
 9     # But if the app does have a models module, we need to figure out
10     # whether to suppress or propagate the error. If can_postpone is
11     # True then it may be that the package is still being imported by
12     # Python and the models module isn't available yet. So we add the
13     # app to the postponed list and we'll try it again after all the
14     # recursion has finished (in populate). If can_postpone is False
15     # then it's time to raise the ImportError.
16     else:
17         if can_postpone:
18             self.postponed.append(app_name)
19             return None
20         else:
21             raise

A print statement in _populate indicates that no apps are postponed and that all apps are, in fact, loaded in sequence. Furthermore the signal gets connected before any other apps are loaded. The output of shell with several print statements:

Signal connected.

Adding a print statement in the Model's metaclass indicates that some classes are instantiated before their owner apps are loaded:

Preparing class 'django.contrib.contenttypes.models.ContentType'
Preparing class 'django.contrib.auth.models.Permission'
Preparing class 'django.contrib.auth.models.Group_permissions'
Preparing class 'django.contrib.auth.models.Group'
Preparing class 'django.contrib.auth.models.User_user_permissions'
Preparing class 'django.contrib.auth.models.User_groups'
Preparing class 'django.contrib.auth.models.User'
Preparing class 'django.contrib.auth.models.Message'
Preparing class 'django.contrib.sites.models.Site'
Signal connected.
Preparing class 'django.contrib.sessions.models.Session'
Preparing class 'django.contrib.admin.models.LogEntry'

Further snooping shows that these classes are instantiated by translation-related preparation code, before the shell command is properly handled, at 202:

1 # Switch to English, because creates database content
2 # like permissions, and those shouldn't contain any translations.
3 # But only do this if we can assume we have a working settings file,
4 # because django.utils.translation requires settings.
5 if self.can_import_settings:
6     try:
7         from django.utils import translation
8         translation.activate('en-us')

Models are instantiated inside the last call to activate() which eventually calls translation() at django.utils.translation.trans_real: 161:

1 for appname in reversed(settings.INSTALLED_APPS):
2     app = import_module(appname)
3     apppath = os.path.join(os.path.dirname(app.__file__), 'locale')
5     if os.path.isdir(apppath):
6         res = _merge(apppath)

From the above, it is straightforward to see that by removing the admin and auth apps, or by placing our app after the admin and auth apps, our signal connects before any Models are loaded. Neither of these two "solutions" however appear robust or welcome.

It should be noted that, because the call to translation.activate() is being made inside the execute method of the base class from which all management commands derive, this behaviour is to be expected when Django starts up through commands other than shell.

Admittedly, the official Django documentation for this signal states "Django uses this signal internally; it's not generally used in third-party applications." however, I strongly feel that Django would benefit from a formal and deterministic load order for INSTALLED_APPS.


comments powered by Disqus