Commit 81d785d1 by Calen Pennington

Merge pull request #3862 from cpennington/external-opaque-keys

Extract opaque_keys into a separate library
parents 502b285b ed7a631c
"""
Defines the :class:`OpaqueKey` class, to be used as the base-class for
implementing pluggable OpaqueKeys.
These keys are designed to provide a limited, forward-evolveable interface to
an application, while concealing the particulars of the serialization
formats, and allowing new serialization formats to be installed transparently.
"""
from abc import ABCMeta, abstractmethod, abstractproperty
from copy import deepcopy
from collections import namedtuple
from functools import total_ordering
from stevedore.enabled import EnabledExtensionManager
class InvalidKeyError(Exception):
"""
Raised to indicated that a serialized key isn't valid (wasn't able to be parsed
by any available providers).
"""
def __init__(self, key_class, serialized):
super(InvalidKeyError, self).__init__(u'{}: {}'.format(key_class, serialized))
class OpaqueKeyMetaclass(ABCMeta):
"""
Metaclass for :class:`OpaqueKey`. Sets the default value for the values in ``KEY_FIELDS`` to
``None``.
"""
def __new__(mcs, name, bases, attrs):
if '__slots__' not in attrs:
for field in attrs.get('KEY_FIELDS', []):
attrs.setdefault(field, None)
return super(OpaqueKeyMetaclass, mcs).__new__(mcs, name, bases, attrs)
@total_ordering
class OpaqueKey(object):
"""
A base-class for implementing pluggable opaque keys. Individual key subclasses identify
particular types of resources, without specifying the actual form of the key (or
its serialization).
There are two levels of expected subclasses: Key type definitions, and key implementations
::
OpaqueKey
|
Key type
|
Key implementation
The key type base class must define the class property ``KEY_TYPE``, which identifies
which ``entry_point`` namespace the keys implementations should be registered with.
The KeyImplementation classes must define the following:
``CANONICAL_NAMESPACE``
Identifies the key namespace for the particular key implementation
(when serializing). Key implementations must be registered using the
``CANONICAL_NAMESPACE`` as their entry_point name, but can also be registered
with other names for backwards compatibility.
``KEY_FIELDS``
A list of attribute names that will be used to establish object
identity. Key implementation instances will compare equal iff all of
their ``KEY_FIELDS`` match, and will not compare equal to instances
of different KeyImplementation classes (even if the ``KEY_FIELDS`` match).
These fields must be hashable.
``_to_string``
Serialize the key into a unicode object. This should not include the namespace
prefix (``CANONICAL_NAMESPACE``).
``_from_string``
Construct an instance of this :class:`OpaqueKey` from a unicode object. The namespace
will already have been parsed.
OpaqueKeys will not have optional constructor parameters (due to the implementation of
``KEY_FIELDS``), by default. However, an implementation class can provide a default,
as long as it passes that default to a call to ``super().__init__``.
:class:`OpaqueKey` objects are immutable.
Serialization of an :class:`OpaqueKey` is performed by using the :func:`unicode` builtin.
Deserialization is performed by the :meth:`from_string` method.
"""
__metaclass__ = OpaqueKeyMetaclass
__slots__ = ('_initialized')
NAMESPACE_SEPARATOR = u':'
@classmethod
@abstractmethod
def _from_string(cls, serialized):
"""
Return an instance of `cls` parsed from its `serialized` form.
Args:
cls: The :class:`OpaqueKey` subclass.
serialized (unicode): A serialized :class:`OpaqueKey`, with namespace already removed.
Raises:
InvalidKeyError: Should be raised if `serialized` is not a valid serialized key
understood by `cls`.
"""
raise NotImplementedError()
@abstractmethod
def _to_string(self):
"""
Return a serialization of `self`.
This serialization should not include the namespace prefix.
"""
raise NotImplementedError()
@classmethod
def _separate_namespace(cls, serialized):
"""
Return the namespace from a serialized :class:`OpaqueKey`, and
the rest of the key.
Args:
serialized (unicode): A serialized :class:`OpaqueKey`.
Raises:
MissingNamespace: Raised when no namespace can be
extracted from `serialized`.
"""
namespace, _, rest = serialized.partition(cls.NAMESPACE_SEPARATOR)
# If ':' isn't found in the string, then the source string
# is returned as the first result (i.e. namespace)
if namespace == serialized:
raise InvalidKeyError(cls, serialized)
return (namespace, rest)
def __init__(self, *args, **kwargs):
# pylint: disable=no-member
if len(args) + len(kwargs) != len(self.KEY_FIELDS):
raise TypeError('__init__() takes exactly {} arguments ({} given)'.format(
len(self.KEY_FIELDS),
len(args) + len(kwargs)
))
keyed_args = dict(zip(self.KEY_FIELDS, args))
overlapping_args = keyed_args.viewkeys() & kwargs.viewkeys()
if overlapping_args:
raise TypeError('__init__() got multiple values for keyword argument {!r}'.format(overlapping_args[0]))
keyed_args.update(kwargs)
for key, value in keyed_args.viewitems():
if key not in self.KEY_FIELDS:
raise TypeError('__init__() got an unexpected argument {!r}'.format(key))
setattr(self, key, value)
self._initialized = True
def replace(self, **kwargs):
"""
Return: a new :class:`OpaqueKey` with ``KEY_FIELDS`` specified in ``kwargs`` replaced
their corresponding values.
"""
existing_values = {key: getattr(self, key) for key in self.KEY_FIELDS} # pylint: disable=no-member
existing_values.update(kwargs)
return type(self)(**existing_values)
def __setattr__(self, name, value):
if getattr(self, '_initialized', False):
raise AttributeError("Can't set {!r}. OpaqueKeys are immutable.".format(name))
super(OpaqueKey, self).__setattr__(name, value)
def __delattr__(self, name):
raise AttributeError("Can't delete {!r}. OpaqueKeys are immutable.".format(name))
def __unicode__(self):
"""
Serialize this :class:`OpaqueKey`, in the form ``<CANONICAL_NAMESPACE>:<value of _to_string>``.
"""
return self.NAMESPACE_SEPARATOR.join([self.CANONICAL_NAMESPACE, self._to_string()]) # pylint: disable=no-member
def __copy__(self):
return self.replace()
def __deepcopy__(self, memo):
return self.replace(**{
key: deepcopy(getattr(self, key), memo) for key in self.KEY_FIELDS # pylint: disable=no-member
})
def __setstate__(self, state_dict):
# used by pickle to set fields on an unpickled object
for key in state_dict:
if key in self.KEY_FIELDS: # pylint: disable=no-member
setattr(self, key, state_dict[key])
def __getstate__(self):
# used by pickle to get fields on an unpickled object
pickleable_dict = {}
for key in self.KEY_FIELDS: # pylint: disable=no-member
pickleable_dict[key] = getattr(self, key)
return pickleable_dict
@property
def _key(self):
"""Returns a tuple of key fields"""
return tuple(getattr(self, field) for field in self.KEY_FIELDS) # pylint: disable=no-member
def __eq__(self, other):
return (
type(self) == type(other) and
self._key == other._key # pylint: disable=protected-access
)
def __ne__(self, other):
return not (self == other)
def __lt__(self, other):
if type(self) != type(other):
raise TypeError()
return self._key < other._key # pylint: disable=protected-access
def __hash__(self):
return hash(self._key)
def __str__(self):
return unicode(self).encode('utf-8')
def __repr__(self):
return '{}({})'.format(
self.__class__.__name__,
', '.join(repr(getattr(self, key)) for key in self.KEY_FIELDS) # pylint: disable=no-member
)
def __len__(self):
"""Return the number of characters in the serialized OpaqueKey"""
return len(unicode(self))
@classmethod
def _drivers(cls):
"""
Return a driver manager for all key classes that are
subclasses of `cls`.
"""
return EnabledExtensionManager(
cls.KEY_TYPE, # pylint: disable=no-member
check_func=lambda extension: issubclass(extension.plugin, cls),
invoke_on_load=False,
)
@classmethod
def from_string(cls, serialized):
"""
Return a :class:`OpaqueKey` object deserialized from
the `serialized` argument. This object will be an instance
of a subclass of the `cls` argument.
Args:
serialized: A stringified form of a :class:`OpaqueKey`
"""
if serialized is None:
raise InvalidKeyError(cls, serialized)
# pylint: disable=protected-access
namespace, rest = cls._separate_namespace(serialized)
try:
return cls._drivers()[namespace].plugin._from_string(rest)
except KeyError:
raise InvalidKeyError(cls, serialized)
import copy
import json
from unittest import TestCase
from stevedore.extension import Extension
from mock import Mock
from opaque_keys import OpaqueKey, InvalidKeyError
def _mk_extension(name, cls):
return Extension(
name,
Mock(name='entry_point_{}'.format(name)),
cls,
Mock(name='obj_{}'.format(name)),
)
class DummyKey(OpaqueKey):
"""
Key type for testing
"""
KEY_TYPE = 'opaque_keys.testing'
__slots__ = ()
class HexKey(DummyKey):
KEY_FIELDS = ('value',)
__slots__ = KEY_FIELDS
def _to_string(self):
return hex(self._value)
@classmethod
def _from_string(cls, serialized):
if not serialized.startswith('0x'):
raise InvalidKeyError(cls, serialized)
try:
return cls(int(serialized, 16))
except (ValueError, TypeError):
raise InvalidKeyError(cls, serialized)
class Base10Key(DummyKey):
KEY_FIELDS = ('value',)
# Deliberately not using __slots__, to test both cases
def _to_string(self):
return unicode(self._value)
@classmethod
def _from_string(cls, serialized):
try:
return cls(int(serialized))
except (ValueError, TypeError):
raise InvalidKeyError(cls, serialized)
class DictKey(DummyKey):
KEY_FIELDS = ('value',)
__slots__ = KEY_FIELDS
def _to_string(self):
return json.dumps(self._value)
@classmethod
def _from_string(cls, serialized):
try:
return cls(json.loads(serialized))
except (ValueError, TypeError):
raise InvalidKeyError(cls, serialized)
class KeyTests(TestCase):
def test_namespace_from_string(self):
hex_key = DummyKey.from_string('hex:0x10')
self.assertIsInstance(hex_key, HexKey)
self.assertEquals(hex_key.value, 16)
base_key = DummyKey.from_string('base10:15')
self.assertIsInstance(base_key, Base10Key)
self.assertEquals(base_key.value, 15)
def test_unknown_namespace(self):
with self.assertRaises(InvalidKeyError):
DummyKey.from_string('no_namespace:0x10')
def test_no_namespace_from_string(self):
with self.assertRaises(InvalidKeyError):
DummyKey.from_string('0x10')
with self.assertRaises(InvalidKeyError):
DummyKey.from_string('15')
def test_immutability(self):
key = HexKey(10)
with self.assertRaises(AttributeError):
key.value = 11 # pylint: disable=attribute-defined-outside-init
def test_equality(self):
self.assertEquals(DummyKey.from_string('hex:0x10'), DummyKey.from_string('hex:0x10'))
self.assertNotEquals(DummyKey.from_string('hex:0x10'), DummyKey.from_string('base10:16'))
def test_constructor(self):
with self.assertRaises(TypeError):
HexKey()
with self.assertRaises(TypeError):
HexKey(foo='bar')
with self.assertRaises(TypeError):
HexKey(10, 20)
with self.assertRaises(TypeError):
HexKey(value=10, bar=20)
self.assertEquals(HexKey(10).value, 10)
self.assertEquals(HexKey(value=10).value, 10)
def test_replace(self):
hex10 = HexKey(10)
hex11 = hex10.replace(value=11)
hex_copy = hex10.replace()
self.assertNotEquals(id(hex10), id(hex11))
self.assertNotEquals(id(hex10), id(hex_copy))
self.assertNotEquals(hex10, hex11)
self.assertEquals(hex10, hex_copy)
self.assertEquals(HexKey(10), hex10)
self.assertEquals(HexKey(11), hex11)
def test_copy(self):
original = DictKey({'foo': 'bar'})
copied = copy.copy(original)
deep = copy.deepcopy(original)
self.assertEquals(original, copied)
self.assertNotEquals(id(original), id(copied))
self.assertEquals(id(original.value), id(copied.value))
self.assertEquals(original, deep)
self.assertNotEquals(id(original), id(deep))
self.assertNotEquals(id(original.value), id(deep.value))
self.assertEquals(copy.deepcopy([original]), [original])
def test_subclass(self):
with self.assertRaises(InvalidKeyError):
HexKey.from_string('base10:15')
with self.assertRaises(InvalidKeyError):
Base10Key.from_string('hex:0x10')
def test_ordering(self):
ten = HexKey(value=10)
eleven = HexKey(value=11)
self.assertLess(ten, eleven)
self.assertLessEqual(ten, ten)
self.assertLessEqual(ten, eleven)
self.assertGreater(eleven, ten)
self.assertGreaterEqual(eleven, eleven)
self.assertGreaterEqual(eleven, ten)
def test_non_ordering(self):
# Verify that different key types aren't comparable
ten = HexKey(value=10)
twelve = Base10Key(value=12)
# pylint: disable=pointless-statement
with self.assertRaises(TypeError):
ten < twelve
with self.assertRaises(TypeError):
ten > twelve
with self.assertRaises(TypeError):
ten <= twelve
with self.assertRaises(TypeError):
ten >= twelve
from setuptools import setup
setup(
name="opaque_keys",
version="0.1",
packages=[
"opaque_keys",
],
install_requires=[
"stevedore"
],
entry_points={
'opaque_keys.testing': [
'base10 = opaque_keys.tests.test_opaque_keys:Base10Key',
'hex = opaque_keys.tests.test_opaque_keys:HexKey',
'dict = opaque_keys.tests.test_opaque_keys:DictKey',
]
}
)
......@@ -27,6 +27,7 @@
-e git+https://github.com/edx-solutions/django-splash.git@9965a53c269666a30bb4e2b3f6037c138aef2a55#egg=django-splash
-e git+https://github.com/edx/acid-block.git@459aff7b63db8f2c5decd1755706c1a64fb4ebb1#egg=acid-xblock
-e git+https://github.com/edx/edx-ora2.git@release-2014-05-23T16.59#egg=edx-ora2
-e git+https://github.com/edx/opaque-keys.git@1f5ab1abd8273559795b0460e74658e7cd8adc8d#egg=opaque-keys
# Prototype XBlocks for limited roll-outs and user testing. These are not for general use.
-e git+https://github.com/pmitros/ConceptXBlock.git@2376fde9ebdd83684b78dde77ef96361c3bd1aa0#egg=concept-xblock
......@@ -3,7 +3,6 @@
-e common/lib/calc
-e common/lib/capa
-e common/lib/chem
-e common/lib/opaque_keys
-e common/lib/sandbox-packages
-e common/lib/symmath
-e common/lib/xmodule
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment