Repository: openedx/opaque-keys
Pull request #369: feat: Create `LibraryContainerLocator` for Library Containers [FC-0083]
Author: login: ChrisChV
Labels: name: open-source-contribution
description: PR author is not from Axim or 2U
name: FC
description: Relates to an Axim Funded Contribution project
Created: 2025-03-13T20:52:54Z | Merged: 2025-03-21T20:05:24Z

## Description

- Refactor `LibraryCollectionKey` to `LibraryItemKey` to use it as the general class for `LibraryCollectionLocator` and `LibraryContainerLocator`.
- Creation of `LibraryContainerLocator`
- Add `LibraryUsageLocatorV2` also as `LibraryItemKey`

## Support Information

- Github issues:
    - https://github.com/openedx/openedx-learning/issues/282
    - https://github.com/openedx/frontend-app-authoring/issues/1704
- Used in: https://github.com/openedx/edx-platform/pull/36379
- Internal ticket: [FAL-4108](https://tasks.opencraft.com/browse/FAL-4108)

## Testing instructions

- Check all the code.
- Verify that the test covers the changes.



Diff:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index f95eeba..0956f84 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,3 +1,8 @@
+# 2.12.0
+
+* Refactor: Rename LibraryCollectionKey to LibraryItemKey.
+* Added LibraryContainerLocator.
+
 # 2.11.0
 
 * Added LibraryCollectionKey and LibraryCollectionLocator
diff --git a/opaque_keys/__init__.py b/opaque_keys/__init__.py
index ce00e01..ec8eb33 100644
--- a/opaque_keys/__init__.py
+++ b/opaque_keys/__init__.py
@@ -14,7 +14,7 @@
 from stevedore.enabled import EnabledExtensionManager
 from typing_extensions import Self  # For python 3.11 plus, can just use "from typing import Self"
 
-__version__ = '2.11.0'
+__version__ = '2.12.0'
 
 
 class InvalidKeyError(Exception):
diff --git a/opaque_keys/edx/keys.py b/opaque_keys/edx/keys.py
index 7be4794..98a1a81 100644
--- a/opaque_keys/edx/keys.py
+++ b/opaque_keys/edx/keys.py
@@ -93,19 +93,19 @@ def make_asset_key(self, asset_type: str, path: str) -> AssetKey:  # pragma: no
         raise NotImplementedError()
 
 
-class LibraryCollectionKey(OpaqueKey):
+class LibraryItemKey(OpaqueKey):
     """
-    An :class:`opaque_keys.OpaqueKey` identifying a particular Library Collection object.
+    An :class:`opaque_keys.OpaqueKey` identifying a particular item in a library.
     """
-    KEY_TYPE = 'collection_key'
+    KEY_TYPE = 'library_item_key'
     library_key: LibraryLocatorV2
-    collection_id: str
     __slots__ = ()
 
     @property
+    @abstractmethod
     def org(self) -> str | None:  # pragma: no cover
         """
-        The organization that this collection belongs to.
+        The organization that this object belongs to.
         """
         raise NotImplementedError()
 
diff --git a/opaque_keys/edx/locator.py b/opaque_keys/edx/locator.py
index 323106c..8c80a07 100644
--- a/opaque_keys/edx/locator.py
+++ b/opaque_keys/edx/locator.py
@@ -16,7 +16,7 @@
 
 from opaque_keys import OpaqueKey, InvalidKeyError
 from opaque_keys.edx.keys import AssetKey, CourseKey, DefinitionKey, \
-    LearningContextKey, UsageKey, UsageKeyV2, LibraryCollectionKey
+    LearningContextKey, UsageKey, UsageKeyV2, LibraryItemKey
 
 log = logging.getLogger(__name__)
 
@@ -1623,14 +1623,13 @@ def html_id(self) -> str:
         return str(self)
 
 
-class LibraryCollectionLocator(CheckFieldMixin, LibraryCollectionKey):
+class LibraryCollectionLocator(CheckFieldMixin, LibraryItemKey):
     """
     When serialized, these keys look like:
         lib-collection:org:lib:collection-id
     """
     CANONICAL_NAMESPACE = 'lib-collection'
     KEY_FIELDS = ('library_key', 'collection_id')
-    library_key: LibraryLocatorV2
     collection_id: str
 
     __slots__ = KEY_FIELDS
@@ -1655,7 +1654,7 @@ def __init__(self, library_key: LibraryLocatorV2, collection_id: str):
     @property
     def org(self) -> str | None:  # pragma: no cover
         """
-        The organization that this collection belongs to.
+        The organization that this Collection belongs to.
         """
         return self.library_key.org
 
@@ -1676,3 +1675,65 @@ def _from_string(cls, serialized: str) -> Self:
             return cls(library_key, collection_id)
         except (ValueError, TypeError) as error:
             raise InvalidKeyError(cls, serialized) from error
+
+
+class LibraryContainerLocator(CheckFieldMixin, LibraryItemKey):
+    """
+    When serialized, these keys look like:
+        lct:org:lib:ct-type:ct-id
+    """
+    CANONICAL_NAMESPACE = 'lct'  # "Library Container"
+    KEY_FIELDS = ('library_key', 'container_type', 'container_id')
+    container_type: str
+    container_id: str
+
+    __slots__ = KEY_FIELDS
+    CHECKED_INIT = False
+
+    # Allow container IDs to contian unicode characters
+    CONTAINER_ID_REGEXP = re.compile(r'^[\w\-.]+$', flags=re.UNICODE)
+
+    def __init__(self, library_key: LibraryLocatorV2, container_type: str, container_id: str):
+        """
+        Construct a CollectionLocator
+        """
+        if not isinstance(library_key, LibraryLocatorV2):
+            raise TypeError("library_key must be a LibraryLocatorV2")
+
+        self._check_key_string_field("container_type", container_type)
+        self._check_key_string_field("container_id", container_id, regexp=self.CONTAINER_ID_REGEXP)
+        super().__init__(
+            library_key=library_key,
+            container_type=container_type,
+            container_id=container_id,
+        )
+
+    @property
+    def org(self) -> str | None:  # pragma: no cover
+        """
+        The organization that this Container belongs to.
+        """
+        return self.library_key.org
+
+    def _to_string(self) -> str:
+        """
+        Serialize this key as a string
+        """
+        return ":".join((
+            self.library_key.org,
+            self.library_key.slug,
+            self.container_type,
+            self.container_id
+        ))
+
+    @classmethod
+    def _from_string(cls, serialized: str) -> Self:
+        """
+        Instantiate this key from a serialized string
+        """
+        try:
+            (org, lib_slug, container_type, container_id) = serialized.split(':')
+            library_key = LibraryLocatorV2(org, lib_slug)
+            return cls(library_key, container_type, container_id)
+        except (ValueError, TypeError) as error:
+            raise InvalidKeyError(cls, serialized) from error
diff --git a/opaque_keys/edx/tests/test_container_locators.py b/opaque_keys/edx/tests/test_container_locators.py
new file mode 100644
index 0000000..15ed20c
--- /dev/null
+++ b/opaque_keys/edx/tests/test_container_locators.py
@@ -0,0 +1,75 @@
+"""
+Tests of LibraryContainerLocator
+"""
+import ddt
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.tests import LocatorBaseTest
+from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2
+
+
+@ddt.ddt
+class TestLibraryContainerLocator(LocatorBaseTest):
+    """
+    Tests of :class:`.LibraryContainerLocator`
+    """
+    @ddt.data(
+        "org/lib/id/foo",
+        "org/lib/id",
+        "org+lib+id",
+        "org+lib+",
+        "org+lib++id@library",
+        "org+ne@t",
+        "per%ent+sign",
+    )
+    def test_coll_key_from_invalid_string(self, coll_id_str):
+        with self.assertRaises(InvalidKeyError):
+            LibraryContainerLocator.from_string(coll_id_str)
+
+    def test_key_constructor(self):
+        org = 'TestX'
+        lib = 'LibraryX'
+        container_type = 'unit'
+        container_id = 'test-container'
+        library_key = LibraryLocatorV2(org=org, slug=lib)
+        container_key = LibraryContainerLocator(
+            library_key=library_key,
+            container_type=container_type,
+            container_id=container_id,
+        )
+        library_key = container_key.library_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(library_key.org, org)
+        self.assertEqual(library_key.slug, lib)
+
+    def test_key_constructor_bad_ids(self):
+        library_key = LibraryLocatorV2(org="TestX", slug="lib1")
+
+        with self.assertRaises(TypeError):
+            LibraryContainerLocator(library_key=None, container_type='unit', container_id='usage')
+
+        with self.assertRaises(ValueError):
+            LibraryContainerLocator(library_key=library_key, container_type='unit', container_id='usage-!@#{$%^&*}')
+
+    def test_key_constructor_bad_type(self):
+        library_key = LibraryLocatorV2(org="TestX", slug="lib1")
+
+        with self.assertRaises(ValueError):
+            LibraryContainerLocator(library_key=library_key, container_type='unit-!@#{$%^&*}', container_id='usage')
+
+    def test_key_from_string(self):
+        org = 'TestX'
+        lib = 'LibraryX'
+        container_type = 'unit'
+        container_id = 'test-container'
+        str_key = f"lct:{org}:{lib}:{container_type}:{container_id}"
+        container_key = LibraryContainerLocator.from_string(str_key)
+        library_key = container_key.library_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(library_key.org, org)
+        self.assertEqual(library_key.slug, lib)
diff --git a/setup.py b/setup.py
index 401337e..ee96343 100644
--- a/setup.py
+++ b/setup.py
@@ -183,8 +183,9 @@ def get_version(*file_paths):
         'block_type': [
             'block-type-v1 = opaque_keys.edx.block_types:BlockTypeKeyV1',
         ],
-        'collection_key': [
+        'library_item_key': [
             'lib-collection = opaque_keys.edx.locator:LibraryCollectionLocator',
+            'lct = opaque_keys.edx.locator:LibraryContainerLocator',
         ],
     }
 )


Reviews:
user: login: bradenmacdonald
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T00:48:30Z
user: login: navinkarkera
body: @ChrisChV Looks good.
:+1:
- [ ] I tested this: Verified tests.
- [x] I read through the code
- [x] I checked for accessibility issues
- [x] Includes documentation

state: APPROVED
author_association: NONE
submitted_at: 2025-03-14T05:36:11Z
user: login: ChrisChV
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T22:21:48Z
user: login: ChrisChV
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T23:30:38Z
user: login: bradenmacdonald
body: Looks good! I tested this out in https://github.com/openedx/edx-platform/pull/36371 though I haven't tried it with the latest rename from Element -> Item
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T23:41:51Z
user: login: pomegranited
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-15T22:55:47Z
user: login: ChrisChV
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:00:06Z
user: login: kdmccormick
body: Don't we need LibraryContainerLocators to be UsageKeys so that we can store xblock state against units, sections, and subsections?
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-17T16:32:20Z
user: login: kdmccormick
body: OK, that all makes sense. Then let's merge this 🚀
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-19T12:15:45Z

Review comments:
diff_hunk: @@ -1,3 +1,8 @@
+# 2.12.0
+
+* Refactor: Rename LibraryCollectionKey to LibraryElementKey.
path: CHANGELOG.rst
user: login: bradenmacdonald
body: Nit: we have been using the term "item" in a few place to refer to any thing in a library (block/collection/container) so I would slightly prefer that over "element"
created_at: 2025-03-14T00:48:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1676,3 +1675,65 @@ def _from_string(cls, serialized: str) -> Self:
             return cls(library_key, collection_id)
         except (ValueError, TypeError) as error:
             raise InvalidKeyError(cls, serialized) from error
+
+
+class LibraryContainerLocator(CheckFieldMixin, LibraryElementKey):
+    """
+    When serialized, these keys look like:
+        lct:org:lib:ct-type:ct-id
+    """
+    CANONICAL_NAMESPACE = 'lct'  # "Library Container"
path: opaque_keys/edx/locator.py
user: login: navinkarkera
body: Question: Do we need this to be short? If size doesn't matter, maybe change this to something readable.
created_at: 2025-03-14T04:53:56Z
author_association: NONE
diff_hunk: @@ -1676,3 +1675,65 @@ def _from_string(cls, serialized: str) -> Self:
             return cls(library_key, collection_id)
         except (ValueError, TypeError) as error:
             raise InvalidKeyError(cls, serialized) from error
+
+
+class LibraryContainerLocator(CheckFieldMixin, LibraryElementKey):
+    """
+    When serialized, these keys look like:
+        lct:org:lib:ct-type:ct-id
+    """
+    CANONICAL_NAMESPACE = 'lct'  # "Library Container"
path: opaque_keys/edx/locator.py
user: login: ChrisChV
body: @navinkarkera Yes, other larger possibilities were ruled out; you can see that conversation here: https://github.com/openedx/openedx-learning/issues/282#issuecomment-2715951009
created_at: 2025-03-14T22:21:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,3 +1,8 @@
+# 2.12.0
+
+* Refactor: Rename LibraryCollectionKey to LibraryElementKey.
path: CHANGELOG.rst
user: login: ChrisChV
body: Updated here https://github.com/openedx/opaque-keys/pull/369/commits/fc0cff1bc932993f47f6fc0b58bb3cd882a1ca31
created_at: 2025-03-14T23:30:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -93,19 +93,19 @@ def make_asset_key(self, asset_type: str, path: str) -> AssetKey:  # pragma: no
         raise NotImplementedError()
 
 
-class LibraryCollectionKey(OpaqueKey):
+class LibraryItemKey(OpaqueKey):
     """
-    An :class:`opaque_keys.OpaqueKey` identifying a particular Library Collection object.
+    An :class:`opaque_keys.OpaqueKey` identifying a particular item in a library.
     """
-    KEY_TYPE = 'collection_key'
+    KEY_TYPE = 'library_element_key'
path: opaque_keys/edx/keys.py
user: login: pomegranited
body: If we're renaming this to `LibraryItemKey` instead of `LibraryElementKey`, shouldn't this key type be `library_item_key`?
created_at: 2025-03-15T22:55:47Z
author_association: NONE
diff_hunk: @@ -93,19 +93,19 @@ def make_asset_key(self, asset_type: str, path: str) -> AssetKey:  # pragma: no
         raise NotImplementedError()
 
 
-class LibraryCollectionKey(OpaqueKey):
+class LibraryItemKey(OpaqueKey):
     """
-    An :class:`opaque_keys.OpaqueKey` identifying a particular Library Collection object.
+    An :class:`opaque_keys.OpaqueKey` identifying a particular item in a library.
     """
-    KEY_TYPE = 'collection_key'
+    KEY_TYPE = 'library_element_key'
path: opaque_keys/edx/keys.py
user: login: ChrisChV
body: You are right. Updated: https://github.com/openedx/opaque-keys/pull/369/commits/ec22e77e2232bd600ddac2069e7355823a6279a7
created_at: 2025-03-17T16:00:05Z
author_association: CONTRIBUTOR

PR comments:
user: login: openedx-webhooks
created_at: 2025-03-13T20:52:59Z
body: Thanks for the pull request, @ChrisChV!


This repository is currently maintained by `@openedx/axim-engineering`.


Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

<details><summary>:radio_button: Get product approval</summary>

If you haven't already, [check this list](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3875962884/How+to+submit+an+open+source+contribution+for+Product+Review#Does-my-contribution-require-Product-Review%3F) to see if your contribution needs to go through the product review process.

- If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the [Product Working Group](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3449028609/Product+Working+Group).
    - This process (including the steps you'll need to take) is documented [here](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3875962884/How+to+submit+an+open+source+contribution+for+Product+Review#Product-Review-Process).
- If it doesn't, simply proceed with the next step.
</details>

<details><summary>:radio_button: Provide context</summary>

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

- Dependencies
  > This PR must be merged before / after / at the same time as ...
- Blockers
  > This PR is waiting for OEP-1234 to be accepted.
- Timeline information
  > This PR must be merged by XX date **because** ...
- Partner information
  > This is for a course on edx.org.
- Supporting documentation
- Relevant [Open edX discussion forum](https://discuss.openedx.org/) threads
</details>





<details><summary>:radio_button: Get a green build</summary>

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.
</details><details>


---

<details><summary>Where can I find more information?</summary>

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

- [Overview of Review Process for Community Contributions](https://docs.openedx.org/en/latest/developers/references/developer_guide/process/FAQ-about-pull-requests.html)
- [Pull Request Status Guide](https://docs.openedx.org/en/latest/developers/references/developer_guide/process/pull-request-statuses.html)
- [Making changes to your pull request](https://docs.openedx.org/en/latest/documentors/how-tos/make_changes_to_your_pull_request.html)
</details>

<details><summary>When can I expect my changes to be merged?</summary>

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

- The size and impact of the changes that it introduces
- The need for product review
- Maintenance status of the parent repository

:bulb: *As a result it may take up to several weeks or months to complete a review and merge your PR.*
</details>
<!-- comment:external_pr -->
<!-- data: eyJkcmFmdCI6IGZhbHNlfQ== -->

author_association: NONE
user: login: codecov[bot]
created_at: 2025-03-13T21:04:37Z
body: ## [Codecov](https://app.codecov.io/gh/openedx/opaque-keys/pull/369?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) Report
Attention: Patch coverage is `97.36842%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 93.80%. Comparing base [(`b958395`)](https://app.codecov.io/gh/openedx/opaque-keys/commit/b95839536033adabf3ce187f1bfc047804462e78?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) to head [(`ec22e77`)](https://app.codecov.io/gh/openedx/opaque-keys/commit/ec22e77e2232bd600ddac2069e7355823a6279a7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx).
> Report is 29 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/openedx/opaque-keys/pull/369?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) | Patch % | Lines |
|---|---|---|
| [opaque\_keys/edx/locator.py](https://app.codecov.io/gh/openedx/opaque-keys/pull/369?src=pr&el=tree&filepath=opaque_keys%2Fedx%2Flocator.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx#diff-b3BhcXVlX2tleXMvZWR4L2xvY2F0b3IucHk=) | 92.00% | [2 Missing :warning: ](https://app.codecov.io/gh/openedx/opaque-keys/pull/369?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #369      +/-   ##
==========================================
+ Coverage   93.72%   93.80%   +0.07%     
==========================================
  Files          30       31       +1     
  Lines        2934     3004      +70     
  Branches      191      192       +1     
==========================================
+ Hits         2750     2818      +68     
- Misses        157      159       +2     
  Partials       27       27              
```

| [Flag](https://app.codecov.io/gh/openedx/opaque-keys/pull/369/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/openedx/opaque-keys/pull/369/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) | `93.80% <97.36%> (+0.07%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/openedx/opaque-keys/pull/369?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: bradenmacdonald
created_at: 2025-03-14T00:47:44Z
body: Is it possible for `LibraryUsageLocatorV2` to also be a LibraryElementkey, or can each locator only be a single type of key? (I guess it's more important that it be a UsageKey if it can only be one).
author_association: CONTRIBUTOR
user: login: ChrisChV
created_at: 2025-03-14T23:32:55Z
body: > Is it possible for LibraryUsageLocatorV2 to also be a LibraryElementkey, or can each locator only be a single type of key? 

@bradenmacdonald  I've tried this, and it causes conflicts with the [KEY_TYPE](https://github.com/openedx/opaque-keys/pull/369/files#diff-6117aaf0e3d3aaf8569961ad62a10f24b38af67ea69267477abacaf7f071676dR100) (it inherits two different values ​​for this property). Apparently, a locator can only have one `KEY_TYPE`.
author_association: CONTRIBUTOR
user: login: ChrisChV
created_at: 2025-03-14T23:33:57Z
body: @bradenmacdonald It's ready for your review
author_association: CONTRIBUTOR
user: login: pomegranited
created_at: 2025-03-15T22:24:26Z
body: Hi @ormsbee or @kdmccormick  -- can you help get this merged and a new release created?
author_association: NONE
user: login: ChrisChV
created_at: 2025-03-18T18:52:28Z
body: > Don't we need LibraryContainerLocators to be UsageKeys so that we can store xblock state against units, sections, and subsections?

Hi @kdmccormick, could you give me more context about this? What is the xblock state that we need to store in the containers?

author_association: CONTRIBUTOR
user: login: kdmccormick
created_at: 2025-03-18T21:14:37Z
body: Sure @ChrisChV . Honestly, I actually cannot think of a reason to store state against containers in libraries right now. Still, I think that this would be good future-proofing, and it is fairly harmless if we don't end up needing it. Here are some of the ways I think it could pay off:

* It is not clear yet how courses will be represented in Learning Core. One idea is that courses will be stored _within libraries_. That would mean...
  * we would store a student's [`position`](https://github.com/openedx/edx-platform/blob/master/xmodule/seq_block.py#L75) as user state against an `lct:`. key. 
  * furthermore, we will need to store student state for various selectors (e.g. [`ItemBank.selected`](https://github.com/openedx/edx-platform/blob/f20b9cf466083b08e67d1a6ee2023b38e10f385b/xmodule/item_bank_block.py#L72)); I'm not sure if that state will get stored against the selector or its container, but if the latter, then we that will be state stored against `lct:`.
  * Many many edx-platform Python APIs and HTTP APIs a expect UsageKeys as a reference to units and sequences. Examples include the CourseBlocks (block transformers) and SequenceMetadata APIs.
* We know that we want to move away from the idea that "containers are XBlocks", but we are several years away from that. In the meantime, I believe that the Learning Core runtime will need to be able to return containers as if they are XBlocks, and that is only possible if they have usage keys.
author_association: MEMBER
user: login: kdmccormick
created_at: 2025-03-18T21:15:10Z
body: @ormsbee ^ I could use a sanity check from you on this, if you have time.
author_association: MEMBER
user: login: bradenmacdonald
created_at: 2025-03-19T05:34:52Z
body: Jumping in with some quick thoughts on this: I'm not super opposed to making these UsageKeys, but it doesn't make sense to me and I think it's cleaner not to do so.

> One idea is that courses will be stored within libraries. That would mean... 

If you mean courses will be stored within libraries and learners will learn from those courses within libraries, I haven't heard about that and don't like it in principle. I do see how having a course within a library is arguably a natural extension of having units, subsections, and sections - but learners wouldn't be accessing any of those things within the library generally; only when they're copied into a course. "True" independent courses might be in the same learning package, but I don't think they'd be accessed directly by learners within a library. What's more, courses aren't really "containers" and generally have their own keys `course-v1` etc.

> furthermore, we will need to store student state for various selectors (e.g. [ItemBank.selected](https://github.com/openedx/edx-platform/blob/f20b9cf466083b08e67d1a6ee2023b38e10f385b/xmodule/item_bank_block.py#L72));

That's only required for learning in courses. We don't support `ItemBank` in libraries at all, because it's an XBlock with children, and the runtime doesn't support it. Eventually we will implement ItemBank-like functionality on top of containers (and "selectors") and then it will be allowed in libraries and support student state, but it wouldn't be XBlock student state. It will be a new "container"/"outline" student state, and would work fine with `lct:` keys that are not `UsageKeys`.

> Many many edx-platform Python APIs and HTTP APIs a expect UsageKeys as a reference to units and sequences. Examples include the CourseBlocks (block transformers) and SequenceMetadata APIs.

None of those are APIs that we use with content libraries, and if they were, then they'd need to be updated to support non-XBlock containers anyways, since that's what we want to move toward. I see this (having `lct:` as a non-UsageKey) as a forcing function to help us aim toward the new architecture (containers aren't XBlocks).

> the Learning Core runtime will need to be able to return containers as if they are XBlocks, and that is only possible if they have usage keys.

I don't think we need that (in libraries)? They aren't implemented as XBlocks, and can't be used as XBlocks in the Library / Learning Core runtime. That runtime doesn't support XBlocks with children so it can't support Units as XBlocks.
author_association: CONTRIBUTOR
user: login: ormsbee
created_at: 2025-03-19T06:27:35Z
body: I'll try to write more tomorrow, but my quick take on this is that I don't think this needs to be a UsageKey. Also, I don't want to get in the mindset that we need a parallel set of container UsageKeys because of where the data is being stored. It's true that at some point we're going to be serving container content data out of Learning Core, if nothing else when we move course content to it. But we're going to need some mapping of existing block-v1: style UsageKeys to the corresponding course content in a LearningPackage anyhow, for backwards compatibility. That mapping doesn't even have to be 1:1 (e.g. the CCX use case).

I am fuzzy on exactly how we transition from a world where it's XBlocks all the way down to one where we shove the containers out of the XBlock runtime. The Unit seems like a great place to start because it has no user state for the most part (ignoring custom asides and the fact that technically it has a position field that I'm almost sure is never actually used). I expect that working out how to sequence that transition will be one of the thorniest pieces of this whole affair.

Edit: To be clear, I don't mean that we have to cross this particular bridge to get to courses-on-learning-core. We're going to be in XBlock-shim-land for a while. But this is going to be the next hill of confusion in the codebase. Though I'm hopeful that we can make something that looks like an XBlock for read purposes that hits 95% of the backwards compatibility use cases without things being too crazy. But now I have completely rambled on a tangent....
author_association: CONTRIBUTOR
user: login: ChrisChV
created_at: 2025-03-19T17:12:48Z
body: > OK, that all makes sense. Then let's merge this 🚀

Thanks! :rocket: :rocket: @kdmccormick @ormsbee could you help me merge this and create a new release?
author_association: CONTRIBUTOR
user: login: kdmccormick
created_at: 2025-03-21T16:05:34Z
body: @ormsbee any last-minute objections?

I still think these might be UsageKeys, but we can retroactively add that base class in the future if it turns out to be true.
author_association: MEMBER
user: login: ormsbee
created_at: 2025-03-21T18:23:51Z
body: No objections. Agreed that we can add it in the future if we need to.
author_association: CONTRIBUTOR
user: login: kdmccormick
created_at: 2025-03-21T19:08:27Z
body: Can you merge @ChrisChV or should I?
author_association: MEMBER
user: login: rpenido
created_at: 2025-03-21T19:43:24Z
body: > Can you merge @ChrisChV or should I?

He doesn't have merge rights on this repo, so it would be great if you could merge it.
author_association: NONE
user: login: kdmccormick
created_at: 2025-03-21T20:07:01Z
body: Released: https://github.com/openedx/opaque-keys/releases/tag/2.12.0
author_association: MEMBER