From 014cb9e564e04977ae4852167f923ac10116e898 Mon Sep 17 00:00:00 2001
From: Calen Pennington <cale@edx.org>
Date: Wed, 30 Apr 2014 10:17:27 -0400
Subject: [PATCH] Make course ids and usage ids opaque to LMS and Studio
 [partial commit]

This commit is the base library for OpaqueKeys.

These keys are now objects with a limited interface, and the particular
internal representation is managed by the data storage layer (the
modulestore).

For the LMS, there should be no outward-facing changes to the system.
The keys are, for now, a change to internal representation only. For
Studio, the new serialized form of the keys is used in urls, to allow
for further migration in the future.

Co-Author: Andy Armstrong <andya@edx.org>
Co-Author: Christina Roberts <christina@edx.org>
Co-Author: David Baumgold <db@edx.org>
Co-Author: Diana Huang <dkh@edx.org>
Co-Author: Don Mitchell <dmitchell@edx.org>
Co-Author: Julia Hansbrough <julia@edx.org>
Co-Author: Nimisha Asthagiri <nasthagiri@edx.org>
Co-Author: Sarina Canelake <sarina@edx.org>

[LMS-2370]
---
 .../lib/opaque_keys/opaque_keys/__init__.py   | 247 ++++++++++++++++++
 .../opaque_keys/opaque_keys/tests/__init__.py |   0
 .../opaque_keys/tests/test_opaque_keys.py     | 182 +++++++++++++
 common/lib/opaque_keys/setup.py               |  19 ++
 requirements/edx/base.txt                     |   1 +
 requirements/edx/local.txt                    |   1 +
 6 files changed, 450 insertions(+)
 create mode 100644 common/lib/opaque_keys/opaque_keys/__init__.py
 create mode 100644 common/lib/opaque_keys/opaque_keys/tests/__init__.py
 create mode 100644 common/lib/opaque_keys/opaque_keys/tests/test_opaque_keys.py
 create mode 100644 common/lib/opaque_keys/setup.py

diff --git a/common/lib/opaque_keys/opaque_keys/__init__.py b/common/lib/opaque_keys/opaque_keys/__init__.py
new file mode 100644
index 00000000000..3a33add8a14
--- /dev/null
+++ b/common/lib/opaque_keys/opaque_keys/__init__.py
@@ -0,0 +1,247 @@
+"""
+Defines OpaqueKey class, to be used as the base-class for
+implementing pluggable OpaqueKeys.
+"""
+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 OpaqueKeys. Automatically derives the class from a namedtuple
+    with a fieldset equal to the KEY_FIELDS class attribute, if KEY_FIELDS is set.
+    """
+    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
+        |
+    KeyType
+        |
+    KeyImplementation
+
+    The KeyType 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 CANONICAL_NAMESPACE and KEY_FIELDS.
+        CANONICAL_NAMESPACE: Identifies the key namespace for the particular
+            key_implementation (when serializing). KeyImplementations must be
+            registered using the CANONICAL_NAMESPACE is 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. KeyImplementation 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.
+
+    OpaqueKeys will not have optional constructor parameters (due to the implementation of
+    KEY_FIELDS), by default. However, and implementation class can provide a default,
+    as long as it passes that default to a call to super().__init__.
+
+    OpaqueKeys are immutable.
+    """
+    __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):
+        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):
+        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)
diff --git a/common/lib/opaque_keys/opaque_keys/tests/__init__.py b/common/lib/opaque_keys/opaque_keys/tests/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/common/lib/opaque_keys/opaque_keys/tests/test_opaque_keys.py b/common/lib/opaque_keys/opaque_keys/tests/test_opaque_keys.py
new file mode 100644
index 00000000000..4f9c8ef5a90
--- /dev/null
+++ b/common/lib/opaque_keys/opaque_keys/tests/test_opaque_keys.py
@@ -0,0 +1,182 @@
+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
diff --git a/common/lib/opaque_keys/setup.py b/common/lib/opaque_keys/setup.py
new file mode 100644
index 00000000000..eaf0ec48971
--- /dev/null
+++ b/common/lib/opaque_keys/setup.py
@@ -0,0 +1,19 @@
+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',
+        ]
+    }
+)
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 466cadcd660..02f3a7ad968 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -70,6 +70,7 @@ Shapely==1.2.16
 singledispatch==3.4.0.2
 sorl-thumbnail==11.12
 South==0.7.6
+stevedore==0.14.1
 sure==1.2.3
 sympy==0.7.1
 xmltodict==0.4.1
diff --git a/requirements/edx/local.txt b/requirements/edx/local.txt
index 0e775d04e3f..e56d8dc8ace 100644
--- a/requirements/edx/local.txt
+++ b/requirements/edx/local.txt
@@ -3,6 +3,7 @@
 -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
-- 
GitLab