diff --git a/opaque_keys/edx/keys.py b/opaque_keys/edx/keys.py index 72e29684..11739935 100644 --- a/opaque_keys/edx/keys.py +++ b/opaque_keys/edx/keys.py @@ -61,6 +61,13 @@ def make_asset_key(self, asset_type, path): # pragma: no cover """ raise NotImplementedError() + @abstractmethod + def make_course_key_v2(self): # pragma: no cover + """ + Returns a course key v2 object without run information. + """ + raise NotImplementedError() + class DefinitionKey(OpaqueKey): """ @@ -77,6 +84,37 @@ def block_type(self): # pragma: no cover raise NotImplementedError() +class CourseKeyV2(OpaqueKey): + """ + An :class:`opaque_keys.OpaqueKey` identifying a + serialized Course Key object. + """ + KEY_TYPE = 'course_key_v2' + __slots__ = () + + @abstractproperty + def from_course_run_key(self, course_key): # pragma: no cover + """ + Get course key v2 from the course run key. + + Arguments: + course_key (:class:`CourseKey`): The course identifier. + + """ + raise NotImplementedError() + + @abstractproperty + def make_course_run_key(self, course_run): # pragma: no cover + """ + Get course run key (course_key) from the course key v2. + + Arguments: + course_run (str): The course run for course run identifier. + + """ + raise NotImplementedError() + + class CourseObjectMixin(object): """ An abstract :class:`opaque_keys.OpaqueKey` mixin diff --git a/opaque_keys/edx/locator.py b/opaque_keys/edx/locator.py index 0dcdaac1..58f1d638 100644 --- a/opaque_keys/edx/locator.py +++ b/opaque_keys/edx/locator.py @@ -16,7 +16,7 @@ from six import string_types, text_type from opaque_keys import OpaqueKey, InvalidKeyError -from opaque_keys.edx.keys import CourseKey, UsageKey, DefinitionKey, AssetKey +from opaque_keys.edx.keys import CourseKey, UsageKey, DefinitionKey, AssetKey, CourseKeyV2 log = logging.getLogger(__name__) @@ -380,6 +380,12 @@ def _from_deprecated_string(cls, serialized): return cls(*serialized.split('/'), deprecated=True) + def make_course_key_v2(self): + """ + Returns a course key v2 object without run information. + """ + return CourseLocatorV2(org=self.org, course=self.course) + CourseKey.set_deprecated_fallback(CourseLocator) @@ -574,10 +580,11 @@ def _to_deprecated_string(self): """ LibraryLocators are never deprecated. """ raise NotImplementedError - @classmethod - def _from_deprecated_string(cls, serialized): - """ LibraryLocators are never deprecated. """ - raise NotImplementedError + def make_course_key_v2(self): # pragma: no cover + """ + Returns a course key v2 object without run information. + """ + raise NotImplementedError() class BlockUsageLocator(BlockLocatorBase, UsageKey): @@ -1330,3 +1337,78 @@ def to_deprecated_list_repr(self): # Register AssetLocator as the deprecated fallback for AssetKey AssetKey.set_deprecated_fallback(AssetLocator) + + +class CourseLocatorV2(CourseKeyV2): # pylint: disable=abstract-method + """ + An CourseKeyV2 implementation class. + """ + CANONICAL_NAMESPACE = 'course-v2' + KEY_FIELDS = ('org', 'course') + __slots__ = KEY_FIELDS + + KEY_REGEX = re.compile( + r'^(?P{ALLOWED_ID_CHARS}+)\+(?P{ALLOWED_ID_CHARS}+)$'.format( + ALLOWED_ID_CHARS=Locator.ALLOWED_ID_CHARS + ) + ) + + def __init__(self, org=None, course=None, **kwargs): + """ + Construct a CourseLocatorV2. + + Arguments: + org (string): Organization identifier for the course + course (string): Course number + + """ + super(CourseLocatorV2, self).__init__(org=org, course=course, **kwargs) + + if not (self.org and self.course): + raise InvalidKeyError(self.__class__, 'Both org and course must be set.') + + @classmethod + def from_course_run_key(cls, course_key): + """ + Get course key v2 from the course run key object. + + Arguments: + course_key (:class:`CourseKey`): The course identifier. + + """ + return cls(**{key: getattr(course_key, key) for key in cls.KEY_FIELDS}) + + def make_course_run_key(self, course_run): + """ + Get course run key (course_key) from the course key v2. + + Arguments: + course_run (str): The course run for course run identifier. + + """ + return CourseLocator(org=self.org, course=self.course, run=course_run) + + @classmethod + def _from_string(cls, serialized): + """ + Return a CourseLocatorV2 parsing the given serialized string. + + Arguments: + serialized: string for matching + + """ + parse = cls.KEY_REGEX.match(serialized) + if not parse: + raise InvalidKeyError(cls, serialized) + + parse = parse.groupdict() + return cls(**{key: parse.get(key) for key in cls.KEY_FIELDS}) + + def _to_string(self): + """ + Return a string representing this location. + """ + return '{org}+{course}'.format( + org=self.org, + course=self.course + ) diff --git a/opaque_keys/edx/tests/test_course_locators.py b/opaque_keys/edx/tests/test_course_locators.py index fbe17b1c..4d952f0b 100644 --- a/opaque_keys/edx/tests/test_course_locators.py +++ b/opaque_keys/edx/tests/test_course_locators.py @@ -11,7 +11,7 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey -from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator +from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator, CourseLocatorV2 from opaque_keys.edx.tests import LocatorBaseTest, TestDeprecated @@ -279,3 +279,15 @@ def test_empty_run(self): 'org/course/', text_type(CourseLocator('org', 'course', '', deprecated=True)) ) + + def test_make_course_key_v2(self): + """ + Verify that the method `make_course_key_v2` of class `CourseLocator` + returns a course key v2 object without course run information. + """ + organization = 'org' + course_number = 'course' + course_run = 'run' + course_run_key = CourseLocator(org=organization, course=course_number, run=course_run) + expected_course_key_v2 = CourseLocatorV2(org=organization, course=course_number) + self.assertEqual(expected_course_key_v2, course_run_key.make_course_key_v2()) diff --git a/opaque_keys/edx/tests/test_library_locators.py b/opaque_keys/edx/tests/test_library_locators.py index 43caf731..6bf94c78 100644 --- a/opaque_keys/edx/tests/test_library_locators.py +++ b/opaque_keys/edx/tests/test_library_locators.py @@ -237,3 +237,12 @@ def test_course_agnostic(self): self.assertEqual(lib_key2, lib_key3) self.assertEqual(lib_key3.org, None) self.assertEqual(lib_key3.library, None) + + def test_make_course_key_v2(self): + """ + Verify that the method `make_course_key_v2` of class `LibraryLocator` + raises exception `NotImplementedError`. + """ + lib_key = CourseKey.from_string('library-v1:TestX+lib1') + with self.assertRaises(NotImplementedError): + lib_key.make_course_key_v2() # pylint: disable=protected-access diff --git a/opaque_keys/edx/tests/test_locators.py b/opaque_keys/edx/tests/test_locators.py index 036778ef..39cc9a56 100644 --- a/opaque_keys/edx/tests/test_locators.py +++ b/opaque_keys/edx/tests/test_locators.py @@ -5,11 +5,13 @@ import random +import ddt from six import text_type from bson.objectid import ObjectId -from opaque_keys.edx.locator import Locator, CourseLocator, DefinitionLocator, VersionTree -from opaque_keys.edx.keys import DefinitionKey +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import DefinitionKey, CourseKey, CourseKeyV2 +from opaque_keys.edx.locator import Locator, CourseLocator, DefinitionLocator, VersionTree, CourseLocatorV2 class LocatorTests(TestCase): @@ -59,3 +61,67 @@ def test_version_tree(self): test_id = ObjectId(test_id_loc) valid_locator = CourseLocator(version_guid=test_id) self.assertEqual(VersionTree(valid_locator).children, []) + + +@ddt.ddt +class CourseLocatorV2Tests(TestCase): + """ + Tests for :class:`.CourseLocatorV2` + """ + + def test_from_string(self): + """ + Verify that the method `from_string` of class `CourseKeyV2` + returns an object of `CourseLocatorV2` for a valid course key. + """ + course_key_v2 = 'course-v2:org+course' + course_locator_v2 = CourseKeyV2.from_string(course_key_v2) + expected_course_locator = CourseLocatorV2(org='org', course='course') + self.assertEqual(expected_course_locator, course_locator_v2) + + def test_from_course_run_key(self): + """ + Verify that the method `from_course_run_key` of class `CourseLocatorV2` + coverts a valid course run key to a course key v2. + """ + course_key = CourseKey.from_string('course-v1:org+course+run') + expected_course_key = CourseLocatorV2(org=course_key.org, course=course_key.course) + actual_course_key = CourseLocatorV2.from_course_run_key(course_key) + self.assertEqual(expected_course_key, actual_course_key) + + def test_make_course_run_key(self): + """ + Verify that the method `make_course_run_key` of class `CourseLocatorV2` + returns a course run key with provided run. + """ + organization = 'org' + course_number = 'course' + course_run = 'run' + course_key_v2 = CourseLocatorV2(org=organization, course=course_number) + expected_course_run_key = CourseLocator(org=organization, course=course_number, run=course_run) + self.assertEqual(expected_course_run_key, course_key_v2.make_course_run_key(course_run)) + + def test_serialize_to_string(self): + """ + Verify that the method `_to_string` of class `CourseLocatorV2` + serializes a course key v2 to a string with expected format. + """ + organization = 'org' + course_number = 'course' + course_locator_v2 = CourseLocatorV2(org=organization, course=course_number) + expected_serialized_key = 'course-v2:{org}+{course}'.format(org=organization, course=course_number) + self.assertEqual(expected_serialized_key, str(course_locator_v2)) + + @ddt.data( + 'org/course/run', + 'org+course+run', + 'org+course+run+foo', + 'course-v2:org+course+run', + ) + def test_from_string_with_invalid_input(self, course_key): + """ + Verify that the method `from_string` of class `CourseKeyV2` + raises exception `InvalidKeyError` for unsupported key formats. + """ + with self.assertRaises(InvalidKeyError): + CourseKeyV2.from_string(course_key) diff --git a/setup.py b/setup.py index 20d124bd..12ad34a5 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='edx-opaque-keys', - version='0.4', + version='0.5.0', author='edX', url='https://github.com/edx/opaque-keys', classifiers=[ @@ -31,6 +31,9 @@ # don't use slashes in any new code 'slashes = opaque_keys.edx.locator:CourseLocator', ], + 'course_key_v2': [ + 'course-v2 = opaque_keys.edx.locator:CourseLocatorV2', + ], 'usage_key': [ 'block-v1 = opaque_keys.edx.locator:BlockUsageLocator', 'lib-block-v1 = opaque_keys.edx.locator:LibraryUsageLocator',