Browse Source

Fixed #31180 -- Configured applications automatically.

Aymeric Augustin 4 years ago
parent
commit
3f2821af6b
39 changed files with 370 additions and 133 deletions
  1. 126 54
      django/apps/config.py
  2. 0 3
      django/contrib/admin/__init__.py
  3. 2 0
      django/contrib/admin/apps.py
  4. 0 1
      django/contrib/admindocs/__init__.py
  5. 0 3
      django/contrib/auth/__init__.py
  6. 0 1
      django/contrib/contenttypes/__init__.py
  7. 0 1
      django/contrib/flatpages/__init__.py
  8. 0 1
      django/contrib/gis/__init__.py
  9. 0 1
      django/contrib/humanize/__init__.py
  10. 0 2
      django/contrib/messages/__init__.py
  11. 0 1
      django/contrib/postgres/__init__.py
  12. 0 1
      django/contrib/redirects/__init__.py
  13. 0 1
      django/contrib/sessions/__init__.py
  14. 0 3
      django/contrib/sitemaps/__init__.py
  15. 0 1
      django/contrib/sites/__init__.py
  16. 0 1
      django/contrib/staticfiles/__init__.py
  17. 0 1
      django/contrib/syndication/__init__.py
  18. 2 0
      docs/internals/deprecation.txt
  19. 61 39
      docs/ref/applications.txt
  20. 32 0
      docs/releases/3.2.txt
  21. 0 1
      tests/apps/default_config_app/__init__.py
  22. 0 5
      tests/apps/default_config_app/apps.py
  23. 1 0
      tests/apps/explicit_default_config_app/__init__.py
  24. 5 0
      tests/apps/explicit_default_config_app/apps.py
  25. 1 0
      tests/apps/explicit_default_config_mismatch_app/__init__.py
  26. 5 0
      tests/apps/explicit_default_config_mismatch_app/apps.py
  27. 5 0
      tests/apps/explicit_default_config_mismatch_app/not_apps.py
  28. 1 0
      tests/apps/namespace_package_base/nsapp/apps.py
  29. 0 0
      tests/apps/no_config_app/__init__.py
  30. 0 0
      tests/apps/one_config_app/__init__.py
  31. 5 0
      tests/apps/one_config_app/apps.py
  32. 93 11
      tests/apps/tests.py
  33. 0 0
      tests/apps/two_configs_app/__init__.py
  34. 9 0
      tests/apps/two_configs_app/apps.py
  35. 0 0
      tests/apps/two_configs_one_default_app/__init__.py
  36. 10 0
      tests/apps/two_configs_one_default_app/apps.py
  37. 0 0
      tests/apps/two_default_configs_app/__init__.py
  38. 11 0
      tests/apps/two_default_configs_app/apps.py
  39. 1 1
      tests/i18n/loading_app/apps.py

+ 126 - 54
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.
         """
-        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
+        # 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:
+            app_module = import_module(entry)
+        except Exception:
+            pass
         else:
+            # 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)
+                    )
+                ]
+                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:
-                # If this works, the app module specifies an app config class.
-                entry = module.default_app_config
+                new_entry = app_module.default_app_config
             except AttributeError:
-                # Otherwise, it simply uses the default app config class.
-                return cls(entry, module)
+                # 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:
-                mod_path, _, cls_name = entry.rpartition('.')
-
-        # If we're reaching this point, we must attempt to load the app config
-        # class located at <mod_path>.<cls_name>
-        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
+                message = (
+                    '%r defines default_app_config = %r. ' % (entry, new_entry)
                 )
-                if candidates:
-                    raise ImproperlyConfigured(
-                        "'%s' does not contain a class '%s'. Choices are: %s."
-                        % (mod_path, cls_name, ', '.join(candidates))
+                if new_entry == app_config_name:
+                    message += (
+                        'Django now detects this configuration automatically. '
+                        'You can remove default_app_config.'
                     )
-                import_module(entry)
+                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:
-                raise
+                # 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):
         """

+ 0 - 3
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'

+ 2 - 0
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()

+ 0 - 1
django/contrib/admindocs/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'django.contrib.admindocs.apps.AdminDocsConfig'

+ 0 - 3
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'

+ 0 - 1
django/contrib/contenttypes/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'django.contrib.contenttypes.apps.ContentTypesConfig'

+ 0 - 1
django/contrib/flatpages/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'django.contrib.flatpages.apps.FlatPagesConfig'

+ 0 - 1
django/contrib/gis/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'django.contrib.gis.apps.GISConfig'

+ 0 - 1
django/contrib/humanize/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'django.contrib.humanize.apps.HumanizeConfig'

+ 0 - 2
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'

+ 0 - 1
django/contrib/postgres/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'django.contrib.postgres.apps.PostgresConfig'

+ 0 - 1
django/contrib/redirects/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'django.contrib.redirects.apps.RedirectsConfig'

+ 0 - 1
django/contrib/sessions/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'django.contrib.sessions.apps.SessionsConfig'

+ 0 - 3
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'

+ 0 - 1
django/contrib/sites/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'django.contrib.sites.apps.SitesConfig'

+ 0 - 1
django/contrib/staticfiles/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'django.contrib.staticfiles.apps.StaticFilesConfig'

+ 0 - 1
django/contrib/syndication/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'django.contrib.syndication.apps.SyndicationConfig'

+ 2 - 0
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

+ 61 - 39
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::
-
-    # rock_n_roll/__init__.py
-
-    default_app_config = 'rock_n_roll.apps.RockNRollConfig'
+``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.
 
-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 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.
 
-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 :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.
 
-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!*
 

+ 32 - 0
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.

+ 0 - 1
tests/apps/default_config_app/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'apps.default_config_app.apps.CustomConfig'

+ 0 - 5
tests/apps/default_config_app/apps.py

@@ -1,5 +0,0 @@
-from django.apps import AppConfig
-
-
-class CustomConfig(AppConfig):
-    name = 'apps.default_config_app'

+ 1 - 0
tests/apps/explicit_default_config_app/__init__.py

@@ -0,0 +1 @@
+default_app_config = 'apps.explicit_default_config_app.apps.ExplicitDefaultConfig'

+ 5 - 0
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'

+ 1 - 0
tests/apps/explicit_default_config_mismatch_app/__init__.py

@@ -0,0 +1 @@
+default_app_config = 'apps.explicit_default_config_mismatch_app.not_apps.ExplicitDefaultConfigMismatch'

+ 5 - 0
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'

+ 5 - 0
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'

+ 1 - 0
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__)

+ 0 - 0
tests/apps/no_config_app/__init__.py


+ 0 - 0
tests/apps/one_config_app/__init__.py


+ 5 - 0
tests/apps/one_config_app/apps.py

@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class OneConfig(AppConfig):
+    name = 'apps.one_config_app'

+ 93 - 11
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)

+ 0 - 0
tests/apps/two_configs_app/__init__.py


+ 9 - 0
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'

+ 0 - 0
tests/apps/two_configs_one_default_app/__init__.py


+ 10 - 0
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'

+ 0 - 0
tests/apps/two_default_configs_app/__init__.py


+ 11 - 0
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'

+ 1 - 1
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'