Fixed #21874 -- Require Django applications to have a filesystem path.

Wherever possible this filesystem path is derived automatically from the app
module's ``__path__`` and ``__file__`` attributes (this avoids any
backwards-compatibility problems).

AppConfig allows specifying an app's filesystem location explicitly, which
overrides all autodetection based on ``__path__`` and ``__file__``. This
permits Django to support any type of module as an app (namespace packages,
fake modules, modules loaded by other hypothetical non-filesystem module
loaders), as long as the app is configured with an explicit filesystem path.

Thanks Aymeric for review and discussion.
This commit is contained in:
Carl Meyer 2014-01-27 13:28:53 -07:00
parent b87bc461c8
commit 88a2d39159
5 changed files with 102 additions and 20 deletions

View File

@ -1,4 +1,5 @@
from importlib import import_module from importlib import import_module
import os
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import module_has_submodule from django.utils.module_loading import module_has_submodule
@ -34,23 +35,10 @@ class AppConfig(object):
self.verbose_name = self.label.title() self.verbose_name = self.label.title()
# Filesystem path to the application directory eg. # Filesystem path to the application directory eg.
# u'/usr/lib/python2.7/dist-packages/django/contrib/admin'. May be # u'/usr/lib/python2.7/dist-packages/django/contrib/admin'. Unicode on
# None if the application isn't a bona fide package eg. if it's an # 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: self.path = self._path_from_module(app_module)
paths = app_module.__path__
except AttributeError:
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().
@ -64,6 +52,29 @@ class AppConfig(object):
def __repr__(self): def __repr__(self):
return '<%s: %s>' % (self.__class__.__name__, self.label) return '<%s: %s>' % (self.__class__.__name__, self.label)
def _path_from_module(self, module):
"""Attempt to determine app's filesystem path from its module."""
# See #21874 for extended discussion of the behavior of this method in
# various cases.
# Convert paths to list because Python 3.3 _NamespacePath does not
# support indexing.
paths = list(getattr(module, '__path__', []))
if len(paths) != 1:
filename = getattr(module, '__file__', None)
if filename is not None:
paths = [os.path.dirname(filename)]
if len(paths) > 1:
raise ImproperlyConfigured(
"The app module %r has multiple filesystem locations (%r); "
"you must configure this app with an AppConfig subclass "
"with a 'path' class attribute." % (module, paths))
elif not paths:
raise ImproperlyConfigured(
"The app module %r has no filesystem location, "
"you must configure this app with an AppConfig subclass "
"with a 'path' class attribute." % (module,))
return upath(paths[0])
@classmethod @classmethod
def create(cls, entry): def create(cls, entry):
""" """

View File

@ -73,8 +73,11 @@ class ProjectState(object):
class AppConfigStub(AppConfig): class AppConfigStub(AppConfig):
""" """
Stubs a Django AppConfig. Only provides a label and a dict of models. Stubs a Django AppConfig. Only provides a label, and a dict of models.
""" """
# Not used, but required by AppConfig.__init__
path = ''
def __init__(self, label): def __init__(self, label):
super(AppConfigStub, self).__init__(label, None) super(AppConfigStub, self).__init__(label, None)

View File

@ -171,8 +171,6 @@ Configurable attributes
required; for instance if the app package is a `namespace package`_ with required; for instance if the app package is a `namespace package`_ with
multiple paths. multiple paths.
It may be ``None`` if the application isn't stored in a directory.
Read-only attributes Read-only attributes
-------------------- --------------------

View File

@ -4,7 +4,7 @@ import os
import sys import sys
from unittest import skipUnless from unittest import skipUnless
from django.apps import apps from django.apps import apps, AppConfig
from django.apps.registry import Apps from django.apps.registry import Apps
from django.contrib.admin.models import LogEntry from django.contrib.admin.models import LogEntry
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
@ -201,6 +201,71 @@ class AppsTests(TestCase):
self.assertEqual(new_apps.get_model("apps", "SouthPonies"), temp_model) self.assertEqual(new_apps.get_model("apps", "SouthPonies"), temp_model)
class Stub(object):
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class AppConfigTests(TestCase):
"""Unit tests for AppConfig class."""
def test_path_set_explicitly(self):
"""If subclass sets path as class attr, no module attributes needed."""
class MyAppConfig(AppConfig):
path = 'foo'
ac = MyAppConfig('label', Stub())
self.assertEqual(ac.path, 'foo')
def test_explicit_path_overrides(self):
"""If path set as class attr, overrides __path__ and __file__."""
class MyAppConfig(AppConfig):
path = 'foo'
ac = MyAppConfig('label', Stub(__path__=['a'], __file__='b/__init__.py'))
self.assertEqual(ac.path, 'foo')
def test_dunder_path(self):
"""If single element in __path__, use it (in preference to __file__)."""
ac = AppConfig('label', Stub(__path__=['a'], __file__='b/__init__.py'))
self.assertEqual(ac.path, 'a')
def test_no_dunder_path_fallback_to_dunder_file(self):
"""If there is no __path__ attr, use __file__."""
ac = AppConfig('label', Stub(__file__='b/__init__.py'))
self.assertEqual(ac.path, 'b')
def test_empty_dunder_path_fallback_to_dunder_file(self):
"""If the __path__ attr is empty, use __file__ if set."""
ac = AppConfig('label', Stub(__path__=[], __file__='b/__init__.py'))
self.assertEqual(ac.path, 'b')
def test_multiple_dunder_path_fallback_to_dunder_file(self):
"""If the __path__ attr is length>1, use __file__ if set."""
ac = AppConfig('label', Stub(__path__=['a', 'b'], __file__='c/__init__.py'))
self.assertEqual(ac.path, 'c')
def test_no_dunder_path_or_dunder_file(self):
"""If there is no __path__ or __file__, raise ImproperlyConfigured."""
with self.assertRaises(ImproperlyConfigured):
AppConfig('label', Stub())
def test_empty_dunder_path_no_dunder_file(self):
"""If the __path__ attr is empty and there is no __file__, raise."""
with self.assertRaises(ImproperlyConfigured):
AppConfig('label', Stub(__path__=[]))
def test_multiple_dunder_path_no_dunder_file(self):
"""If the __path__ attr is length>1 and there is no __file__, raise."""
with self.assertRaises(ImproperlyConfigured):
AppConfig('label', Stub(__path__=['a', 'b']))
@skipUnless( @skipUnless(
sys.version_info > (3, 3, 0), sys.version_info > (3, 3, 0),
"Namespace packages sans __init__.py were added in Python 3.3") "Namespace packages sans __init__.py were added in Python 3.3")

View File

@ -43,6 +43,8 @@ def create_egg(name, resources):
""" """
egg = types.ModuleType(name) egg = types.ModuleType(name)
egg.__loader__ = MockLoader() egg.__loader__ = MockLoader()
egg.__path__ = ['/some/bogus/path/']
egg.__file__ = '/some/bogus/path/__init__.pyc'
egg._resources = resources egg._resources = resources
sys.modules[name] = egg sys.modules[name] = egg
@ -68,6 +70,9 @@ class EggLoaderTest(TestCase):
def _get(self, path): def _get(self, path):
return self.module._resources[path].read() return self.module._resources[path].read()
def _fn(self, base, resource_name):
return resource_name
pkg_resources._provider_factories[MockLoader] = MockProvider pkg_resources._provider_factories[MockLoader] = MockProvider
self.empty_egg = create_egg("egg_empty", {}) self.empty_egg = create_egg("egg_empty", {})