diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 97e9e73..c6a4ce9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,7 @@ +# 3.0.0 + +* Breaking: LibraryItemKey is replaced by CollectionKey and ContainerKey + # 2.13.0 * Breaking change to the new LibraryContainerLocator and diff --git a/opaque_keys/__init__.py b/opaque_keys/__init__.py index e7aee04..2a27e57 100644 --- a/opaque_keys/__init__.py +++ b/opaque_keys/__init__.py @@ -14,7 +14,7 @@ from stevedore.enabled import EnabledExtensionManager -__version__ = '2.13.0' +__version__ = '3.0.0' class InvalidKeyError(Exception): diff --git a/opaque_keys/edx/django/models.py b/opaque_keys/edx/django/models.py index d9ed737..15c8e25 100644 --- a/opaque_keys/edx/django/models.py +++ b/opaque_keys/edx/django/models.py @@ -3,6 +3,7 @@ If Django is unavailable, none of the classes below will work as intended. """ from __future__ import annotations + import logging import warnings @@ -17,8 +18,7 @@ IsNull = object from opaque_keys import OpaqueKey -from opaque_keys.edx.keys import BlockTypeKey, CourseKey, LearningContextKey, UsageKey - +from opaque_keys.edx.keys import BlockTypeKey, CourseKey, LearningContextKey, ContainerKey, CollectionKey, UsageKey log = logging.getLogger(__name__) @@ -224,6 +224,28 @@ class UsageKeyField(OpaqueKeyField): _pyi_private_get_type: UsageKey | None +class ContainerKeyField(OpaqueKeyField): + """ + A django Field that stores a ContainerKey object as a string. + """ + description = "A Location object, saved to the DB in the form of a string" + KEY_CLASS = ContainerKey + # Declare the field types for the django-stubs mypy type hint plugin: + _pyi_private_set_type: ContainerKey | str | None + _pyi_private_get_type: ContainerKey | None + + +class CollectionKeyField(OpaqueKeyField): + """ + A django Field that stores a CollectionKey object as a string. + """ + description = "A Location object, saved to the DB in the form of a string" + KEY_CLASS = CollectionKey + # Declare the field types for the django-stubs mypy type hint plugin: + _pyi_private_set_type: CollectionKey | str | None + _pyi_private_get_type: CollectionKey | None + + class LocationKeyField(UsageKeyField): """ A django Field that stores a UsageKey object as a string. diff --git a/opaque_keys/edx/django/tests/models.py b/opaque_keys/edx/django/tests/models.py index 6dd0011..23f7924 100644 --- a/opaque_keys/edx/django/tests/models.py +++ b/opaque_keys/edx/django/tests/models.py @@ -10,7 +10,12 @@ Model = object from opaque_keys.edx.django.models import ( - BlockTypeKeyField, CourseKeyField, CreatorMixin, UsageKeyField + BlockTypeKeyField, + CollectionKeyField, + ContainerKeyField, + CourseKeyField, + CreatorMixin, + UsageKeyField, ) @@ -64,3 +69,5 @@ class ComplexModel(Model): course_key = CourseKeyField(max_length=255, validators=[is_edx]) block_type_key = BlockTypeKeyField(max_length=255, blank=True) usage_key = UsageKeyField(max_length=255, blank=False) + collection_key = CollectionKeyField(max_length=255, blank=False) + container_key = ContainerKeyField(max_length=255, blank=False) diff --git a/opaque_keys/edx/django/tests/test_models.py b/opaque_keys/edx/django/tests/test_models.py index cede90a..4465b01 100644 --- a/opaque_keys/edx/django/tests/test_models.py +++ b/opaque_keys/edx/django/tests/test_models.py @@ -12,7 +12,7 @@ import pytest from opaque_keys.edx.django.models import OpaqueKeyField, UsageKeyField -from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.keys import CollectionKey, ContainerKey, CourseKey, UsageKey from .models import ComplexModel, Container, ExampleModel @@ -68,10 +68,14 @@ def setUp(self): super().setUp() self.course_key = CourseKey.from_string('course-v1:edX+FUN101x+3T2017') self.usage_key = UsageKey.from_string('block-v1:edX+FUN101x+3T2017+type@html+block@12345678') + self.collection_key = CollectionKey.from_string('lib-collection:TestX:LibraryX:test-problem-bank') + self.container_key = ContainerKey.from_string('lct:TestX:LibraryX:unit:test-container') self.model = ComplexModel( id='foobar', course_key=self.course_key, - usage_key=self.usage_key + usage_key=self.usage_key, + collection_key=self.collection_key, + container_key=self.container_key, ) self.model.save() diff --git a/opaque_keys/edx/keys.py b/opaque_keys/edx/keys.py index 2e0da97..335c969 100644 --- a/opaque_keys/edx/keys.py +++ b/opaque_keys/edx/keys.py @@ -4,16 +4,11 @@ from __future__ import annotations import json from abc import abstractmethod -from typing import TYPE_CHECKING, Self +from typing import Self import warnings -from typing_extensions import deprecated # For python 3.13+ can use 'from warnings import deprecated' - from opaque_keys import OpaqueKey -if TYPE_CHECKING: - from opaque_keys.edx.locator import LibraryLocatorV2 - class LearningContextKey(OpaqueKey): # pylint: disable=abstract-method """ @@ -93,28 +88,6 @@ def make_asset_key(self, asset_type: str, path: str) -> AssetKey: # pragma: no raise NotImplementedError() -class LibraryItemKey(OpaqueKey): - """ - An :class:`opaque_keys.OpaqueKey` identifying a particular item in a library. - """ - KEY_TYPE = 'library_item_key' - lib_key: LibraryLocatorV2 - __slots__ = () - - @property - @abstractmethod - def org(self) -> str | None: # pragma: no cover - """ - The organization that this object belongs to. - """ - raise NotImplementedError() - - @property - @deprecated("Use lib_key instead") - def library_key(self): - return self.lib_key - - class DefinitionKey(OpaqueKey): """ An :class:`opaque_keys.OpaqueKey` identifying an XBlock definition. @@ -281,6 +254,40 @@ def map_into_course(self, course_key: CourseKey) -> Self: raise ValueError("Cannot use map_into_course like that with this key type.") +class ContainerKey(OpaqueKey): + """ + An :class:`opaque_keys.OpaqueKey` identifying a container (a non-XBlock + structure like a unit, section, or subsection). + """ + KEY_TYPE = 'container_key' + __slots__ = () + + @property + @abstractmethod + def context_key(self) -> LearningContextKey: # pragma: no cover + """ + Get the learning context key (LearningContextKey) for this container. + """ + raise NotImplementedError() + + +class CollectionKey(OpaqueKey): + """ + An :class:`opaque_keys.OpaqueKey` identifying a collection (a group of + content, mostly used in libraries to organize components/containers). + """ + KEY_TYPE = 'collection_key' + __slots__ = () + + @property + @abstractmethod + def context_key(self) -> LearningContextKey: # pragma: no cover + """ + Get the learning context key (LearningContextKey) for this collection. + """ + raise NotImplementedError() + + class AsideDefinitionKey(DefinitionKey): """ A definition key for an aside. diff --git a/opaque_keys/edx/locator.py b/opaque_keys/edx/locator.py index d139a71..3a20690 100644 --- a/opaque_keys/edx/locator.py +++ b/opaque_keys/edx/locator.py @@ -15,7 +15,7 @@ from opaque_keys import OpaqueKey, InvalidKeyError from opaque_keys.edx.keys import AssetKey, CourseKey, DefinitionKey, \ - LearningContextKey, UsageKey, UsageKeyV2, LibraryItemKey + LearningContextKey, UsageKey, UsageKeyV2, ContainerKey, CollectionKey log = logging.getLogger(__name__) @@ -1622,13 +1622,14 @@ def html_id(self) -> str: return str(self) -class LibraryCollectionLocator(CheckFieldMixin, LibraryItemKey): +class LibraryCollectionLocator(CheckFieldMixin, CollectionKey): """ When serialized, these keys look like: lib-collection:org:lib:collection-id """ CANONICAL_NAMESPACE = 'lib-collection' KEY_FIELDS = ('lib_key', 'collection_id') + lib_key: LibraryLocatorV2 collection_id: str __slots__ = KEY_FIELDS @@ -1675,14 +1676,19 @@ def _from_string(cls, serialized: str) -> Self: except (ValueError, TypeError) as error: raise InvalidKeyError(cls, serialized) from error + @property + def context_key(self) -> LibraryLocatorV2: + return self.lib_key + -class LibraryContainerLocator(CheckFieldMixin, LibraryItemKey): +class LibraryContainerLocator(CheckFieldMixin, ContainerKey): """ When serialized, these keys look like: lct:org:lib:ct-type:ct-id """ CANONICAL_NAMESPACE = 'lct' # "Library Container" KEY_FIELDS = ('lib_key', 'container_type', 'container_id') + lib_key: LibraryLocatorV2 container_type: str container_id: str @@ -1714,6 +1720,10 @@ def org(self) -> str | None: # pragma: no cover """ return self.lib_key.org + @property + def context_key(self) -> LibraryLocatorV2: + return self.lib_key + def _to_string(self) -> str: """ Serialize this key as a string diff --git a/opaque_keys/edx/tests/test_collection_locators.py b/opaque_keys/edx/tests/test_collection_locators.py index 0e86a32..26ea47a 100644 --- a/opaque_keys/edx/tests/test_collection_locators.py +++ b/opaque_keys/edx/tests/test_collection_locators.py @@ -4,6 +4,7 @@ import ddt from opaque_keys import InvalidKeyError from opaque_keys.edx.tests import LocatorBaseTest +from opaque_keys.edx.keys import CollectionKey from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryLocatorV2 @@ -32,11 +33,12 @@ def test_coll_key_constructor(self): lib_key = LibraryLocatorV2(org=org, slug=lib) coll_key = LibraryCollectionLocator(lib_key=lib_key, collection_id=code) lib_key = coll_key.lib_key - self.assertEqual(str(coll_key), "lib-collection:TestX:LibraryX:test-problem-bank") - self.assertEqual(coll_key.org, org) - self.assertEqual(coll_key.collection_id, code) - self.assertEqual(lib_key.org, org) - self.assertEqual(lib_key.slug, lib) + assert str(coll_key) == "lib-collection:TestX:LibraryX:test-problem-bank" + assert coll_key.org == org + assert coll_key.collection_id == code + assert lib_key.org == org + assert lib_key.slug == lib + assert isinstance(coll_key, CollectionKey) def test_coll_key_constructor_bad_ids(self): lib_key = LibraryLocatorV2(org="TestX", slug="lib1") @@ -52,13 +54,18 @@ def test_coll_key_from_string(self): code = 'test-problem-bank' str_key = f"lib-collection:{org}:{lib}:{code}" coll_key = LibraryCollectionLocator.from_string(str_key) + assert coll_key == CollectionKey.from_string(str_key) + assert str(coll_key) == str_key + assert coll_key.org == org + assert coll_key.collection_id == code lib_key = coll_key.lib_key - self.assertEqual(str(coll_key), str_key) - self.assertEqual(coll_key.org, org) - self.assertEqual(coll_key.collection_id, code) - self.assertEqual(lib_key.org, org) - self.assertEqual(lib_key.slug, lib) + assert isinstance(lib_key, LibraryLocatorV2) + assert lib_key.org == org + assert lib_key.slug == lib + assert coll_key.context_key == lib_key def test_coll_key_invalid_from_string(self): with self.assertRaises(InvalidKeyError): LibraryCollectionLocator.from_string("this-is-a-great-test") + with self.assertRaises(InvalidKeyError): + LibraryCollectionLocator.from_string("lib-collection:TestX:LibraryX:test:too:many:colons") diff --git a/opaque_keys/edx/tests/test_container_locators.py b/opaque_keys/edx/tests/test_container_locators.py index fe04eba..68fff54 100644 --- a/opaque_keys/edx/tests/test_container_locators.py +++ b/opaque_keys/edx/tests/test_container_locators.py @@ -4,6 +4,7 @@ import ddt from opaque_keys import InvalidKeyError from opaque_keys.edx.tests import LocatorBaseTest +from opaque_keys.edx.keys import ContainerKey from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2 @@ -37,12 +38,13 @@ def test_key_constructor(self): container_id=container_id, ) lib_key = container_key.lib_key - self.assertEqual(str(container_key), "lct:TestX:LibraryX:unit:test-container") - self.assertEqual(container_key.org, org) - self.assertEqual(container_key.container_type, container_type) - self.assertEqual(container_key.container_id, container_id) - self.assertEqual(lib_key.org, org) - self.assertEqual(lib_key.slug, lib) + assert str(container_key) == "lct:TestX:LibraryX:unit:test-container" + assert container_key.org == org + assert container_key.container_type == container_type + assert container_key.container_id == container_id + assert lib_key.org == org + assert lib_key.slug == lib + assert isinstance(container_key, ContainerKey) def test_key_constructor_bad_ids(self): lib_key = LibraryLocatorV2(org="TestX", slug="lib1") @@ -66,10 +68,13 @@ def test_key_from_string(self): container_id = 'test-container' str_key = f"lct:{org}:{lib}:{container_type}:{container_id}" container_key = LibraryContainerLocator.from_string(str_key) + assert container_key == ContainerKey.from_string(str_key) + assert str(container_key) == str_key + assert container_key.org == org + assert container_key.container_type == container_type + assert container_key.container_id == container_id lib_key = container_key.lib_key - self.assertEqual(str(container_key), str_key) - self.assertEqual(container_key.org, org) - self.assertEqual(container_key.container_type, container_type) - self.assertEqual(container_key.container_id, container_id) - self.assertEqual(lib_key.org, org) - self.assertEqual(lib_key.slug, lib) + assert isinstance(lib_key, LibraryLocatorV2) + assert lib_key.org == org + assert lib_key.slug == lib + assert container_key.context_key == lib_key diff --git a/setup.py b/setup.py index ee96343..b17d1b9 100644 --- a/setup.py +++ b/setup.py @@ -183,8 +183,10 @@ def get_version(*file_paths): 'block_type': [ 'block-type-v1 = opaque_keys.edx.block_types:BlockTypeKeyV1', ], - 'library_item_key': [ + 'collection_key': [ 'lib-collection = opaque_keys.edx.locator:LibraryCollectionLocator', + ], + 'container_key': [ 'lct = opaque_keys.edx.locator:LibraryContainerLocator', ], }