Skip to content

Commit

Permalink
Update ExtensionInfo to be able to work with or without entrypoints.
Browse files Browse the repository at this point in the history
ExtensionInfo, the internal class for storing various bits of data about
an extension (paths, names, metadata, etc.) previously required the use
of a Python EntryPoint in order to retrieve the data. This made unit
tests more annoying than they should have been.

Now, ExtensionInfo's constructor takes the extension class, package
name, and metadata. A new `create_from_entrypoint()` class method has
been added for using an EntryPoint. The old constructor still accepts
one as well, but will warn about deprecated usage first.

Some unit tests have also been updated to make use of this, and to test
the old behavior (modernizing some of the checks while there).

Testing Done:
Djblets and Review Board unit tests pass.

Made use of this with the new, upcoming extension testing support.

Reviewed at https://reviews.reviewboard.org/r/8288/
  • Loading branch information
chipx86 committed Jul 20, 2016
1 parent 56ad7ad commit 02815f3
Show file tree
Hide file tree
Showing 3 changed files with 202 additions and 75 deletions.
141 changes: 123 additions & 18 deletions djblets/extensions/extension.py
Expand Up @@ -29,13 +29,14 @@
import locale
import logging
import os
import warnings
from email.parser import FeedParser

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.urlresolvers import get_mod_func
from django.utils import six
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext as _

from djblets.extensions.settings import Settings

Expand Down Expand Up @@ -204,7 +205,52 @@ class ExtensionInfo(object):

encodings = ['utf-8', locale.getpreferredencoding(False), 'latin1']

def __init__(self, entrypoint, ext_class):
@classmethod
def create_from_entrypoint(cls, entrypoint, ext_class):
"""Create a new ExtensionInfo from a Python EntryPoint.
This will pull out information from the EntryPoint and return a new
ExtensionInfo from it.
It handles pulling out metadata from the older :file:`PKG-INFO` files
and the newer :file:`METADATA` files.
Args:
entrypoint (pkg_resources.EntryPoint):
The EntryPoint pointing to the extension class.
ext_class (type):
The extension class (subclass of :py:class:`Extension`).
Returns:
ExtensionInfo:
An ExtensionInfo instance, populated with metadata from the
package.
"""
metadata = cls._get_metadata_from_entrypoint(entrypoint, ext_class.id)

return cls(ext_class=ext_class,
package_name=metadata.get('Name'),
metadata=metadata)

@classmethod
def _get_metadata_from_entrypoint(cls, entrypoint, extension_id):
"""Return metadata information from an entrypoint.
This is used internally to parse and validate package information from
an entrypoint for use in ExtensionInfo.
Args:
entrypoint (pkg_resources.EntryPoint):
The EntryPoint pointing to the extension class.
extension_id (unicode):
The extension's ID.
Returns:
dict:
The resulting metadata dictionary.
"""
dist = entrypoint.dist

try:
Expand All @@ -219,14 +265,14 @@ def __init__(self, entrypoint, ext_class):
logging.error('No METADATA or PKG-INFO found for the package '
'containing the %s extension. Information on '
'the extension may be missing.',
ext_class.id)
extension_id)

data = '\n'.join(lines)

# Try to decode the PKG-INFO content. If no decoding method is
# successful then the PKG-INFO content will remain unchanged and
# processing will continue with the parsing.
for enc in self.encodings:
for enc in cls.encodings:
try:
data = data.decode(enc)
break
Expand All @@ -241,17 +287,85 @@ def __init__(self, entrypoint, ext_class):
p.feed(data)
pkg_info = p.close()

# Extensions will often override "Name" to be something
# user-presentable, but we sometimes need the package name
self.package_name = pkg_info.get('Name')
return dict(pkg_info.items())

def __init__(self, ext_class, package_name, metadata={}):
"""Instantiate the ExtensionInfo using metadata and an extension class.
This will set information about the extension based on the metadata
provided by the caller and the extension class itself.
Args:
ext_class (type):
The extension class (subclass of :py:class:`Extension`).
package_name (unicode):
The package name owning the extension.
metadata (dict, optional):
Optional metadata for the extension. If the extension provides
its own metadata, that will take precedence.
Raises:
TypeError:
The parameters passed were invalid (they weren't a new-style
call or a legacy entrypoint-related call).
"""
try:
issubclass(ext_class, Extension)
except TypeError:
try:
is_entrypoint = (hasattr(ext_class, 'dist') and
issubclass(package_name, Extension))
except TypeError:
is_entrypoint = False

if is_entrypoint:
# These are really (probably) an entrypoint and class,
# respectively. Fix up the variables.
entrypoint, ext_class = ext_class, package_name

metadata = self._get_metadata_from_entrypoint(entrypoint,
ext_class.id)
package_name = metadata.get('Name')

# Warn after the above. Something about the above calls cause
# warnings to be reset.
warnings.warn(
'ExtensionInfo.__init__() no longer accepts an '
'EntryPoint. Please update your code to call '
'ExtensionInfo.create_from_entrypoint() instead.',
DeprecationWarning)
else:
logging.error('Unexpected parameters passed to '
'ExtensionInfo.__init__: ext_class=%r, '
'package_name=%r, metadata=%r',
ext_class, package_name, metadata)

raise TypeError(
_('Invalid parameters passed to ExtensionInfo.__init__'))

metadata = dict(pkg_info.items())
# Set the base information from the extension and the package.
self.package_name = package_name
self.app_name = '.'.join(ext_class.__module__.split('.')[:-1])
self.is_configurable = ext_class.is_configurable
self.has_admin_site = ext_class.has_admin_site
self.installed_htdocs_path = \
os.path.join(settings.MEDIA_ROOT, 'ext', self.package_name)
self.installed_static_path = \
os.path.join(settings.STATIC_ROOT, 'ext', ext_class.id)

# State set by ExtensionManager.
self.enabled = False
self.installed = False
self.requirements = []

# Set information from the provided metadata.
if ext_class.metadata is not None:
metadata.update(ext_class.metadata)

self.metadata = metadata
self.name = metadata.get('Name')
self.name = metadata.get('Name', package_name)
self.version = metadata.get('Version')
self.summary = metadata.get('Summary')
self.description = metadata.get('Description')
Expand All @@ -260,15 +374,6 @@ def __init__(self, entrypoint, ext_class):
self.license = metadata.get('License')
self.url = metadata.get('Home-page')
self.author_url = metadata.get('Author-home-page', self.url)
self.app_name = '.'.join(ext_class.__module__.split('.')[:-1])
self.enabled = False
self.installed = False
self.is_configurable = ext_class.is_configurable
self.has_admin_site = ext_class.has_admin_site
self.installed_htdocs_path = \
os.path.join(settings.MEDIA_ROOT, 'ext', self.package_name)
self.installed_static_path = \
os.path.join(settings.STATIC_ROOT, 'ext', ext_class.id)

def __str__(self):
return "%s %s (enabled = %s)" % (self.name, self.version, self.enabled)
3 changes: 2 additions & 1 deletion djblets/extensions/manager.py
Expand Up @@ -464,7 +464,8 @@ def _load_extensions(self, full_reload=False):
# Don't override the info if we've previously loaded this
# class.
if not getattr(ext_class, 'info', None):
ext_class.info = ExtensionInfo(entrypoint, ext_class)
ext_class.info = ExtensionInfo.create_from_entrypoint(
entrypoint, ext_class)

registered_ext = registered_extensions.get(class_name)

Expand Down
133 changes: 77 additions & 56 deletions djblets/extensions/tests.py
Expand Up @@ -29,6 +29,7 @@
import os
import threading
import time
import warnings

from django import forms
from django.conf import settings
Expand Down Expand Up @@ -343,83 +344,103 @@ def test_get_admin_urlconf(self):
self.fail("Should have loaded admin_urls.py")


class ExtensionInfoTest(TestCase):
def test_metadata_from_package(self):
"""Testing ExtensionInfo metadata from package"""
app_name = 'test_extension.dummy'
project_name = 'DummyExtension'
class ExtensionInfoTests(TestCase):
def test_create_from_entrypoint(self):
"""Testing ExtensionInfo.create_from_entrypoint"""
module_name = 'test_extension.dummy.submodule'
package_name = 'DummyExtension'
extension_id = '%s:DummyExtension' % module_name
htdocs_path = os.path.join(settings.MEDIA_ROOT, 'ext',
project_name)
static_path = os.path.join(settings.STATIC_ROOT, 'ext',
extension_id)

ext_class = Mock()
ext_class.__module__ = module_name
ext_class.id = extension_id
ext_class.metadata = None
class TestExtension(Extension):
__module__ = module_name
id = extension_id

entrypoint = FakeEntryPoint(TestExtension, project_name=package_name)
extension_info = ExtensionInfo.create_from_entrypoint(entrypoint,
TestExtension)

self._check_extension_info(extension_info=extension_info,
app_name='test_extension.dummy',
package_name=package_name,
extension_id=extension_id,
metadata=entrypoint.dist.metadata)

def test_create_from_entrypoint_with_custom_metadata(self):
"""Testing ExtensionInfo.create_from_entrypoint with custom
Extension.metadata
"""
package_name = 'DummyExtension'
module_name = 'test_extension.dummy.submodule'
extension_id = '%s:DummyExtension' % module_name

entrypoint = FakeEntryPoint(ext_class, project_name=project_name)
extension_info = ExtensionInfo(entrypoint, ext_class)
metadata = entrypoint.dist.metadata
class TestExtension(Extension):
__module__ = module_name
id = extension_id
metadata = {
'Name': 'OverrideName',
'Version': '3.14159',
'Summary': 'Lorem ipsum dolor sit amet.',
'Description': 'Tempus fugit.',
'License': 'None',
'Home-page': 'http://127.0.0.1/',
}

self.assertEqual(extension_info.app_name, app_name)
self.assertEqual(extension_info.author, metadata['Author'])
self.assertEqual(extension_info.author_email, metadata['Author-email'])
self.assertEqual(extension_info.description, metadata['Description'])
self.assertFalse(extension_info.enabled)
self.assertEqual(extension_info.installed_htdocs_path,
htdocs_path)
self.assertEqual(extension_info.installed_static_path,
static_path)
self.assertFalse(extension_info.installed)
self.assertEqual(extension_info.license, metadata['License'])
self.assertEqual(extension_info.metadata, metadata)
self.assertEqual(extension_info.name, metadata['Name'])
self.assertEqual(extension_info.summary, metadata['Summary'])
self.assertEqual(extension_info.url, metadata['Home-page'])
self.assertEqual(extension_info.version, metadata['Version'])
entrypoint = FakeEntryPoint(TestExtension, project_name=package_name)
extension_info = ExtensionInfo.create_from_entrypoint(entrypoint,
TestExtension)

expected_metadata = entrypoint.dist.metadata.copy()
expected_metadata.update(TestExtension.metadata)

def test_custom_metadata(self):
"""Testing ExtensionInfo metadata from Extension.metadata"""
app_name = 'test_extension.dummy'
project_name = 'DummyExtension'
self._check_extension_info(extension_info=extension_info,
app_name='test_extension.dummy',
package_name=package_name,
extension_id=extension_id,
metadata=expected_metadata)

def test_deprecated_entrypoint_in_init(self):
"""Testing ExtensionInfo.__init__ with deprecated entrypoint support"""
module_name = 'test_extension.dummy.submodule'
package_name = 'DummyExtension'
extension_id = '%s:DummyExtension' % module_name
htdocs_path = os.path.join(settings.MEDIA_ROOT, 'ext',
project_name)
metadata = {
'Name': 'OverrideName',
'Version': '3.14159',
'Summary': 'Lorem ipsum dolor sit amet.',
'Description': 'Tempus fugit.',
'Author': 'Somebody',
'Author-email': 'somebody@example.com',
'License': 'None',
'Home-page': 'http://127.0.0.1/',
}

ext_class = Mock()
ext_class.__module__ = module_name
ext_class.metadata = metadata
ext_class.id = extension_id
class TestExtension(Extension):
__module__ = module_name
id = extension_id

entrypoint = FakeEntryPoint(TestExtension, project_name=package_name)

with warnings.catch_warnings(record=True) as w:
extension_info = ExtensionInfo(entrypoint, TestExtension)

self.assertEqual(six.text_type(w[0].message),
'ExtensionInfo.__init__() no longer accepts an '
'EntryPoint. Please update your code to call '
'ExtensionInfo.create_from_entrypoint() instead.')

entry_point = FakeEntryPoint(ext_class, project_name=project_name)
self._check_extension_info(extension_info=extension_info,
app_name='test_extension.dummy',
package_name=package_name,
extension_id=extension_id,
metadata=entrypoint.dist.metadata)

extension_info = ExtensionInfo(entry_point, ext_class)
def _check_extension_info(self, extension_info, app_name, package_name,
extension_id, metadata):
htdocs_path = os.path.join(settings.MEDIA_ROOT, 'ext', package_name)
static_path = os.path.join(settings.STATIC_ROOT, 'ext', extension_id)

self.assertEqual(extension_info.app_name, app_name)
self.assertEqual(extension_info.author, metadata['Author'])
self.assertEqual(extension_info.author_email, metadata['Author-email'])
self.assertEqual(extension_info.description, metadata['Description'])
self.assertFalse(extension_info.enabled)
self.assertEqual(extension_info.installed_htdocs_path,
htdocs_path)
self.assertEqual(extension_info.installed_htdocs_path, htdocs_path)
self.assertEqual(extension_info.installed_static_path, static_path)
self.assertFalse(extension_info.installed)
self.assertEqual(extension_info.license, metadata['License'])
self.assertEqual(extension_info.metadata, metadata)
self.assertEqual(extension_info.name, metadata['Name'])
self.assertEqual(extension_info.package_name, package_name)
self.assertEqual(extension_info.summary, metadata['Summary'])
self.assertEqual(extension_info.url, metadata['Home-page'])
self.assertEqual(extension_info.version, metadata['Version'])
Expand Down

0 comments on commit 02815f3

Please sign in to comment.