From 02815f34cc14fc50b422ced2f5423974c0460b7f Mon Sep 17 00:00:00 2001 From: Christian Hammond Date: Mon, 18 Jul 2016 02:24:57 -0700 Subject: [PATCH 1/4] Update ExtensionInfo to be able to work with or without entrypoints. 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/ --- djblets/extensions/extension.py | 141 ++++++++++++++++++++++++++++---- djblets/extensions/manager.py | 3 +- djblets/extensions/tests.py | 133 +++++++++++++++++------------- 3 files changed, 202 insertions(+), 75 deletions(-) diff --git a/djblets/extensions/extension.py b/djblets/extensions/extension.py index 0d69288d..53415fd4 100644 --- a/djblets/extensions/extension.py +++ b/djblets/extensions/extension.py @@ -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 @@ -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: @@ -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 @@ -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') @@ -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) diff --git a/djblets/extensions/manager.py b/djblets/extensions/manager.py index e05e6cb9..70f2f3b7 100644 --- a/djblets/extensions/manager.py +++ b/djblets/extensions/manager.py @@ -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) diff --git a/djblets/extensions/tests.py b/djblets/extensions/tests.py index 35c07f5e..211fd520 100644 --- a/djblets/extensions/tests.py +++ b/djblets/extensions/tests.py @@ -29,6 +29,7 @@ import os import threading import time +import warnings from django import forms from django.conf import settings @@ -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']) From 4b80e795924bd3ec16ea54ab75450f27cf094a04 Mon Sep 17 00:00:00 2001 From: Christian Hammond Date: Tue, 19 Jul 2016 23:45:06 -0700 Subject: [PATCH 2/4] Update Djblets doc building for beanbag-docutils and new Django 1.6 docs. This updates our Djblets docs building to use beanbag-docutils instead of bundling a bunch of extensions, cleaning up some of the tree. It also switches us over to a fork of the Django 1.6 docs, hosted on readthedocs.org. The official Django website no longer hosts docs for unsupported versions of Django, so we're switching to this fork in order to ensure that we're still linking to appropriate docs. Testing Done: Built the docs successfully. Tested that references to Django docs were working. Reviewed at https://reviews.reviewboard.org/r/8292/ --- dev-requirements.txt | 1 + docs/djblets/_ext/autodoc_utils.py | 56 ---------- docs/djblets/_ext/djangorefs.py | 5 - docs/djblets/_ext/github_linkcode.py | 151 --------------------------- docs/djblets/_ext/httprole.py | 104 ------------------ docs/djblets/_ext/retina_images.py | 44 -------- docs/djblets/conf.py | 39 +++++-- 7 files changed, 29 insertions(+), 371 deletions(-) delete mode 100644 docs/djblets/_ext/autodoc_utils.py delete mode 100644 docs/djblets/_ext/djangorefs.py delete mode 100644 docs/djblets/_ext/github_linkcode.py delete mode 100644 docs/djblets/_ext/httprole.py delete mode 100644 docs/djblets/_ext/retina_images.py diff --git a/dev-requirements.txt b/dev-requirements.txt index af451347..eb6e01a8 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,6 @@ --allow-all-external https://github.com/beanbaginc/django-evolution/archive/master.zip +beanbag-docutils coverage kgb nose diff --git a/docs/djblets/_ext/autodoc_utils.py b/docs/djblets/_ext/autodoc_utils.py deleted file mode 100644 index 8386a8df..00000000 --- a/docs/djblets/_ext/autodoc_utils.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import unicode_literals - -import sys - -from django.utils import six -from django.utils.functional import Promise - - -_deprecated_lists = {} - - -def _repr_promise(promise): - """Return a sane representation of a lazy localized string. - - If the promise is a result of ugettext_lazy(), it will be converted into - a Unicode string before generating a representation. - """ - if hasattr(promise, '_proxy____text_cast'): - return '_(%s)' % repr(six.text_type(promise)) - - return super(promise.__class__, promise).__repr__(promise) - - -def _filter_members(app, what, name, obj, skip, options): - """Filter members out of the documentation. - - This will look up the name in the ``autodoc_excludes`` table under the - ``what`` and under ``'*'`` keys. If an entry is listed, it will be - excluded from the documentation. - - It will also skip modules listed in ``__deprecated__`` in the documented - module. - """ - excludes = app.config['autodoc_excludes'] - - for key in (what, '*'): - if key in excludes and name in excludes.get(key, []): - return True - - module = app.env.temp_data['autodoc:module'] - - if module not in _deprecated_lists: - _deprecated_lists[module] = \ - getattr(sys.modules[module], '__deprecated__', []) - - if name in _deprecated_lists[module]: - return True - - return skip - - -def setup(app): - Promise.__repr__ = _repr_promise - - app.add_config_value('autodoc_excludes', {}, True) - app.connect(b'autodoc-skip-member', _filter_members) diff --git a/docs/djblets/_ext/djangorefs.py b/docs/djblets/_ext/djangorefs.py deleted file mode 100644 index cfe3a072..00000000 --- a/docs/djblets/_ext/djangorefs.py +++ /dev/null @@ -1,5 +0,0 @@ -def setup(app): - app.add_crossref_type( - directivename='setting', - rolename='setting', - indextemplate='pair: %s; setting') diff --git a/docs/djblets/_ext/github_linkcode.py b/docs/djblets/_ext/github_linkcode.py deleted file mode 100644 index 6fd151f6..00000000 --- a/docs/djblets/_ext/github_linkcode.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import unicode_literals - -import inspect -import os -import re -import subprocess -import sys - -import djblets -from djblets import VERSION - - -GIT_BRANCH_CONTAINS_RE = re.compile(r'^\s*([^\s]+)\s+([0-9a-f]+)\s.*') - - -_head_ref = None - - -def _run_git(cmd): - """Run git with the given arguments, returning the output.""" - p = subprocess.Popen(['git'] + cmd, stdout=subprocess.PIPE) - output, error = p.communicate() - ret_code = p.poll() - - if ret_code: - raise subprocess.CalledProcessError(ret_code, 'git') - - return output - - -def _get_branch_for_version(): - """Return the branch or tag for the current version of Review Board.""" - if VERSION[4] == 'final' or VERSION[4] > 0: - if djblets.is_release(): - return 'release-%s.%s.%s' % (VERSION[0], VERSION[1], VERSION[2]) - else: - return 'release-%s.%s.x' % (VERSION[0], VERSION[1]) - else: - return 'master' - - -def git_get_nearest_tracking_branch(ref='HEAD', remote='origin'): - """Return the nearest tracking branch for the given Git repository.""" - merge_base = _get_branch_for_version() - - try: - _run_git(['fetch', 'origin', '%s:%s' % (merge_base, merge_base)]) - except Exception: - # Ignore, as we may already have this. Hopefully it won't fail later. - pass - - lines = _run_git(['branch', '-rv', '--contains', merge_base]).splitlines() - - remote_prefix = '%s/' % remote - best_distance = None - best_ref_name = None - - for line in lines: - m = GIT_BRANCH_CONTAINS_RE.match(line.strip()) - - if m: - ref_name = m.group(1) - sha = m.group(2) - - if (ref_name.startswith(remote_prefix) and - not ref_name.endswith('/HEAD')): - - distance = len(_run_git(['log', - '--pretty=format:%%H', - '%s..%s' % (ref, sha)]).splitlines()) - - if best_distance is None or distance < best_distance: - best_distance = distance - best_ref_name = ref_name - - if best_ref_name and best_ref_name.startswith(remote_prefix): - # Strip away the "origin/". - best_ref_name = best_ref_name[len(remote_prefix):] - - return best_ref_name - - -def get_git_doc_ref(): - """Return the revision used for linking to source code on GitHub.""" - global _head_ref - - if not _head_ref: - try: - branch = git_get_nearest_tracking_branch('.') - _head_ref = _run_git(['rev-parse', branch]).strip() - except subprocess.CalledProcessError: - _head_ref = None - - return _head_ref - - -def github_linkcode_resolve(domain, info): - """Return a link to the source on GitHub for the given autodoc info.""" - if (domain != 'py' or not info['module'] or - not info['module'].startswith('djblets')): - # These aren't the modules you're looking for. - return None - - # Grab the module referenced in the docs. - submod = sys.modules.get(info['module']) - - if submod is None: - return None - - # Split that, trying to find the module at the very tail of the module - # path. - obj = submod - - for part in info['fullname'].split('.'): - try: - obj = getattr(obj, part) - except: - return None - - # Grab the name of the source file. - try: - filename = inspect.getsourcefile(obj) - except: - filename = None - - if not filename: - return None - - filename = os.path.relpath(filename, - start=os.path.dirname(djblets.__file__)) - - # Find the line number of the thing being documented. - try: - linenum = inspect.findsource(obj)[1] - except: - linenum = None - - # Build a reference for the line number in GitHub. - if linenum: - linespec = '#L%d' % (linenum + 1) - else: - linespec = '' - - # Get the branch/tag/commit to link to. - ref = get_git_doc_ref() - - if not ref: - ref = _get_branch_for_version() - - return ('https://github.com/djblets/djblets/blob/%s/djblets/%s%s' - % (ref, filename, linespec)) diff --git a/docs/djblets/_ext/httprole.py b/docs/djblets/_ext/httprole.py deleted file mode 100644 index 5f555644..00000000 --- a/docs/djblets/_ext/httprole.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -Sphinx plugin to add a ``http`` role. -""" -from docutils import nodes - - -DEFAULT_HTTP_STATUS_CODES_URL = \ - 'http://en.wikipedia.org/wiki/List_of_HTTP_status_codes#%s' - -HTTP_STATUS_CODES = { - 100: 'Continue', - 101: 'Switching Protocols', - 102: 'Processing', - 200: 'OK', - 201: 'Created', - 202: 'Accepted', - 203: 'Non-Authoritative Information', - 204: 'No Content', - 205: 'Reset Content', - 206: 'Partial Content', - 207: 'Multi-Status', - 300: 'Multiple Choices', - 301: 'Moved Permanently', - 302: 'Found', - 303: 'See Other', - 304: 'Not Modified', - 305: 'Use Proxy', - 306: 'Switch Proxy', - 307: 'Temporary Redirect', - 400: 'Bad Request', - 401: 'Unauthorized', - 402: 'Payment Required', - 403: 'Forbidden', - 404: 'Not Found', - 405: 'Method Not Allowed', - 406: 'Not Acceptable', - 407: 'Proxy Authentication Required', - 408: 'Request Timeout', - 409: 'Conflict', - 410: 'Gone', - 411: 'Length Required', - 412: 'Precondition Failed', - 413: 'Request Entity Too Large', - 414: 'Request-URI Too Long', - 415: 'Unsupported Media Type', - 416: 'Requested Range Not Satisfiable', - 417: 'Expectation Failed', - 418: 'I\m a teapot', - 422: 'Unprocessable Entity', - 423: 'Locked', - 424: 'Failed Dependency', - 425: 'Unordered Collection', - 426: 'Upgrade Required', - 449: 'Retry With', - 450: 'Blocked by Windows Parental Controls', - 500: 'Internal Server Error', - 501: 'Not Implemented', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - 504: 'Gateway Timeout', - 505: 'HTTP Version Not Supported', - 506: 'Variant Also Negotiates', - 507: 'Insufficient Storage', - 509: 'Bandwidth Limit Exceeded', - 510: 'Not Extended', -} - - -def setup(app): - app.add_config_value('http_status_codes_url', - DEFAULT_HTTP_STATUS_CODES_URL, True) - app.add_role('http', http_role) - - -def http_role(role, rawtext, text, linenum, inliner, options={}, content=[]): - try: - status_code = int(text) - - if status_code not in HTTP_STATUS_CODES: - raise ValueError - except ValueError: - msg = inliner.reporter.error( - 'HTTP status code must be a valid HTTP status; ' - '"%s" is invalid.' % text, - line=linenum) - prb = inliner.problematic(rawtext, rawtext, msg) - return [prb], [msg] - - http_status_codes_url = \ - inliner.document.settings.env.config.http_status_codes_url - - if not http_status_codes_url or '%s' not in http_status_codes_url: - msg = inliner.reporter.error('http_status_codes_url must be ' - 'configured.', - line=linenum) - prb = inliner.problematic(rawtext, rawtext, msg) - return [prb], [msg] - - ref = http_status_codes_url % status_code - status_code_text = 'HTTP %s %s' % (status_code, - HTTP_STATUS_CODES[status_code]) - node = nodes.reference(rawtext, status_code_text, refuri=ref, **options) - - return [node], [] diff --git a/docs/djblets/_ext/retina_images.py b/docs/djblets/_ext/retina_images.py deleted file mode 100644 index 32aecfd8..00000000 --- a/docs/djblets/_ext/retina_images.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Sphinx extension for Retina images. - -This extension goes through all the images Sphinx will provide in _images and -checks if Retina versions are available. If there are any, they will be copied -as well. -""" -import os - - -def add_retina_images(app, env): - retina_images = [] - - for full_path, (docnames, filename) in env.images.iteritems(): - base, ext = os.path.splitext(full_path) - retina_path = base + '@2x' + ext - - if os.path.exists(retina_path): - retina_images += [ - (docname, retina_path) - for docname in docnames - ] - - for docname, path in retina_images: - env.images.add_file(docname, path) - - -def collect_pages(app): - new_images = {} - - for full_path, basename in app.builder.images.iteritems(): - base, ext = os.path.splitext(full_path) - retina_path = base + '@2x' + ext - - if retina_path in app.env.images: - new_images[retina_path] = app.env.images[retina_path][1] - - app.builder.images.update(new_images) - - return [] - - -def setup(app): - app.connect('env-updated', add_retina_images) - app.connect('html-collect-pages', collect_pages) diff --git a/docs/djblets/conf.py b/docs/djblets/conf.py index 92c12242..77c42a14 100644 --- a/docs/djblets/conf.py +++ b/docs/djblets/conf.py @@ -27,7 +27,7 @@ import djblets -from github_linkcode import github_linkcode_resolve +from beanbag_docutils.sphinx.ext.github import github_linkcode_resolve # General configuration @@ -43,10 +43,10 @@ 'sphinx.ext.linkcode', 'sphinx.ext.napoleon', 'sphinx.ext.todo', - 'autodoc_utils', - 'djangorefs', - 'httprole', - 'retina_images', + 'beanbag_docutils.sphinx.ext.autodoc_utils', + 'beanbag_docutils.sphinx.ext.django_utils', + 'beanbag_docutils.sphinx.ext.http_role', + 'beanbag_docutils.sphinx.ext.retina_images', ] # Add any paths that contain templates here, relative to this directory. @@ -217,11 +217,11 @@ intersphinx_mapping = { - 'django': ('https://docs.djangoproject.com/en/%s/' + 'django': ('http://django.readthedocs.io/en/%s.x/' % djblets.django_major_version, - 'https://docs.djangoproject.com/en/%s/_objects/' - % djblets.django_major_version), + None), 'python': ('https://docs.python.org/2.7', None), + 'reviewboard': ('https://www.reviewboard.org/docs/manual/2.5/', None), } todo_include_todos = True @@ -262,7 +262,24 @@ autosummary_generate = True -napolean_google_docstring = True -napolean_numpy_docstring = False +napoleon_google_docstring = True +napoleon_numpy_docstring = False -linkcode_resolve = github_linkcode_resolve + +def linkcode_resolve(domain, info): + version = djblets.VERSION + + if version[4] == 'final' or version[4] > 0: + if djblets.is_release(): + branch = 'release-%s.%s.%s' % (version[0], version[1], version[2]) + else: + branch = 'release-%s.%s.x' % (version[0], version[1]) + else: + branch = 'master' + + return github_linkcode_resolve(domain=domain, + info=info, + allowed_module_names=['djblets'], + github_org_id='djblets', + github_repo_id='djblets', + branch=branch) From eee9cb8d7c2d02e32343eecf14bc7d4421d084b8 Mon Sep 17 00:00:00 2001 From: Christian Hammond Date: Tue, 19 Jul 2016 23:45:55 -0700 Subject: [PATCH 3/4] Add infrastructure to help apps offer extension testing functionality. This provides some new classes that applications can use to help extension authors write unit tests for their extensions. There's a new test runner class that handles setting up a Django environment, configuring basic Djblets settings, and handling static media collection. This can easily form the base test runner for any Django applications, not just those with extensions, but it's also useful for extension test cases. There's also a new mixin for unit test: ExtensionTestCaseMixin. This makes it easy to get a registered/enabled extension instance for a given extension class, so that unit tests can be written utilizing that extension. Documentation has been written on making use of these in a product. Based on work by Weijie Sun. Testing Done: Updated Review Board to make use of this and modified Power Pack to use the new infrastructure. I successfully completed a unit test run. Built the docs and read through them. Tested the code samples. Reviewed at https://reviews.reviewboard.org/r/8290/ --- djblets/extensions/testing/__init__.py | 17 ++ djblets/extensions/testing/testcases.py | 95 ++++++ djblets/testing/testrunners.py | 279 ++++++++++++++++++ docs/djblets/coderef/index.rst | 3 + docs/djblets/guides/extensions/index.rst | 1 + .../guides/extensions/testing-extensions.rst | 127 ++++++++ 6 files changed, 522 insertions(+) create mode 100644 djblets/extensions/testing/__init__.py create mode 100644 djblets/extensions/testing/testcases.py create mode 100644 djblets/testing/testrunners.py create mode 100644 docs/djblets/guides/extensions/testing-extensions.rst diff --git a/djblets/extensions/testing/__init__.py b/djblets/extensions/testing/__init__.py new file mode 100644 index 00000000..505700f1 --- /dev/null +++ b/djblets/extensions/testing/__init__.py @@ -0,0 +1,17 @@ +"""Extension testing support. + +This provides handy imports for extension testing classes: + +* :py:class:`djblets.extensions.testing.testcases.ExtensionTestCaseMixin` +""" + +from __future__ import unicode_literals + +from djblets.extensions.testing.testcases import ExtensionTestCaseMixin + + +__all__ = [ + 'ExtensionTestCaseMixin', +] + +__autodoc_excludes__ = __all__ diff --git a/djblets/extensions/testing/testcases.py b/djblets/extensions/testing/testcases.py new file mode 100644 index 00000000..6e1a4c9b --- /dev/null +++ b/djblets/extensions/testing/testcases.py @@ -0,0 +1,95 @@ +"""Mixins for test cases that need to test enabled extensions.""" + +from __future__ import unicode_literals + +from djblets.extensions.extension import ExtensionInfo +from djblets.extensions.manager import get_extension_managers +from djblets.extensions.models import RegisteredExtension + + +class ExtensionTestCaseMixin(object): + """Unit tests mixin for more easily testing extensions. + + This will do the hard work of creating the fake registration information + needed for an extension and to instantiate an instance for testing. + + Subclasses need to define :py:attr:`extension_class` and may want to + implement :py:meth:`get_extension_manager` (by default, the first + registered extension manager will be used). + + Projects may want to provide their own subclass for their extensions to use + that implements :py:meth:`get_extension_manager`, so extensions won't have + to. + + Attributes: + extension_mgr (djblets.extensions.manager.ExtensionManager): + The extension manager owning the extension. Tests can use this to + manually enable/disable the extension, if needed. + + extension (djblets.extensions.extension.Extension): + The extension instance being tested. + """ + + #: The extension class to test. + extension_class = None + + #: Optional metadata to use for the extension information. + extension_metadata = {} + + #: Optional package name to use for the extension information. + extension_package_name = 'TestPackage' + + def setUp(self): + super(ExtensionTestCaseMixin, self).setUp() + + self.extension_mgr = self.get_extension_manager() + + # We want to override all the information, even if a previous test + # already set it. The metadata may be different, and the registration + # definitely needs to be replaced (as it contains extension settings). + extension_id = '%s.%s' % (self.extension_class.__module__, + self.extension_class.__name__) + + self.extension_class.id = extension_id + self.extension_class.info = ExtensionInfo( + ext_class=self.extension_class, + package_name=self.extension_package_name, + metadata=self.extension_metadata) + + self.extension_class.registration = RegisteredExtension.objects.create( + class_name=extension_id, + name=self.extension_class.info.name, + enabled=True, + installed=True) + + # We're going to manually inject the extension, instead of calling + # load(), since it might not be found otherwise. + self.extension_mgr._extension_classes[extension_id] = \ + self.extension_class + + self.extension = self.extension_mgr.enable_extension(extension_id) + assert self.extension + + def tearDown(self): + super(ExtensionTestCaseMixin, self).tearDown() + + if self.extension.info.enabled: + # Manually shut down the extension first, before we have the + # extension manager disable it. This will help ensure we have the + # right state up-front. + self.extension.shutdown() + + self.extension_mgr.disable_extension(self.extension_class.id) + + def get_extension_manager(self): + """Return the extension manager used for the tests. + + Subclasses may want to override this to pick a specific extension + manager, if the project uses more than one. The default behavior is + to return the first registered extension manager. + + Returns: + djblets.extensions.manager.ExtensionManager: + The extension manager used for tests. + """ + return get_extension_managers()[0] diff --git a/djblets/testing/testrunners.py b/djblets/testing/testrunners.py new file mode 100644 index 00000000..231fb3ae --- /dev/null +++ b/djblets/testing/testrunners.py @@ -0,0 +1,279 @@ +from __future__ import unicode_literals + +import os +import shutil +import stat +import sys +import tempfile + +import nose +from django.core.management import execute_from_command_line +from django.test.runner import DiscoverRunner + +try: + # Make sure to pre-load all the image handlers. If we do this later during + # unit tests, we don't seem to always get our list, causing tests to fail. + from PIL import Image + Image.init() +except ImportError: + try: + import Image + Image.init() + except ImportError: + pass + +from django.conf import settings +from djblets.cache.serials import generate_media_serial + + +class TestRunner(DiscoverRunner): + """Test runner for standard Djblets-based projects. + + This class provides all the common setup for settings, databases, and + directories that are generally needed by Django projects using Djblets. + Much of the behavior can be overridden by subclasses. + + `nose `_ is used to run the test + suites. The options can be configured through :py:attr:`nose_options`. + + This can be subclassed in order to set the settings for the test run, or + it can be instantiated with those settings passed in as keyword arguments. + """ + + #: The options used for nose. + #: + #: This is a list of command line arguments that would be passed to + #: :command:`nosetests`. + nose_options = [ + '-v', + '--match=^test', + '--with-id', + '--with-doctest', + '--doctest-extension=.txt', + ] + + #: A list of Python package/module names to test. + test_packages = [] + + #: Whether or not ``collectstatic`` needs to be run before tests. + needs_collect_static = True + + def __init__(self, *args, **kwargs): + """Initialize the test runner. + + The caller can override any of the options otherwise defined on the + class. + + Args: + nose_options (list, optional): + A list of options used for nose. See :py:attr:`nose_options`. + + test_packages (list, optional): + A list of Python package/module names to test. See + :py:attr:`test_packages`. + + needs_collect_static (bool, optional): + Whether or not ``collectstatic`` needs to be run before + tests. See :py:attr:`needs_collect_static`. + """ + super(TestRunner, self).__init__(*args, **kwargs) + + # Override any values that the caller wants to override. This allows + # the runner to be instantiated with the desired arguments instead + # of subclassed. + try: + self.nose_options = kwargs['nose_options'] + except KeyError: + pass + + try: + self.test_packages = kwargs['test_packages'] + except KeyError: + pass + + try: + self.needs_collect_static = kwargs['needs_collect_static'] + except KeyError: + pass + + def setup_test_environment(self, *args, **kwargs): + """Set up an environment for the unit tests. + + This will handle setting all the default settings for a Djblets-based + project and will create the directory structure needed for the tests + in a temp directory. + + Subclasses can override this to provide additional setup logic. + + This must be called before :py:meth:`run_tests`. + + Args: + *args (tuple): + Additional positional arguments to pass to Django's version + of this method. + + **kwargs (dict): + Additional keyword arguments to pass to Django's version + of this method. + """ + super(TestRunner, self).setup_test_environment(*args, **kwargs) + + # Default to testing in a non-subdir install. + settings.SITE_ROOT = '/' + + # Set some defaults for cache serials, in case the tests need them. + settings.AJAX_SERIAL = 123 + settings.TEMPLATE_SERIAL = 123 + + # Set a faster password hasher, for performance. + settings.PASSWORD_HASHERS = ( + 'django.contrib.auth.hashers.SHA1PasswordHasher', + ) + + # Make sure we're using standard static files storage, and not + # something like Pipeline or S3 (since we don't want to trigger any + # special behavior). Subclasses are free to override this setting. + settings.STATICFILES_STORAGE = \ + 'django.contrib.staticfiles.storage.StaticFilesStorage' + + # Create a temp directory that tests can rely upon. + self.tempdir = tempfile.mkdtemp(prefix='rb-tests-') + + # Configure file paths for static media. This will handle the main + # static and uploaded media directories, along with extension + # directories (for projects that need to use them). + settings.STATIC_URL = settings.SITE_ROOT + 'static/' + settings.MEDIA_URL = settings.SITE_ROOT + 'media/' + settings.STATIC_ROOT = os.path.join(self.tempdir, 'static') + settings.MEDIA_ROOT = os.path.join(self.tempdir, 'media') + + required_dirs = self.setup_dirs() + [ + settings.STATIC_ROOT, + settings.MEDIA_ROOT, + os.path.join(settings.MEDIA_ROOT, 'ext'), + os.path.join(settings.STATIC_ROOT, 'ext'), + ] + + for dirname in required_dirs: + if not os.path.exists(dirname): + os.makedirs(dirname) + + if self.needs_collect_static: + # Collect all static media needed for tests. + execute_from_command_line([ + __file__, 'collectstatic', '--noinput', '-v', '0', + ]) + + generate_media_serial() + + def teardown_test_environment(self, *args, **kwargs): + """Tear down the environment for the unit tests. + + This will clean up the temp directory structure.It must be called after + :py:meth:`run_tests`. + + Args: + *args (tuple): + Additional positional arguments to pass to Django's version + of this method. + + **kwargs (dict): + Additional keyword arguments to pass to Django's version + of this method. + """ + shutil.rmtree(self.tempdir) + + super(TestRunner, self).teardown_test_environment(*args, **kwargs) + + def run_tests(self, test_labels=[], argv=None, *args, **kwargs): + """Run the test suite. + + Args: + test_labels (list of unicode, optional): + Specific tests to run. + + argv (list of unicode, optional): + Additional arguments for nose. If not specified, sys.argv is + used. + + *args (tuple, unused): + Unused additional positional arguments. + + **kwargs (dict, unused): + Unused additional keyword arguments. + + Returns: + int: + The exit code. 0 means all tests passed, while 1 means there were + failures. + """ + if argv is None: + argv = sys.argv + + self.setup_test_environment() + old_config = self.setup_databases() + + self.nose_argv = [argv[0]] + self.nose_options + + if '--with-coverage' in argv: + self.nose_argv += ['--with-coverage'] + [ + '--cover-package=%s' % package_name + for package_name in self.test_packages + ] + argv.remove('--with-coverage') + + # If the test files are executable on the file system, nose will need + # the --exe argument to run them + known_file = os.path.join(os.path.dirname(__file__), '__init__.py') + + if (os.path.exists(known_file) and + os.stat(known_file).st_mode & stat.S_IXUSR): + self.nose_argv.append('--exe') + + # manage.py captures everything before "--" + if len(argv) > 2 and '--' in argv: + self.nose_argv += argv[(argv.index('--') + 1):] + + if test_labels: + self.nose_argv += test_labels + else: + # If specific tests are not requested, test all the configured + # test packages. + self.nose_argv += self.test_packages + + self.run_nose() + + self.teardown_databases(old_config) + self.teardown_test_environment() + + if self.result.success: + return 0 + else: + return 1 + + def setup_dirs(self): + """Set up directories to create and use. + + This can return one or more directory paths that need to be created + before the tests can be run. It may also store settings pointing to + those paths. + + This is not responsible for creating the directories. Any returned + paths will be created automatically. + + Returns: + list of unicode: + A list of directory paths to create. + """ + return [] + + def run_nose(self): + """Run the unit tests using nose. + + This will use nose to run the tests, storing the result. + + Returns: + nose.core.TestProgram: + The result from the run. + """ + self.result = nose.main(argv=self.nose_argv, exit=False) diff --git a/docs/djblets/coderef/index.rst b/docs/djblets/coderef/index.rst index 990abab0..ed1ccd38 100644 --- a/docs/djblets/coderef/index.rst +++ b/docs/djblets/coderef/index.rst @@ -90,6 +90,8 @@ Extensions djblets.extensions.settings djblets.extensions.signals djblets.extensions.staticfiles + djblets.extensions.testing + djblets.extensions.testing.testcases djblets.extensions.urls djblets.extensions.views djblets.extensions.templatetags.djblets_extensions @@ -190,6 +192,7 @@ Testing Helpers djblets.testing.decorators djblets.testing.testcases + djblets.testing.testrunners URL Utilities diff --git a/docs/djblets/guides/extensions/index.rst b/docs/djblets/guides/extensions/index.rst index 879f90a7..64990b00 100644 --- a/docs/djblets/guides/extensions/index.rst +++ b/docs/djblets/guides/extensions/index.rst @@ -6,3 +6,4 @@ Extension Guides :maxdepth: 2 writing-extensions + testing-extensions diff --git a/docs/djblets/guides/extensions/testing-extensions.rst b/docs/djblets/guides/extensions/testing-extensions.rst new file mode 100644 index 00000000..b4c9a72a --- /dev/null +++ b/docs/djblets/guides/extensions/testing-extensions.rst @@ -0,0 +1,127 @@ +.. _testing-extensions: + +================== +Testing Extensions +================== + +.. currentmodule:: djblets.extensions.testing.testcases +.. versionadded:: 0.10 + + +Overview +======== + +To ensure that your extension works as expected and continues to work in the +future, it's recommended that your extension come with some unit tests. + +Starting in Djblets 0.10, we make it much easier to write unit tests for your +extension. Your extension will need to ship with two things: A set of test +cases, and a test runner. + + +Writing Test Cases +================== + +Extension test cases can use a mixin, :py:class:`ExtensionTestCaseMixin`, to +set up and enable an extension instance to test against. This takes an +extension class as a class attribute, and optionally allows custom metadata +and a package name to be set. The individual tests can then make use of an +extension instance. + +For example: + +.. code-block:: python + + from django.test import TestCase + from djblets.extensions.testing import ExtensionTestCaseMixin + + from my_extension.extension import MyExtension + + + class MyExtensionTests(ExtensionTestCaseMixin, TestCase): + extension_class = MyExtension + + def test_something(self): + self.assertEqual(self.extension.some_call(), 'some value') + + +Extensions may want to create a base class that utilizes the mixins and sets +the :py:attr:`~ExtensionTestCaseMixin.extension_class` attribute, and subclass +that for all individual test suites. For example: + +.. code-block:: python + + from django.test import TestCase + from djblets.extensions.testing import ExtensionTestCaseMixin + + from my_extension.extension import MyExtension + + + class MyExtensionTestCase(ExtensionTestCaseMixin, TestCase): + extension_class = MyExtension + + + class MyExtensionTests(MyExtensionTestCase): + def test_something(self): + self.assertEqual(self.extension.some_call(), 'some value') + + +.. note:: + + If you're writing extensions for `Review Board`_, you'll want to use + :py:class:`reviewboard.testing.testcase.TestCase` instead of + :py:class:`django.test.TestCase` for the base class. + + +.. _Review Board: https://www.reviewboard.org/ + + +Writing a Test Runner +===================== + +Your extension will also need a test runner. Djblets offers a handy one +built-in that's ready for extensions to use: +:py:class:`djblets.testing.testrunners.TestRunner`. + +To make use of this, you'll want to create a subclass with some state, set up +your Django environment for your project, and invoke the test runner. + +.. note:: + + If you're developing extensions for Review Board, you don't need to do this + at all. Instead, you'll use the :command:`rbext test` command to run your + tests. + +A test runner script might look like: + +.. code-block:: python + + #!/usr/bin/env python + + import os + import sys + + os.environ['DJANGO_SETTINGS_MODULE'] = 'myapp.settings' + + from djblets.testing.testrunners import TestRunner + + test_runner = TestRunner(test_packages=['my_extension']) + + # Run the test suite, passing any specific test names to run that the + # user may have specified on the command line. + failures = test_runner.run_tests(sys.argv[1:]) + + if failures: + sys.exit(1) + +A couple things to note: + +1. You'll need to adjust this to point to the correct Django settings module + for the project that uses the extension, and you also may need to set up + other state for the environment (for example, the project might require + a :file:`settings_local.py` or similar that contains database settings or + other such data). + +2. The project you're developing extensions for may have its own specialized + test runner to use that sets up additional stuff for you. Follow the + project's documentation. From 6b69e0a6f4e03461e25669b4741b6c598eb1a6a1 Mon Sep 17 00:00:00 2001 From: Christian Hammond Date: Mon, 11 Jul 2016 20:26:29 -0700 Subject: [PATCH 4/4] Fix unit test failures for WebAPITokens due to model conflicts. When running the entire test suite for Djblets, we'd see errors with certain field accesses on BaseWebAPIToken subclasses. This was due to the Django model metaclass machinery causing issues when two models shared the same name. To solve this, we're doing what all other test models do: Giving them their own unique names. This results in all the unit tests passing. Testing Done: Unit tests pass. Reviewed at https://reviews.reviewboard.org/r/8280/ --- djblets/webapi/tests/test_api_policy.py | 46 ++++++++++++------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/djblets/webapi/tests/test_api_policy.py b/djblets/webapi/tests/test_api_policy.py index 9a6cb11e..30fb7ab7 100644 --- a/djblets/webapi/tests/test_api_policy.py +++ b/djblets/webapi/tests/test_api_policy.py @@ -22,7 +22,7 @@ class SomeObjectResource(ResourceAPITokenMixin, WebAPIResource): ]) -class WebAPIToken(BaseWebAPIToken): +class APIPolicyWebAPIToken(BaseWebAPIToken): @classmethod def get_root_resource(self): return root_resource @@ -327,13 +327,13 @@ class APIPolicyValidationTests(TestCase): """Tests API policy validation.""" def test_empty(self): """Testing BaseWebAPIToken.validate_policy with empty policy""" - WebAPIToken.validate_policy({}) + APIPolicyWebAPIToken.validate_policy({}) def test_not_object(self): """Testing BaseWebAPIToken.validate_policy without JSON object""" self.assertRaisesValidationError( 'The policy must be a JSON object.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, []) # @@ -346,7 +346,7 @@ def test_no_resources_section(self): """ self.assertRaisesValidationError( 'The policy is missing a "resources" section.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'foo': {} }) @@ -356,7 +356,7 @@ def test_resources_empty(self): """ self.assertRaisesValidationError( 'The policy\'s "resources" section must not be empty.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': {} }) @@ -366,7 +366,7 @@ def test_resources_invalid_format(self): """ self.assertRaisesValidationError( 'The policy\'s "resources" section must be a JSON object.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': [] }) @@ -377,7 +377,7 @@ def test_resources_invalid_format(self): def test_global_valid(self): """Testing BaseWebAPIToken.validate_policy with valid '*' section""" - WebAPIToken.validate_policy({ + APIPolicyWebAPIToken.validate_policy({ 'resources': { '*': { 'allow': ['*'], @@ -391,7 +391,7 @@ def test_empty_global(self): self.assertRaisesValidationError( 'The "resources.*" section must have "allow" and/or "block" ' 'rules.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': { '*': {} @@ -404,7 +404,7 @@ def test_global_not_object(self): """ self.assertRaisesValidationError( 'The "resources.*" section must be a JSON object.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': { '*': [] @@ -415,7 +415,7 @@ def test_global_allow_not_list(self): """Testing BaseWebAPIToken.validate_policy with *.allow not a list""" self.assertRaisesValidationError( 'The "resources.*" section\'s "allow" rule must be a list.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': { '*': { @@ -428,7 +428,7 @@ def test_global_block_not_list(self): """Testing BaseWebAPIToken.validate_policy with *.block not a list""" self.assertRaisesValidationError( 'The "resources.*" section\'s "block" rule must be a list.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': { '*': { @@ -443,7 +443,7 @@ def test_global_block_not_list(self): def test_resource_global_valid(self): """Testing BaseWebAPIToken.validate_policy with .* valid""" - WebAPIToken.validate_policy({ + APIPolicyWebAPIToken.validate_policy({ 'resources': { 'someobject': { '*': { @@ -459,7 +459,7 @@ def test_resource_global_empty(self): self.assertRaisesValidationError( 'The "resources.someobject.*" section must have "allow" and/or ' '"block" rules.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': { 'someobject': { @@ -474,7 +474,7 @@ def test_resource_global_invalid_policy_id(self): """ self.assertRaisesValidationError( '"foobar" is not a valid resource policy ID.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': { 'foobar': { @@ -491,7 +491,7 @@ def test_resource_global_not_object(self): """ self.assertRaisesValidationError( 'The "resources.someobject.*" section must be a JSON object.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': { 'someobject': { @@ -507,7 +507,7 @@ def test_resource_global_allow_not_list(self): self.assertRaisesValidationError( 'The "resources.someobject.*" section\'s "allow" rule must be a ' 'list.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': { 'someobject': { @@ -525,7 +525,7 @@ def test_resource_global_block_not_list(self): self.assertRaisesValidationError( 'The "resources.someobject.*" section\'s "block" rule must be a ' 'list.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': { 'someobject': { @@ -543,7 +543,7 @@ def test_resource_global_block_not_list(self): def test_resource_id_valid(self): """Testing BaseWebAPIToken.validate_policy with . valid """ - WebAPIToken.validate_policy({ + APIPolicyWebAPIToken.validate_policy({ 'resources': { 'someobject': { '42': { @@ -560,7 +560,7 @@ def test_resource_id_empty(self): self.assertRaisesValidationError( 'The "resources.someobject.42" section must have "allow" and/or ' '"block" rules.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': { 'someobject': { @@ -575,7 +575,7 @@ def test_resource_id_invalid_id_type(self): """ self.assertRaisesValidationError( '42 must be a string in "resources.someobject"', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': { 'someobject': { @@ -592,7 +592,7 @@ def test_resource_id_not_object(self): """ self.assertRaisesValidationError( 'The "resources.someobject.42" section must be a JSON object.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': { 'someobject': { @@ -608,7 +608,7 @@ def test_resource_id_allow_not_list(self): self.assertRaisesValidationError( 'The "resources.someobject.42" section\'s "allow" rule must ' 'be a list.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': { 'someobject': { @@ -626,7 +626,7 @@ def test_resource_id_block_not_list(self): self.assertRaisesValidationError( 'The "resources.someobject.42" section\'s "block" rule must ' 'be a list.', - WebAPIToken.validate_policy, + APIPolicyWebAPIToken.validate_policy, { 'resources': { 'someobject': {