Skip to content

Commit

Permalink
Merge pull request #46 from open-craft/library-locators
Browse files Browse the repository at this point in the history
New locators for content libraries
  • Loading branch information
dmitchell committed Oct 30, 2014
2 parents 0091e5c + 644f1bc commit b124013
Show file tree
Hide file tree
Showing 6 changed files with 717 additions and 7 deletions.
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ Julia Hansbrough <[email protected]>
Nimisha Asthagiri <[email protected]>
David Baumgold <[email protected]>
Gabe Mulley <[email protected]>
Braden MacDonald <[email protected]>
341 changes: 335 additions & 6 deletions opaque_keys/edx/locator.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,17 +99,18 @@ class BlockLocatorBase(Locator):
# pep8 happy and ignore pylint as that's easier to do.
# pylint: disable=bad-continuation
URL_RE_SOURCE = r"""
((?P<org>{ALLOWED_ID_CHARS}+)\+(?P<course>{ALLOWED_ID_CHARS}+)\+(?P<run>{ALLOWED_ID_CHARS}+)\+?)??
({BRANCH_PREFIX}@(?P<branch>{ALLOWED_ID_CHARS}+)\+?)?
({VERSION_PREFIX}@(?P<version_guid>[A-F0-9]+)\+?)?
({BLOCK_TYPE_PREFIX}@(?P<block_type>{ALLOWED_ID_CHARS}+)\+?)?
((?P<org>{ALLOWED_ID_CHARS}+)\+(?P<course>{ALLOWED_ID_CHARS}+)(\+(?P<run>{ALLOWED_ID_CHARS}+))?{SEP})??
({BRANCH_PREFIX}@(?P<branch>{ALLOWED_ID_CHARS}+){SEP})?
({VERSION_PREFIX}@(?P<version_guid>[A-F0-9]+){SEP})?
({BLOCK_TYPE_PREFIX}@(?P<block_type>{ALLOWED_ID_CHARS}+){SEP})?
({BLOCK_PREFIX}@(?P<block_id>{ALLOWED_ID_CHARS}+))?
""".format(
ALLOWED_ID_CHARS=Locator.ALLOWED_ID_CHARS,
BRANCH_PREFIX=BRANCH_PREFIX,
VERSION_PREFIX=Locator.VERSION_PREFIX,
BLOCK_TYPE_PREFIX=Locator.BLOCK_TYPE_PREFIX,
BLOCK_PREFIX=BLOCK_PREFIX,
SEP=r'(\+(?=.)|$)', # Separator: requires a non-trailing '+' or end of string
)

URL_RE = re.compile('^' + URL_RE_SOURCE + '$', re.IGNORECASE | re.VERBOSE | re.UNICODE)
Expand Down Expand Up @@ -403,6 +404,219 @@ def _from_deprecated_string(cls, serialized):
CourseKey.set_deprecated_fallback(CourseLocator)


class LibraryLocator(BlockLocatorBase, CourseKey):
"""
Locates a library. Libraries are XBlock structures with a 'library' block
at their root.
Libraries are treated analogously to courses for now. Once opaque keys are
better supported, they will no longer have the 'run' property, and may no
longer conform to CourseKey but rather some more general key type.
Examples of valid LibraryLocator specifications:
LibraryLocator(version_guid=ObjectId('519665f6223ebd6980884f2b'))
LibraryLocator(org='UniX', library='PhysicsProbs')
LibraryLocator.from_string('library-v1:UniX+PhysicsProbs')
version_guid is optional.
The constructor accepts 'course' as a deprecated alias for the 'library'
attribute.
branch is optional.
"""
CANONICAL_NAMESPACE = 'library-v1'
RUN = 'library' # For backwards compatibility, LibraryLocators have a read-only 'run' property equal to this
KEY_FIELDS = ('org', 'library', 'branch', 'version_guid')
__slots__ = KEY_FIELDS
CHECKED_INIT = False

# declare our fields explicitly to avoid pylint warnings
org = None
library = None
branch = None
version_guid = None

def __init__(self, org=None, library=None, branch=None, version_guid=None, **kwargs):
"""
Construct a LibraryLocator
Args:
version_guid (string or ObjectId): optional unique id for the version
org, library: the standard definition. Optional only if version_guid given.
branch (string): the optional branch such as 'draft', 'published', 'staged', 'beta'
"""
if 'offering' in kwargs:
raise ValueError("'offering' is not a valid field for a LibraryLocator.")

if 'course' in kwargs:
if library is not None:
raise ValueError("Cannot specify both 'library' and 'course'")
warnings.warn(
"For LibraryLocators, use 'library' instead of 'course'.",
DeprecationWarning,
stacklevel=2
)
library = kwargs.pop('course')

run = kwargs.pop('run', self.RUN)
if run != self.RUN:
raise ValueError("Invalid run. Should be '{}' or None.".format(self.RUN))

if version_guid:
version_guid = self.as_object_id(version_guid)

if not all(field is None or self.ALLOWED_ID_RE.match(field) for field in [org, library, branch]):
raise InvalidKeyError(self.__class__, [org, library, branch])

if kwargs.get('deprecated', False):
raise InvalidKeyError(self.__class__, 'LibraryLocator cannot have deprecated=True')

super(LibraryLocator, self).__init__(
org=org,
library=library,
branch=branch,
version_guid=version_guid,
**kwargs
)

if self.version_guid is None and (self.org is None or self.library is None):
raise InvalidKeyError(self.__class__, "Either version_guid or org and library should be set")

@property
def run(self):
"""
Deprecated. Return a 'run' for compatibility with CourseLocator.
"""
warnings.warn("Accessing 'run' on a LibraryLocator is deprecated.", DeprecationWarning, stacklevel=2)
return self.RUN

@property
def course(self):
"""
Deprecated. Return a 'course' for compatibility with CourseLocator.
"""
warnings.warn("Accessing 'course' on a LibraryLocator is deprecated.", DeprecationWarning, stacklevel=2)
return self.library

@property
def version(self):
"""
Deprecated. The ambiguously named field from CourseLocation which code
expects to find. Equivalent to version_guid.
"""
warnings.warn(
"version is no longer supported as a property of Locators. Please use the version_guid property.",
DeprecationWarning,
stacklevel=2
)
return self.version_guid

@classmethod
def _from_string(cls, serialized):
"""
Return a LibraryLocator parsing the given serialized string
:param serialized: matches the string to a LibraryLocator
"""
parse = cls.parse_url(serialized)

# The regex detects the "library" key part as "course"
# since we're sharing a regex with CourseLocator
parse["library"] = parse["course"]
del parse["course"]

if parse['version_guid']:
parse['version_guid'] = cls.as_object_id(parse['version_guid'])

return cls(**{key: parse.get(key) for key in cls.KEY_FIELDS})

def html_id(self):
"""
Return an id which can be used on an html page as an id attr of an html element.
"""
return unicode(self)

def make_usage_key(self, block_type, block_id):
return LibraryUsageLocator(
library_key=self,
block_type=block_type,
block_id=block_id,
)

def make_asset_key(self, asset_type, path):
return AssetLocator(self, asset_type, path, deprecated=False)

def version_agnostic(self):
"""
We don't care if the locator's version is not the current head; so, avoid version conflict
by reducing info.
Returns a copy of itself without any version info.
Raises:
ValueError: if the block locator has no org & course, run
"""
return self.replace(version_guid=None)

def course_agnostic(self):
"""
We only care about the locator's version not its library.
Returns a copy of itself without any library info.
Raises:
ValueError: if the block locator has no version_guid
"""
return self.replace(org=None, library=None, branch=None)

def for_branch(self, branch):
"""
Return a new CourseLocator for another branch of the same library (also version agnostic)
"""
if self.org is None and branch is not None:
raise InvalidKeyError(self.__class__, "Branches must have full library ids not just versions")
return self.replace(branch=branch, version_guid=None)

def for_version(self, version_guid):
"""
Return a new LibraryLocator for another version of the same library and branch. Usually used
when the head is updated (and thus the library x branch now points to this version)
"""
return self.replace(version_guid=version_guid)

@property
def package_id(self):
"""
Returns the package identifier for this `LibraryLocator`.
Returns 'self.org+self.library' if both are present; else returns None.
"""
if self.org and self.library:
return self.ORG_SEPARATOR.join([self.org, self.library])
else:
return None

def _to_string(self):
"""
Return a string representing this location.
"""
parts = []
if self.library:
parts.append(unicode(self.package_id))
if self.branch:
parts.append(u"{prefix}@{branch}".format(prefix=self.BRANCH_PREFIX, branch=self.branch))
if self.version_guid:
parts.append(u"{prefix}@{guid}".format(prefix=self.VERSION_PREFIX, guid=self.version_guid))
return u"+".join(parts)

def _to_deprecated_string(self):
""" LibraryLocators are never deprecated. """
raise NotImplementedError

@classmethod
def _from_deprecated_string(cls, serialized):
""" LibraryLocators are never deprecated. """
raise NotImplementedError


class BlockUsageLocator(BlockLocatorBase, UsageKey):
"""
Encodes a location.
Expand Down Expand Up @@ -728,8 +942,7 @@ def make_relative(cls, course_locator, block_type, block_id):
"""
if hasattr(course_locator, 'course_key'):
course_locator = course_locator.course_key
return BlockUsageLocator(
course_key=course_locator,
return course_locator.make_usage_key(
block_type=block_type,
block_id=block_id
)
Expand Down Expand Up @@ -855,6 +1068,122 @@ def _from_deprecated_son(cls, id_dict, run):
UsageKey.set_deprecated_fallback(BlockUsageLocator)


class LibraryUsageLocator(BlockUsageLocator):
"""
Just like BlockUsageLocator, but this points to a block stored in a library,
not a course.
"""
CANONICAL_NAMESPACE = 'lib-block-v1'
KEY_FIELDS = ('library_key', 'block_type', 'block_id')

# fake out class introspection as this is an attr in this class's instances
library_key = None
block_type = None

def __init__(self, library_key, block_type, block_id, **kwargs):
"""
Construct a LibraryUsageLocator
"""
# LibraryUsageLocator is a new type of locator so should never be deprecated.
if library_key.deprecated or kwargs.get('deprecated', False):
raise InvalidKeyError(self.__class__, "LibraryUsageLocators are never deprecated.")

block_id = self._parse_block_ref(block_id, False)

if not all(self.ALLOWED_ID_RE.match(val) for val in (block_type, block_id)):
raise InvalidKeyError(self.__class__, "Invalid block_type or block_id ('{}', '{}')".format(block_type, block_id))

# We skip the BlockUsageLocator init and go to its superclass:
# pylint: disable=bad-super-call
super(BlockUsageLocator, self).__init__(library_key=library_key, block_type=block_type, block_id=block_id, **kwargs)

def replace(self, **kwargs):
# BlockUsageLocator allows for the replacement of 'KEY_FIELDS' in 'self.library_key'
lib_key_kwargs = {}
for key in LibraryLocator.KEY_FIELDS:
if key in kwargs:
lib_key_kwargs[key] = kwargs.pop(key)
if 'version' in kwargs and 'version_guid' not in lib_key_kwargs:
lib_key_kwargs['version_guid'] = kwargs.pop('version')
if len(lib_key_kwargs) > 0:
kwargs['library_key'] = self.library_key.replace(**lib_key_kwargs)
if 'course_key' in kwargs:
kwargs['library_key'] = kwargs.pop('course_key')
return super(LibraryUsageLocator, self).replace(**kwargs)

@classmethod
def _from_string(cls, serialized):
"""
Requests LibraryLocator to deserialize its part and then adds the local deserialization of block
"""
# Allow access to _from_string protected method
library_key = LibraryLocator._from_string(serialized) # pylint: disable=protected-access
parsed_parts = LibraryLocator.parse_url(serialized)
block_id = parsed_parts.get('block_id', None)
if block_id is None:
raise InvalidKeyError(cls, serialized)
return cls(library_key, parsed_parts.get('block_type'), block_id)

def version_agnostic(self):
"""
We don't care if the locator's version is not the current head; so, avoid version conflict
by reducing info.
Returns a copy of itself without any version info.
Raises:
ValueError: if the block locator has no org, course, and run
"""
return self.replace(library_key=self.library_key.version_agnostic())

def for_branch(self, branch):
"""
Return a UsageLocator for the same block in a different branch of the library.
"""
return self.replace(library_key=self.library_key.for_branch(branch))

def for_version(self, version_guid):
"""
Return a UsageLocator for the same block in a different version of the library.
"""
return self.replace(library_key=self.library_key.for_version(version_guid))

@property
def course_key(self):
"""
To enable compatibility with BlockUsageLocator, we provide a read-only
course_key property.
"""
return self.library_key

@property
def run(self):
"""Returns the run for this object's library_key."""
warnings.warn(
"Run is a deprecated property of LibraryUsageLocators.",
DeprecationWarning,
stacklevel=2
)
return self.library_key.run

def _to_deprecated_string(self):
""" Disable some deprecated methods of our parent class. """
raise NotImplementedError

@classmethod
def _from_deprecated_string(cls, serialized):
""" Disable some deprecated methods of our parent class. """
raise NotImplementedError

def to_deprecated_son(self, prefix='', tag='i4x'):
""" Disable some deprecated methods of our parent class. """
raise NotImplementedError

@classmethod
def _from_deprecated_son(cls, id_dict, run):
""" Disable some deprecated methods of our parent class. """
raise NotImplementedError


class DefinitionLocator(Locator, DefinitionKey):
"""
Container for how to locate a description (the course-independent content).
Expand Down
8 changes: 7 additions & 1 deletion opaque_keys/edx/tests/test_course_locators.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,13 @@ def test_course_constructor_bad_package_id(self, bad_id):
with self.assertRaises(InvalidKeyError):
CourseKey.from_string('course-v1:test+{}+2014_T2'.format(bad_id))

@ddt.data('course-v1:', 'course-v1:/mit.eecs', 'http:mit.eecs')
@ddt.data(
'course-v1:',
'course-v1:/mit.eecs',
'http:mit.eecs',
'course-v1:mit+course+run{}@branch'.format(CourseLocator.BRANCH_PREFIX),
'course-v1:mit+course+run+',
)
def test_course_constructor_bad_url(self, bad_url):
with self.assertRaises(InvalidKeyError):
CourseKey.from_string(bad_url)
Expand Down
Loading

0 comments on commit b124013

Please sign in to comment.