diff --git a/django/apps/config.py b/django/apps/config.py index 2728503d62..8c276d5d34 100644 --- a/django/apps/config.py +++ b/django/apps/config.py @@ -1,9 +1,13 @@ +import inspect import os +import warnings from importlib import import_module from django.core.exceptions import ImproperlyConfigured -from django.utils.module_loading import module_has_submodule +from django.utils.deprecation import RemovedInDjango41Warning +from django.utils.module_loading import import_string, module_has_submodule +APPS_MODULE_NAME = 'apps' MODELS_MODULE_NAME = 'models' @@ -83,73 +87,139 @@ class AppConfig: """ Factory that creates an app config from an entry in INSTALLED_APPS. """ + # create() eventually returns app_config_class(app_name, app_module). + app_config_class = None + app_name = None + app_module = None + + # If import_module succeeds, entry points to the app module. try: - # If import_module succeeds, entry is a path to an app module, - # which may specify an app config class with default_app_config. - # Otherwise, entry is a path to an app config class or an error. - module = import_module(entry) - - except ImportError: - # Track that importing as an app module failed. If importing as an - # app config class fails too, we'll trigger the ImportError again. - module = None - - mod_path, _, cls_name = entry.rpartition('.') - - # Raise the original exception when entry cannot be a path to an - # app config class. - if not mod_path: - raise - + app_module = import_module(entry) + except Exception: + pass else: - try: - # If this works, the app module specifies an app config class. - entry = module.default_app_config - except AttributeError: - # Otherwise, it simply uses the default app config class. - return cls(entry, module) - else: - mod_path, _, cls_name = entry.rpartition('.') - - # If we're reaching this point, we must attempt to load the app config - # class located at . - mod = import_module(mod_path) - try: - cls = getattr(mod, cls_name) - except AttributeError: - if module is None: - # If importing as an app module failed, check if the module - # contains any valid AppConfigs and show them as choices. - # Otherwise, that error probably contains the most informative - # traceback, so trigger it again. - candidates = sorted( - repr(name) for name, candidate in mod.__dict__.items() - if isinstance(candidate, type) and - issubclass(candidate, AppConfig) and - candidate is not AppConfig - ) - if candidates: - raise ImproperlyConfigured( - "'%s' does not contain a class '%s'. Choices are: %s." - % (mod_path, cls_name, ', '.join(candidates)) + # If app_module has an apps submodule that defines a single + # AppConfig subclass, use it automatically. + # To prevent this, an AppConfig subclass can declare a class + # variable default = False. + # If the apps module defines more than one AppConfig subclass, + # the default one can declare default = True. + if module_has_submodule(app_module, APPS_MODULE_NAME): + mod_path = '%s.%s' % (entry, APPS_MODULE_NAME) + mod = import_module(mod_path) + # Check if there's exactly one AppConfig candidate, + # excluding those that explicitly define default = False. + app_configs = [ + (name, candidate) + for name, candidate in inspect.getmembers(mod, inspect.isclass) + if ( + issubclass(candidate, cls) and + candidate is not cls and + getattr(candidate, 'default', True) ) - import_module(entry) + ] + if len(app_configs) == 1: + app_config_class = app_configs[0][1] + app_config_name = '%s.%s' % (mod_path, app_configs[0][0]) + else: + # Check if there's exactly one AppConfig subclass, + # among those that explicitly define default = True. + app_configs = [ + (name, candidate) + for name, candidate in app_configs + if getattr(candidate, 'default', False) + ] + if len(app_configs) > 1: + candidates = [repr(name) for name, _ in app_configs] + raise RuntimeError( + '%r declares more than one default AppConfig: ' + '%s.' % (mod_path, ', '.join(candidates)) + ) + elif len(app_configs) == 1: + app_config_class = app_configs[0][1] + app_config_name = '%s.%s' % (mod_path, app_configs[0][0]) + + # If app_module specifies a default_app_config, follow the link. + # default_app_config is deprecated, but still takes over the + # automatic detection for backwards compatibility during the + # deprecation period. + try: + new_entry = app_module.default_app_config + except AttributeError: + # Use the default app config class if we didn't find anything. + if app_config_class is None: + app_config_class = cls + app_name = entry else: - raise + message = ( + '%r defines default_app_config = %r. ' % (entry, new_entry) + ) + if new_entry == app_config_name: + message += ( + 'Django now detects this configuration automatically. ' + 'You can remove default_app_config.' + ) + else: + message += ( + "However, Django's automatic detection picked another " + "configuration, %r. You should move the default " + "config class to the apps submodule of your " + "application and, if this module defines several " + "config classes, mark the default one with default = " + "True." % app_config_name + ) + warnings.warn(message, RemovedInDjango41Warning, stacklevel=2) + entry = new_entry + app_config_class = None + + # If import_string succeeds, entry is an app config class. + if app_config_class is None: + try: + app_config_class = import_string(entry) + except Exception: + pass + # If both import_module and import_string failed, it means that entry + # doesn't have a valid value. + if app_module is None and app_config_class is None: + # If the last component of entry starts with an uppercase letter, + # then it was likely intended to be an app config class; if not, + # an app module. Provide a nice error message in both cases. + mod_path, _, cls_name = entry.rpartition('.') + if mod_path and cls_name[0].isupper(): + # We could simply re-trigger the string import exception, but + # we're going the extra mile and providing a better error + # message for typos in INSTALLED_APPS. + # This may raise ImportError, which is the best exception + # possible if the module at mod_path cannot be imported. + mod = import_module(mod_path) + candidates = [ + repr(name) + for name, candidate in inspect.getmembers(mod, inspect.isclass) + if issubclass(candidate, cls) and candidate is not cls + ] + msg = "Module '%s' does not contain a '%s' class." % (mod_path, cls_name) + if candidates: + msg += ' Choices are: %s.' % ', '.join(candidates) + raise ImportError(msg) + else: + # Re-trigger the module import exception. + import_module(entry) # Check for obvious errors. (This check prevents duck typing, but # it could be removed if it became a problem in practice.) - if not issubclass(cls, AppConfig): + if not issubclass(app_config_class, AppConfig): raise ImproperlyConfigured( "'%s' isn't a subclass of AppConfig." % entry) # Obtain app name here rather than in AppClass.__init__ to keep # all error checking for entries in INSTALLED_APPS in one place. - try: - app_name = cls.name - except AttributeError: - raise ImproperlyConfigured( - "'%s' must supply a name attribute." % entry) + if app_name is None: + try: + app_name = app_config_class.name + except AttributeError: + raise ImproperlyConfigured( + "'%s' must supply a name attribute." % entry + ) # Ensure app_name points to a valid module. try: @@ -157,12 +227,14 @@ class AppConfig: except ImportError: raise ImproperlyConfigured( "Cannot import '%s'. Check that '%s.%s.name' is correct." % ( - app_name, mod_path, cls_name, + app_name, + app_config_class.__module__, + app_config_class.__qualname__, ) ) # Entry is a path to an app config class. - return cls(app_name, app_module) + return app_config_class(app_name, app_module) def get_model(self, model_name, require_ready=True): """ diff --git a/django/contrib/admin/__init__.py b/django/contrib/admin/__init__.py index f9bf6543f9..0bcee1a578 100644 --- a/django/contrib/admin/__init__.py +++ b/django/contrib/admin/__init__.py @@ -22,6 +22,3 @@ __all__ = [ def autodiscover(): autodiscover_modules('admin', register_to=site) - - -default_app_config = 'django.contrib.admin.apps.AdminConfig' diff --git a/django/contrib/admin/apps.py b/django/contrib/admin/apps.py index 36c157683d..85e9ff830a 100644 --- a/django/contrib/admin/apps.py +++ b/django/contrib/admin/apps.py @@ -19,6 +19,8 @@ class SimpleAdminConfig(AppConfig): class AdminConfig(SimpleAdminConfig): """The default AppConfig for admin which does autodiscovery.""" + default = True + def ready(self): super().ready() self.module.autodiscover() diff --git a/django/contrib/admindocs/__init__.py b/django/contrib/admindocs/__init__.py index 733519ac54..e69de29bb2 100644 --- a/django/contrib/admindocs/__init__.py +++ b/django/contrib/admindocs/__init__.py @@ -1 +0,0 @@ -default_app_config = 'django.contrib.admindocs.apps.AdminDocsConfig' diff --git a/django/contrib/auth/__init__.py b/django/contrib/auth/__init__.py index de2aa785e6..662137e595 100644 --- a/django/contrib/auth/__init__.py +++ b/django/contrib/auth/__init__.py @@ -217,6 +217,3 @@ def update_session_auth_hash(request, user): request.session.cycle_key() if hasattr(user, 'get_session_auth_hash') and request.user == user: request.session[HASH_SESSION_KEY] = user.get_session_auth_hash() - - -default_app_config = 'django.contrib.auth.apps.AuthConfig' diff --git a/django/contrib/contenttypes/__init__.py b/django/contrib/contenttypes/__init__.py index 9789f36eff..e69de29bb2 100644 --- a/django/contrib/contenttypes/__init__.py +++ b/django/contrib/contenttypes/__init__.py @@ -1 +0,0 @@ -default_app_config = 'django.contrib.contenttypes.apps.ContentTypesConfig' diff --git a/django/contrib/flatpages/__init__.py b/django/contrib/flatpages/__init__.py index 865acd855b..e69de29bb2 100644 --- a/django/contrib/flatpages/__init__.py +++ b/django/contrib/flatpages/__init__.py @@ -1 +0,0 @@ -default_app_config = 'django.contrib.flatpages.apps.FlatPagesConfig' diff --git a/django/contrib/gis/__init__.py b/django/contrib/gis/__init__.py index 458c90eb58..e69de29bb2 100644 --- a/django/contrib/gis/__init__.py +++ b/django/contrib/gis/__init__.py @@ -1 +0,0 @@ -default_app_config = 'django.contrib.gis.apps.GISConfig' diff --git a/django/contrib/humanize/__init__.py b/django/contrib/humanize/__init__.py index 9ca1642fae..e69de29bb2 100644 --- a/django/contrib/humanize/__init__.py +++ b/django/contrib/humanize/__init__.py @@ -1 +0,0 @@ -default_app_config = 'django.contrib.humanize.apps.HumanizeConfig' diff --git a/django/contrib/messages/__init__.py b/django/contrib/messages/__init__.py index 25da09f3bf..40ba6ea6c7 100644 --- a/django/contrib/messages/__init__.py +++ b/django/contrib/messages/__init__.py @@ -1,4 +1,2 @@ from django.contrib.messages.api import * # NOQA from django.contrib.messages.constants import * # NOQA - -default_app_config = 'django.contrib.messages.apps.MessagesConfig' diff --git a/django/contrib/postgres/__init__.py b/django/contrib/postgres/__init__.py index 8723eea638..e69de29bb2 100644 --- a/django/contrib/postgres/__init__.py +++ b/django/contrib/postgres/__init__.py @@ -1 +0,0 @@ -default_app_config = 'django.contrib.postgres.apps.PostgresConfig' diff --git a/django/contrib/redirects/__init__.py b/django/contrib/redirects/__init__.py index 65364a440b..e69de29bb2 100644 --- a/django/contrib/redirects/__init__.py +++ b/django/contrib/redirects/__init__.py @@ -1 +0,0 @@ -default_app_config = 'django.contrib.redirects.apps.RedirectsConfig' diff --git a/django/contrib/sessions/__init__.py b/django/contrib/sessions/__init__.py index 0382f95d7e..e69de29bb2 100644 --- a/django/contrib/sessions/__init__.py +++ b/django/contrib/sessions/__init__.py @@ -1 +0,0 @@ -default_app_config = 'django.contrib.sessions.apps.SessionsConfig' diff --git a/django/contrib/sitemaps/__init__.py b/django/contrib/sitemaps/__init__.py index 05a1b654dc..54720324a2 100644 --- a/django/contrib/sitemaps/__init__.py +++ b/django/contrib/sitemaps/__init__.py @@ -158,6 +158,3 @@ class GenericSitemap(Sitemap): if self.date_field is not None: return getattr(item, self.date_field) return None - - -default_app_config = 'django.contrib.sitemaps.apps.SiteMapsConfig' diff --git a/django/contrib/sites/__init__.py b/django/contrib/sites/__init__.py index b554240bb2..e69de29bb2 100644 --- a/django/contrib/sites/__init__.py +++ b/django/contrib/sites/__init__.py @@ -1 +0,0 @@ -default_app_config = 'django.contrib.sites.apps.SitesConfig' diff --git a/django/contrib/staticfiles/__init__.py b/django/contrib/staticfiles/__init__.py index 8a07b83d2f..e69de29bb2 100644 --- a/django/contrib/staticfiles/__init__.py +++ b/django/contrib/staticfiles/__init__.py @@ -1 +0,0 @@ -default_app_config = 'django.contrib.staticfiles.apps.StaticFilesConfig' diff --git a/django/contrib/syndication/__init__.py b/django/contrib/syndication/__init__.py index 2c3f52578d..e69de29bb2 100644 --- a/django/contrib/syndication/__init__.py +++ b/django/contrib/syndication/__init__.py @@ -1 +0,0 @@ -default_app_config = 'django.contrib.syndication.apps.SyndicationConfig' diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index d6becd31c1..826c2ee814 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -24,6 +24,8 @@ details on these changes. * The ``whitelist`` argument and ``domain_whitelist`` attribute of ``django.core.validators.EmailValidator`` will be removed. +* The ``default_app_config`` module variable will be removed. + .. _deprecation-removed-in-4.0: 4.0 diff --git a/docs/ref/applications.txt b/docs/ref/applications.txt index d1a242163f..765e9e8d3c 100644 --- a/docs/ref/applications.txt +++ b/docs/ref/applications.txt @@ -56,25 +56,28 @@ application and have models, etc. (which would require adding it to Configuring applications ======================== -To configure an application, subclass :class:`~django.apps.AppConfig` and put -the dotted path to that subclass in :setting:`INSTALLED_APPS`. +To configure an application, create an ``apps.py`` module inside the +application, then define a subclass of :class:`AppConfig` there. When :setting:`INSTALLED_APPS` contains the dotted path to an application -module, Django checks for a ``default_app_config`` variable in that module. +module, by default, if Django finds exactly one :class:`AppConfig` subclass in +the ``apps.py`` submodule, it uses that configuration for the application. This +behavior may be disabled by setting :attr:`AppConfig.default` to ``False``. -If it's defined, it's the dotted path to the :class:`~django.apps.AppConfig` -subclass for that application. +If the ``apps.py`` module contains more than one :class:`AppConfig` subclass, +Django will look for a single one where :attr:`AppConfig.default` is ``True``. -If there is no ``default_app_config``, Django uses the base -:class:`~django.apps.AppConfig` class. +If no :class:`AppConfig` subclass is found, the base :class:`AppConfig` class +will be used. -``default_app_config`` allows applications that predate Django 1.7 such as -``django.contrib.admin`` to opt-in to :class:`~django.apps.AppConfig` features -without requiring users to update their :setting:`INSTALLED_APPS`. +Alternatively, :setting:`INSTALLED_APPS` may contain the dotted path to a +configuration class to specify it explicitly:: -New applications should avoid ``default_app_config``. Instead they should -require the dotted path to the appropriate :class:`~django.apps.AppConfig` -subclass to be configured explicitly in :setting:`INSTALLED_APPS`. + INSTALLED_APPS = [ + ... + 'polls.apps.PollsAppConfig', + ... + ] For application authors ----------------------- @@ -90,32 +93,24 @@ would provide a proper name for the admin:: name = 'rock_n_roll' verbose_name = "Rock ā€™nā€™ roll" -You can make your application load this :class:`~django.apps.AppConfig` -subclass by default as follows:: +``RockNRollConfig`` will be loaded automatically when :setting:`INSTALLED_APPS` +contains ``'rock_n_roll'``. If you need to prevent this, set +:attr:`~AppConfig.default` to ``False`` in the class definition. - # rock_n_roll/__init__.py +You can provide several :class:`AppConfig` subclasses with different behaviors. +To tell Django which one to use by default, set :attr:`~AppConfig.default` to +``True`` in its definition. If your users want to pick a non-default +configuration, they must replace ``'rock_n_roll'`` with the dotted path to that +specific class in their :setting:`INSTALLED_APPS` setting. - default_app_config = 'rock_n_roll.apps.RockNRollConfig' +The :attr:`AppConfig.name` attribute tells Django which application this +configuration applies to. You can define any other attribute documented in the +:class:`~django.apps.AppConfig` API reference. -That will cause ``RockNRollConfig`` to be used when :setting:`INSTALLED_APPS` -contains ``'rock_n_roll'``. This allows you to make use of -:class:`~django.apps.AppConfig` features without requiring your users to update -their :setting:`INSTALLED_APPS` setting. Besides this use case, it's best to -avoid using ``default_app_config`` and instead specify the app config class in -:setting:`INSTALLED_APPS` as described next. - -You can also tell your users to put ``'rock_n_roll.apps.RockNRollConfig'`` in -their :setting:`INSTALLED_APPS` setting. You can even provide several different -:class:`~django.apps.AppConfig` subclasses with different behaviors and allow -your users to choose one via their :setting:`INSTALLED_APPS` setting. - -The recommended convention is to put the configuration class in a submodule of -the application called ``apps``. However, this isn't enforced by Django. - -You must include the :attr:`~django.apps.AppConfig.name` attribute for Django -to determine which application this configuration applies to. You can define -any attributes documented in the :class:`~django.apps.AppConfig` API -reference. +:class:`AppConfig` subclasses may be defined anywhere. The ``apps.py`` +convention merely allows Django to load them automatically when +:setting:`INSTALLED_APPS` contains the path to an application module rather +than the path to a configuration class. .. note:: @@ -126,6 +121,11 @@ reference. from django.apps import apps as django_apps +.. versionchanged:: 3.2 + + In previous versions, a ``default_app_config`` variable in the application + module was used to identify the default application configuration class. + For application users --------------------- @@ -147,8 +147,13 @@ configuration:: # ... ] -Again, defining project-specific configuration classes in a submodule called -``apps`` is a convention, not a requirement. +This example shows project-specific configuration classes located in a +submodule called ``apps.py``. This is a convention, not a requirement. +:class:`AppConfig` subclasses may be defined anywhere. + +In this situation, :setting:`INSTALLED_APPS` must contain the dotted path to +the configuration class because it lives outside of an application and thus +cannot be automatically detected. Application configuration ========================= @@ -198,6 +203,22 @@ Configurable attributes required; for instance if the app package is a `namespace package`_ with multiple paths. +.. attribute:: AppConfig.default + + .. versionadded:: 3.2 + + Set this attribute to ``False`` to prevent Django from selecting a + configuration class automatically. This is useful when ``apps.py`` defines + only one :class:`AppConfig` subclass but you don't want Django to use it by + default. + + Set this attribute to ``True`` to tell Django to select a configuration + class automatically. This is useful when ``apps.py`` defines more than one + :class:`AppConfig` subclass and you want Django to use one of them by + default. + + By default, this attribute isn't set. + Read-only attributes -------------------- @@ -412,7 +433,8 @@ processes all applications in the order of :setting:`INSTALLED_APPS`. If it's an application configuration class, Django imports the root package of the application, defined by its :attr:`~AppConfig.name` attribute. If - it's a Python package, Django creates a default application configuration. + it's a Python package, Django looks for an application configuration in an + ``apps.py`` submodule, or else creates a default application configuration. *At this stage, your code shouldn't import any models!* diff --git a/docs/releases/3.2.txt b/docs/releases/3.2.txt index 2f3045f971..5e75f497f4 100644 --- a/docs/releases/3.2.txt +++ b/docs/releases/3.2.txt @@ -31,6 +31,28 @@ officially support the latest release of each series. What's new in Django 3.2 ======================== +Automatic :class:`~django.apps.AppConfig` discovery +--------------------------------------------------- + +Most pluggable applications define an :class:`~django.apps.AppConfig` subclass +in an ``apps.py`` submodule. Many define a ``default_app_config`` variable +pointing to this class in their ``__init__.py``. + +When the ``apps.py`` submodule exists and defines a single +:class:`~django.apps.AppConfig` subclass, Django now uses that configuration +automatically, so you can remove ``default_app_config``. + +``default_app_config`` made it possible to declare only the application's path +in :setting:`INSTALLED_APPS` (e.g. ``'django.contrib.admin'``) rather than the +app config's path (e.g. ``'django.contrib.admin.apps.AdminConfig'``). It was +introduced for backwards-compatibility with the former style, with the intent +to switch the ecosystem to the latter, but the switch didn't happen. + +With automatic ``AppConfig`` discovery, ``default_app_config`` is no longer +needed. As a consequence, it's deprecated. + +See :ref:`configuring-applications-ref` for full details. + Minor features -------------- @@ -387,6 +409,12 @@ Miscellaneous :attr:`~django.db.models.Model._default_manager` to check that related instances exist. +* When an application defines an :class:`~django.apps.AppConfig` subclass in + an ``apps.py`` submodule, Django now uses this configuration automatically, + even if it isn't enabled with ``default_app_config``. Set ``default = False`` + in the :class:`~django.apps.AppConfig` subclass if you need to prevent this + behavior. See :ref:`whats-new-3.2` for more details. + .. _deprecated-features-3.2: Features deprecated in 3.2 @@ -408,3 +436,7 @@ Miscellaneous ``allowlist`` instead of ``whitelist``, and ``domain_allowlist`` instead of ``domain_whitelist``. You may need to rename ``whitelist`` in existing migrations. + +* The ``default_app_config`` application configuration variable is deprecated, + due to the now automatic ``AppConfig`` discovery. See :ref:`whats-new-3.2` + for more details. diff --git a/tests/apps/default_config_app/__init__.py b/tests/apps/default_config_app/__init__.py deleted file mode 100644 index aebe0350d9..0000000000 --- a/tests/apps/default_config_app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -default_app_config = 'apps.default_config_app.apps.CustomConfig' diff --git a/tests/apps/default_config_app/apps.py b/tests/apps/default_config_app/apps.py deleted file mode 100644 index ba859a4750..0000000000 --- a/tests/apps/default_config_app/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class CustomConfig(AppConfig): - name = 'apps.default_config_app' diff --git a/tests/apps/explicit_default_config_app/__init__.py b/tests/apps/explicit_default_config_app/__init__.py new file mode 100644 index 0000000000..d97f1b3f21 --- /dev/null +++ b/tests/apps/explicit_default_config_app/__init__.py @@ -0,0 +1 @@ +default_app_config = 'apps.explicit_default_config_app.apps.ExplicitDefaultConfig' diff --git a/tests/apps/explicit_default_config_app/apps.py b/tests/apps/explicit_default_config_app/apps.py new file mode 100644 index 0000000000..e41ad5dcbb --- /dev/null +++ b/tests/apps/explicit_default_config_app/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ExplicitDefaultConfig(AppConfig): + name = 'apps.explicit_default_config_app' diff --git a/tests/apps/explicit_default_config_mismatch_app/__init__.py b/tests/apps/explicit_default_config_mismatch_app/__init__.py new file mode 100644 index 0000000000..8889145538 --- /dev/null +++ b/tests/apps/explicit_default_config_mismatch_app/__init__.py @@ -0,0 +1 @@ +default_app_config = 'apps.explicit_default_config_mismatch_app.not_apps.ExplicitDefaultConfigMismatch' diff --git a/tests/apps/explicit_default_config_mismatch_app/apps.py b/tests/apps/explicit_default_config_mismatch_app/apps.py new file mode 100644 index 0000000000..ddbead408d --- /dev/null +++ b/tests/apps/explicit_default_config_mismatch_app/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ImplicitDefaultConfigMismatch(AppConfig): + name = 'apps.explicit_default_config_mismatch_app' diff --git a/tests/apps/explicit_default_config_mismatch_app/not_apps.py b/tests/apps/explicit_default_config_mismatch_app/not_apps.py new file mode 100644 index 0000000000..941d35c3d2 --- /dev/null +++ b/tests/apps/explicit_default_config_mismatch_app/not_apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ExplicitDefaultConfigMismatch(AppConfig): + name = 'apps.explicit_default_config_mismatch_app' diff --git a/tests/apps/namespace_package_base/nsapp/apps.py b/tests/apps/namespace_package_base/nsapp/apps.py index a878449bf9..a81362e5b2 100644 --- a/tests/apps/namespace_package_base/nsapp/apps.py +++ b/tests/apps/namespace_package_base/nsapp/apps.py @@ -4,5 +4,6 @@ from django.apps import AppConfig class NSAppConfig(AppConfig): + default = False name = 'nsapp' path = os.path.dirname(__file__) diff --git a/tests/apps/no_config_app/__init__.py b/tests/apps/no_config_app/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/apps/one_config_app/__init__.py b/tests/apps/one_config_app/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/apps/one_config_app/apps.py b/tests/apps/one_config_app/apps.py new file mode 100644 index 0000000000..8b44796e42 --- /dev/null +++ b/tests/apps/one_config_app/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class OneConfig(AppConfig): + name = 'apps.one_config_app' diff --git a/tests/apps/tests.py b/tests/apps/tests.py index 566aec60c3..f4b0a8879b 100644 --- a/tests/apps/tests.py +++ b/tests/apps/tests.py @@ -5,11 +5,17 @@ from django.apps.registry import Apps from django.contrib.admin.models import LogEntry from django.core.exceptions import AppRegistryNotReady, ImproperlyConfigured from django.db import models -from django.test import SimpleTestCase, override_settings +from django.test import SimpleTestCase, ignore_warnings, override_settings from django.test.utils import extend_sys_path, isolate_apps +from django.utils.deprecation import RemovedInDjango41Warning -from .default_config_app.apps import CustomConfig +from .explicit_default_config_app.apps import ExplicitDefaultConfig +from .explicit_default_config_mismatch_app.not_apps import ( + ExplicitDefaultConfigMismatch, +) from .models import SoAlternative, TotallyNormal, new_apps +from .one_config_app.apps import OneConfig +from .two_configs_one_default_app.apps import TwoConfig # Small list with a variety of cases for tests that iterate on installed apps. # Intentionally not in alphabetical order to check if the order is preserved. @@ -84,25 +90,56 @@ class AppsTests(SimpleTestCase): pass def test_no_such_app_config(self): - msg = "No module named 'apps.NoSuchConfig'" + msg = "Module 'apps' does not contain a 'NoSuchConfig' class." with self.assertRaisesMessage(ImportError, msg): with self.settings(INSTALLED_APPS=['apps.NoSuchConfig']): pass def test_no_such_app_config_with_choices(self): msg = ( - "'apps.apps' does not contain a class 'NoSuchConfig'. Choices are: " - "'BadConfig', 'MyAdmin', 'MyAuth', 'NoSuchApp', 'PlainAppsConfig', " - "'RelabeledAppsConfig'." + "Module 'apps.apps' does not contain a 'NoSuchConfig' class. " + "Choices are: 'BadConfig', 'MyAdmin', 'MyAuth', 'NoSuchApp', " + "'PlainAppsConfig', 'RelabeledAppsConfig'." ) - with self.assertRaisesMessage(ImproperlyConfigured, msg): + with self.assertRaisesMessage(ImportError, msg): with self.settings(INSTALLED_APPS=['apps.apps.NoSuchConfig']): pass - def test_default_app_config(self): - with self.settings(INSTALLED_APPS=['apps.default_config_app']): - config = apps.get_app_config('default_config_app') - self.assertIsInstance(config, CustomConfig) + def test_no_config_app(self): + """Load an app that doesn't provide an AppConfig class.""" + with self.settings(INSTALLED_APPS=['apps.no_config_app']): + config = apps.get_app_config('no_config_app') + self.assertIsInstance(config, AppConfig) + + def test_one_config_app(self): + """Load an app that provides an AppConfig class.""" + with self.settings(INSTALLED_APPS=['apps.one_config_app']): + config = apps.get_app_config('one_config_app') + self.assertIsInstance(config, OneConfig) + + def test_two_configs_app(self): + """Load an app that provides two AppConfig classes.""" + with self.settings(INSTALLED_APPS=['apps.two_configs_app']): + config = apps.get_app_config('two_configs_app') + self.assertIsInstance(config, AppConfig) + + def test_two_default_configs_app(self): + """Load an app that provides two default AppConfig classes.""" + msg = ( + "'apps.two_default_configs_app.apps' declares more than one " + "default AppConfig: 'TwoConfig', 'TwoConfigBis'." + ) + with self.assertRaisesMessage(RuntimeError, msg): + with self.settings(INSTALLED_APPS=['apps.two_default_configs_app']): + pass + + def test_two_configs_one_default_app(self): + """ + Load an app that provides two AppConfig classes, one being the default. + """ + with self.settings(INSTALLED_APPS=['apps.two_configs_one_default_app']): + config = apps.get_app_config('two_configs_one_default_app') + self.assertIsInstance(config, TwoConfig) @override_settings(INSTALLED_APPS=SOME_INSTALLED_APPS) def test_get_app_configs(self): @@ -438,3 +475,48 @@ class NamespacePackageAppTests(SimpleTestCase): with self.settings(INSTALLED_APPS=['nsapp.apps.NSAppConfig']): app_config = apps.get_app_config('nsapp') self.assertEqual(app_config.path, self.app_path) + + +class DeprecationTests(SimpleTestCase): + @ignore_warnings(category=RemovedInDjango41Warning) + def test_explicit_default_app_config(self): + with self.settings(INSTALLED_APPS=['apps.explicit_default_config_app']): + config = apps.get_app_config('explicit_default_config_app') + self.assertIsInstance(config, ExplicitDefaultConfig) + + def test_explicit_default_app_config_warning(self): + """ + Load an app that specifies a default AppConfig class matching the + autodetected one. + """ + msg = ( + "'apps.explicit_default_config_app' defines default_app_config = " + "'apps.explicit_default_config_app.apps.ExplicitDefaultConfig'. " + "Django now detects this configuration automatically. You can " + "remove default_app_config." + ) + with self.assertRaisesMessage(RemovedInDjango41Warning, msg): + with self.settings(INSTALLED_APPS=['apps.explicit_default_config_app']): + config = apps.get_app_config('explicit_default_config_app') + self.assertIsInstance(config, ExplicitDefaultConfig) + + def test_explicit_default_app_config_mismatch(self): + """ + Load an app that specifies a default AppConfig class not matching the + autodetected one. + """ + msg = ( + "'apps.explicit_default_config_mismatch_app' defines " + "default_app_config = 'apps.explicit_default_config_mismatch_app." + "not_apps.ExplicitDefaultConfigMismatch'. However, Django's " + "automatic detection picked another configuration, 'apps." + "explicit_default_config_mismatch_app.apps." + "ImplicitDefaultConfigMismatch'. You should move the default " + "config class to the apps submodule of your application and, if " + "this module defines several config classes, mark the default one " + "with default = True." + ) + with self.assertRaisesMessage(RemovedInDjango41Warning, msg): + with self.settings(INSTALLED_APPS=['apps.explicit_default_config_mismatch_app']): + config = apps.get_app_config('explicit_default_config_mismatch_app') + self.assertIsInstance(config, ExplicitDefaultConfigMismatch) diff --git a/tests/apps/two_configs_app/__init__.py b/tests/apps/two_configs_app/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/apps/two_configs_app/apps.py b/tests/apps/two_configs_app/apps.py new file mode 100644 index 0000000000..dd2eac9ad1 --- /dev/null +++ b/tests/apps/two_configs_app/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class TwoConfig(AppConfig): + name = 'apps.two_configs_app' + + +class TwoConfigBis(AppConfig): + name = 'apps.two_configs_app' diff --git a/tests/apps/two_configs_one_default_app/__init__.py b/tests/apps/two_configs_one_default_app/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/apps/two_configs_one_default_app/apps.py b/tests/apps/two_configs_one_default_app/apps.py new file mode 100644 index 0000000000..489eb0f88f --- /dev/null +++ b/tests/apps/two_configs_one_default_app/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class TwoConfig(AppConfig): + default = True + name = 'apps.two_configs_one_default_app' + + +class TwoConfigAlt(AppConfig): + name = 'apps.two_configs_one_default_app' diff --git a/tests/apps/two_default_configs_app/__init__.py b/tests/apps/two_default_configs_app/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/apps/two_default_configs_app/apps.py b/tests/apps/two_default_configs_app/apps.py new file mode 100644 index 0000000000..3cbcc411b0 --- /dev/null +++ b/tests/apps/two_default_configs_app/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class TwoConfig(AppConfig): + default = True + name = 'apps.two_default_configs_app' + + +class TwoConfigBis(AppConfig): + default = True + name = 'apps.two_default_configs_app' diff --git a/tests/i18n/loading_app/apps.py b/tests/i18n/loading_app/apps.py index b4cdece232..14cfdf776f 100644 --- a/tests/i18n/loading_app/apps.py +++ b/tests/i18n/loading_app/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class LoadingAppConfig(AppConfig): - name = 'loading_app' + name = 'i18n.loading_app'