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:
parent
b87bc461c8
commit
88a2d39159
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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", {})
|
||||||
|
|
Loading…
Reference in New Issue