Skip to content

Commit

Permalink
Fixed #17304 -- Allow single-path and configured-path namespace packa…
Browse files Browse the repository at this point in the history
…ges as apps.

Also document the conditions under which a namespace package may or may not be
a Django app, and raise a clearer error message in those cases where it may not
be.

Thanks Aymeric for review and consultation.
  • Loading branch information
carljm committed Jan 26, 2014
1 parent ee4b806 commit 966b186
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 4 deletions.
11 changes: 10 additions & 1 deletion django/apps/base.py
Expand Up @@ -39,9 +39,18 @@ def __init__(self, app_name, app_module):
# egg. Otherwise it's a unicode on Python 2 and a str on Python 3. # egg. Otherwise it's a unicode on Python 2 and a str on Python 3.
if not hasattr(self, 'path'): if not hasattr(self, 'path'):
try: try:
self.path = upath(app_module.__path__[0]) paths = app_module.__path__
except AttributeError: except AttributeError:
self.path = None self.path = None
else:
# Convert paths to list because Python 3.3 _NamespacePath does
# not support indexing.
paths = list(paths)
if len(paths) > 1:
raise ImproperlyConfigured(
"The namespace package app %r has multiple locations, "
"which is not supported: %r" % (app_name, paths))
self.path = upath(paths[0])


# Module containing models eg. <module 'django.contrib.admin.models' # Module containing models eg. <module 'django.contrib.admin.models'
# from 'django/contrib/admin/models.pyc'>. Set by import_models(). # from 'django/contrib/admin/models.pyc'>. Set by import_models().
Expand Down
38 changes: 35 additions & 3 deletions docs/ref/applications.txt
Expand Up @@ -160,17 +160,23 @@ Configurable attributes


This attribute defaults to ``label.title()``. This attribute defaults to ``label.title()``.


Read-only attributes
--------------------

.. attribute:: AppConfig.path .. attribute:: AppConfig.path


Filesystem path to the application directory, e.g. Filesystem path to the application directory, e.g.
``'/usr/lib/python2.7/dist-packages/django/contrib/admin'``. ``'/usr/lib/python2.7/dist-packages/django/contrib/admin'``.


In most cases, Django can automatically detect and set this, but you can
also provide an explicit override as a class attribute on your
:class:`~django.apps.AppConfig` subclass. In a few situations this is
required; for instance if the app package is a `namespace package`_ with
multiple paths.

It may be ``None`` if the application isn't stored in a directory, for It may be ``None`` if the application isn't stored in a directory, for
instance if it's loaded from an egg. instance if it's loaded from an egg.


Read-only attributes
--------------------

.. attribute:: AppConfig.module .. attribute:: AppConfig.module


Root module for the application, e.g. ``<module 'django.contrib.admin' from Root module for the application, e.g. ``<module 'django.contrib.admin' from
Expand Down Expand Up @@ -209,6 +215,32 @@ Methods
def ready(self): def ready(self):
MyModel = self.get_model('MyModel') MyModel = self.get_model('MyModel')


.. _namespace package:

Namespace packages as apps (Python 3.3+)
----------------------------------------

Python versions 3.3 and later support Python packages without an
``__init__.py`` file. These packages are known as "namespace packages" and may
be spread across multiple directories at different locations on ``sys.path``
(see :pep:`420`).

Django applications require a single base filesystem path where Django
(depending on configuration) will search for templates, static assets,
etc. Thus, namespace packages may only be Django applications if one of the
following is true:

1. The namespace package actually has only a single location (i.e. is not
spread across more than one directory.)

2. The :class:`~django.apps.AppConfig` class used to configure the application
has a :attr:`~django.apps.AppConfig.path` class attribute, which is the
absolute directory path Django will use as the single base path for the
application.

If neither of these conditions is met, Django will raise
:exc:`~django.core.exceptions.ImproperlyConfigured`.

Application registry Application registry
==================== ====================


Expand Down
8 changes: 8 additions & 0 deletions tests/apps/namespace_package_base/nsapp/apps.py
@@ -0,0 +1,8 @@
import os

from django.apps import AppConfig
from django.utils._os import upath

class NSAppConfig(AppConfig):
name = 'nsapp'
path = upath(os.path.dirname(__file__))
Empty file.
67 changes: 67 additions & 0 deletions tests/apps/tests.py
@@ -1,10 +1,16 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals


from contextlib import contextmanager
import os
import sys
from unittest import skipUnless

from django.apps import apps from django.apps import apps
from django.apps.registry import Apps from django.apps.registry import Apps
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import models from django.db import models
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.utils._os import upath
from django.utils import six from django.utils import six


from .default_config_app.apps import CustomConfig from .default_config_app.apps import CustomConfig
Expand All @@ -28,6 +34,8 @@
'django.contrib.auth', 'django.contrib.auth',
] + SOME_INSTALLED_APPS[2:] ] + SOME_INSTALLED_APPS[2:]


HERE = os.path.dirname(__file__)



class AppsTests(TestCase): class AppsTests(TestCase):


Expand Down Expand Up @@ -166,3 +174,62 @@ def test_dynamic_load(self):
with self.assertRaises(LookupError): with self.assertRaises(LookupError):
apps.get_model("apps", "SouthPonies") apps.get_model("apps", "SouthPonies")
self.assertEqual(new_apps.get_model("apps", "SouthPonies"), temp_model) self.assertEqual(new_apps.get_model("apps", "SouthPonies"), temp_model)



@skipUnless(
sys.version_info > (3, 3, 0),
"Namespace packages sans __init__.py were added in Python 3.3")
class NamespacePackageAppTests(TestCase):
# We need nsapp to be top-level so our multiple-paths tests can add another
# location for it (if its inside a normal package with an __init__.py that
# isn't possible). In order to avoid cluttering the already-full tests/ dir
# (which is on sys.path), we add these new entries to sys.path temporarily.
base_location = os.path.join(HERE, 'namespace_package_base')
other_location = os.path.join(HERE, 'namespace_package_other_base')
app_path = os.path.join(base_location, 'nsapp')

@contextmanager
def add_to_path(self, *paths):
"""Context manager to temporarily add paths to sys.path."""
_orig_sys_path = sys.path[:]
sys.path.extend(paths)
try:
yield
finally:
sys.path = _orig_sys_path

def test_single_path(self):
"""
A Py3.3+ namespace package can be an app if it has only one path.
"""
with self.add_to_path(self.base_location):
with self.settings(INSTALLED_APPS=['nsapp']):
app_config = apps.get_app_config('nsapp')
self.assertEqual(app_config.path, upath(self.app_path))

def test_multiple_paths(self):
"""
A Py3.3+ namespace package with multiple locations cannot be an app.
(Because then we wouldn't know where to load its templates, static
assets, etc from.)
"""
# Temporarily add two directories to sys.path that both contain
# components of the "nsapp" package.
with self.add_to_path(self.base_location, self.other_location):
with self.assertRaises(ImproperlyConfigured):
with self.settings(INSTALLED_APPS=['nsapp']):
pass

def test_multiple_paths_explicit_path(self):
"""
Multiple locations are ok only if app-config has explicit path.
"""
# Temporarily add two directories to sys.path that both contain
# components of the "nsapp" package.
with self.add_to_path(self.base_location, self.other_location):
with self.settings(INSTALLED_APPS=['nsapp.apps.NSAppConfig']):
app_config = apps.get_app_config('nsapp')
self.assertEqual(app_config.path, upath(self.app_path))

0 comments on commit 966b186

Please sign in to comment.