Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ Change history for XBlock
Unreleased
----------

6.0.0 - 2026-01-20
------------------

* Raise an exception when scope IDs are missing or are not the expected types. In
particular, definition IDs must be DefinitionKey instances and usage IDs must be
UsageKey instances. This has been effectively true within edx-platform (the lone
production client of the XBlock library) for a long time, but explictly
enforcing it will now allow us to add strong type annotations to XBlock in an
upcoming release.

5.3.0 - 2025-12-19
------------------

Expand Down
2 changes: 1 addition & 1 deletion xblock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
XBlock Courseware Components
"""

__version__ = '5.3.0'
__version__ = '6.0.0'
15 changes: 7 additions & 8 deletions xblock/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
KeyValueMultiSaveError,
XBlockSaveError,
)
from xblock.fields import Field, List, Reference, ReferenceList, Scope, String
from xblock.fields import Field, List, Reference, ReferenceList, Scope, String, ScopeIds
from xblock.internal import class_lazy
from xblock.plugin import Plugin
from xblock.validation import Validation
Expand Down Expand Up @@ -393,6 +393,9 @@ def __init__(self, scope_ids, field_data=None, *, runtime, **kwargs):

self._field_data_cache = {}
self._dirty_fields = {}
if not isinstance(scope_ids, ScopeIds):
raise TypeError(f"got {scope_ids=}; should be a ScopeIds instance")
scope_ids.validate_types()
self.scope_ids = scope_ids

super().__init__(**kwargs)
Expand Down Expand Up @@ -780,9 +783,8 @@ def __init__(
self,
runtime,
field_data=None,
scope_ids=UNSET,
*args, # pylint: disable=keyword-arg-before-vararg
**kwargs
scope_ids=None,
**kwargs,
):
"""
Arguments:
Expand All @@ -797,9 +799,6 @@ def __init__(
scope_ids (:class:`.ScopeIds`): Identifiers needed to resolve
scopes.
"""
if scope_ids is UNSET:
raise TypeError('scope_ids are required')

# A cache of the parent block, retrieved from .parent
self._parent_block = None
self._parent_block_id = None
Expand All @@ -811,7 +810,7 @@ def __init__(
self._parent_block_id = for_parent.scope_ids.usage_id

# Provide backwards compatibility for external access through _field_data
super().__init__(runtime=runtime, scope_ids=scope_ids, field_data=field_data, *args, **kwargs)
super().__init__(runtime=runtime, scope_ids=scope_ids, field_data=field_data, **kwargs)

def render(self, view, context=None):
"""Render `view` with this block's runtime and the supplied `context`"""
Expand Down
26 changes: 25 additions & 1 deletion xblock/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
**scopes** to associate each field with particular sets of blocks and users.
The hosting runtime application decides what actual storage mechanism to use
for each scope.

"""
from __future__ import annotations

from collections import namedtuple
import copy
import datetime
Expand All @@ -17,6 +18,8 @@
import traceback
import warnings

from opaque_keys.edx.keys import UsageKey, DefinitionKey

import dateutil.parser
from lxml import etree
import pytz
Expand Down Expand Up @@ -250,6 +253,27 @@ class ScopeIds(namedtuple('ScopeIds', 'user_id block_type def_id usage_id')):
"""
__slots__ = ()

def validate_types(self):
"""
Raise an AssertionError if any of the ids are an unexpected type.

Originally, these fields were all freely-typed; but in practice,
edx-platform's XBlock runtime would fail if the ids did not match the
types below. In order to make the XBlock library reflect the
edx-platform reality and improve type-safety, we've decided to actually
enforce the types here, per:
https://github.com/openedx/XBlock/issues/708
"""
if self.user_id is not None:
if not isinstance(self.user_id, (int, str)):
raise TypeError(f"got {self.user_id=}; should be an int, str, or None")
if not isinstance(self.block_type, str):
raise TypeError(f"got {self.block_type=}; should be a str")
if not isinstance(self.def_id, DefinitionKey):
raise TypeError(f"got {self.def_id=}; should be a DefinitionKey")
if not isinstance(self.usage_id, UsageKey):
raise TypeError(f"got {self.usage_id=}; should be a UsageKey")


# Define special reference that can be used as a field's default in field
# definition to signal that the field should default to a unique string value
Expand Down
147 changes: 111 additions & 36 deletions xblock/runtime.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""
Machinery to make the common case easy when building new runtimes
"""
from __future__ import annotations

from abc import ABCMeta, abstractmethod
from collections import namedtuple
import functools
Expand All @@ -12,13 +14,16 @@
import logging
import re
import threading
import typing as t
import warnings

from lxml import etree
import markupsafe

from web_fragments.fragment import Fragment

from opaque_keys.edx.keys import UsageKey, DefinitionKey, LearningContextKey, CourseKey
from opaque_keys.edx.asides import AsideDefinitionKeyV2, AsideUsageKeyV2
from xblock.core import XBlock, XBlockAside, XML_NAMESPACES
from xblock.fields import Field, BlockScope, Scope, ScopeIds, UserScope
from xblock.field_data import FieldData
Expand Down Expand Up @@ -357,78 +362,148 @@ def create_definition(self, block_type, slug=None):
raise NotImplementedError()


class _InMemoryDefinitionKey(DefinitionKey):
"""
A simple definition key: md:<block_type>:<definition_id>. NOT part of the public XBlock API.
"""
CANONICAL_NAMESPACE = 'md' # "(In-)Memory Definition"
KEY_FIELDS = ("block_type", "definition_id")
block_type: str
definition_id: str
__slots__ = KEY_FIELDS
CHECKED_INIT = False

def __init__(self, block_type: str, definition_id: str):
super().__init__(block_type=block_type, definition_id=definition_id)

def _to_string(self) -> str:
return f"{self.block_type}:{self.definition_id}"

@classmethod
def _from_string(cls, serialized: str):
try:
block_type, definition_id = serialized.split(":")
except ValueError as exc:
raise ValueError(f"invalid {cls.__name__}: {serialized}") from exc
return _InMemoryDefinitionKey(block_type, definition_id)


class _InMemoryUsageKey(UsageKey):
"""
A simple usage key: mu:<block_type>:<usage_id>. NOT part of the public XBlock API.
"""
CANONICAL_NAMESPACE = 'mb' # "(In-)Memory Block"
KEY_FIELDS = ('block_type', 'usage_id')
block_type: str
usage_id: str
__slots__ = KEY_FIELDS
CHECKED_INIT = False

def __init__(self, block_type: str, usage_id: str):
super().__init__(block_type=block_type, usage_id=usage_id)

def _to_string(self) -> str:
return f"{self.block_type}:{self.usage_id}"

@classmethod
def _from_string(cls, serialized: str):
try:
block_type, usage_id = serialized.split(":")
except ValueError as exc:
raise ValueError(f"invalid {cls.__name__}: {serialized}") from exc
return _InMemoryDefinitionKey(block_type, usage_id)

@property
def block_id(self) -> str:
return self.definition_id

@property
def context_key(self) -> LearningContextKey:
"""
Raise an error because these blocks exist outside a LearningContext.
"""
raise TypeError("Usages managed by MemoryIdManager do not have a LearningContext")

@property
def definition_key(self) -> DefinitionKey:
"""
Raise an error because the InMemoryIdManager must be used to access the definition key.
"""
raise TypeError(
"Usages managed by MemoryIdManager do not know their definition keys. Use get_definition_id instead."
)

course_key = context_key # the UsageKey class demands this for backcompat.

def map_into_course(self, course_key: CourseKey) -> t.Self:
return course_key.make_usage_key(self.block_type, self.block_id)


class MemoryIdManager(IdReader, IdGenerator):
"""A simple dict-based implementation of IdReader and IdGenerator."""

ASIDE_USAGE_ID = namedtuple('MemoryAsideUsageId', 'usage_id aside_type')
ASIDE_DEFINITION_ID = namedtuple('MemoryAsideDefinitionId', 'definition_id aside_type')

def __init__(self):
self._ids = itertools.count()
self._usages = {}
self._definitions = {}
self._ids: t.Iterator[int] = itertools.count()
self._usages: dict[DefinitionKey, _InMemoryUsageKey] = {}

def _next_id(self, prefix):
def _next_id(self, prefix) -> str:
"""Generate a new id."""
return f"{prefix}_{next(self._ids)}"

def clear(self):
def clear(self) -> None:
"""Remove all entries."""
self._usages.clear()
self._definitions.clear()

def create_aside(self, definition_id, usage_id, aside_type):
def create_aside(
self, definition_id: DefinitionKey, usage_id: UsageKey, aside_type: str
) -> t.tuple[AsideDefinitionKeyV2, AsideUsageKeyV2]:
"""Create the aside."""
return (
self.ASIDE_DEFINITION_ID(definition_id, aside_type),
self.ASIDE_USAGE_ID(usage_id, aside_type),
AsideDefinitionKeyV2(definition_id, aside_type),
AsideUsageKeyV2(usage_id, aside_type)
)

def get_usage_id_from_aside(self, aside_id):
def get_usage_id_from_aside(self, aside_id: AsideUsageKeyV2) -> UsageKey:
"""Extract the usage_id from the aside's usage_id."""
return aside_id.usage_id
return aside_id.usage_key

def get_definition_id_from_aside(self, aside_id):
def get_definition_id_from_aside(self, aside_id: AsideDefinitionKeyV2) -> DefinitionKey:
"""Extract the original xblock's definition_id from an aside's definition_id."""
return aside_id.definition_id
return aside_id.definition_key

def create_usage(self, def_id):
def create_usage(self, def_id: DefinitionKey) -> _InMemoryUsageKey:
"""Make a usage, storing its definition id."""
usage_id = self._next_id("u")
self._usages[usage_id] = def_id
return usage_id
if not isinstance(def_id, _InMemoryDefinitionKey):
raise TypeError(
f"got def_id of type {type(def_id)}, expected def_id of type {_InMemoryDefinitionKey.__name__}"
)
usage_key = _InMemoryUsageKey(def_id, self._next_id("u"))
self._usages[usage_key] = def_id
return usage_key

def get_definition_id(self, usage_id):
def get_definition_id(self, usage_id: UsageKey) -> _InMemoryDefinitionKey:
"""Get a definition_id by its usage id."""
try:
return self._usages[usage_id]
except KeyError:
raise NoSuchUsage(repr(usage_id)) # pylint: disable= raise-missing-from

def create_definition(self, block_type, slug=None):
"""Make a definition, storing its block type."""
def create_definition(self, block_type: str, slug: str | None = None) -> _InMemoryDefinitionKey:
"""Make a definition, including its block type in its key."""
prefix = "d"
if slug:
prefix += "_" + slug
def_id = self._next_id(prefix)
self._definitions[def_id] = block_type
return def_id
return _InMemoryDefinitionKey(block_type, self._next_id(prefix))

def get_block_type(self, def_id):
def get_block_type(self, def_id: DefinitionKey) -> str:
"""Get a block_type by its definition id."""
try:
return self._definitions[def_id]
except KeyError:
try:
return def_id.aside_type
except AttributeError:
raise NoSuchDefinition(repr(def_id)) # pylint: disable= raise-missing-from
return def_id.block_type

def get_aside_type_from_definition(self, aside_id):
def get_aside_type_from_definition(self, aside_id: AsideDefinitionKeyV2) -> str:
"""Get an aside's type from its definition id."""
return aside_id.aside_type

def get_aside_type_from_usage(self, aside_id):
def get_aside_type_from_usage(self, aside_id: AsideUsageKeyV2) -> str:
"""Get an aside's type from its usage id."""
return aside_id.aside_type

Expand Down
5 changes: 3 additions & 2 deletions xblock/test/django/test_field_translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
DictKeyValueStore,
KvsFieldData,
)
from xblock.test.tools import TestRuntime
from xblock.test.tools import TestRuntime, TestKey


class TestXBlockStringFieldDefaultTranslation(TestCase):
Expand Down Expand Up @@ -45,7 +45,8 @@ class XBlockTest(XBlock):
# Change language to 'de'.
user_language = 'de'
with translation.override(user_language):
tester = runtime.construct_xblock_from_class(XBlockTest, ScopeIds('s0', 'XBlockTest', 'd0', 'u0'))
test_key = TestKey("XBlockTest", "k0")
tester = runtime.construct_xblock_from_class(XBlockTest, ScopeIds('s0', 'XBlockTest', test_key, test_key))

# Assert instantiated XBlock str_field value is not yet evaluated.
assert 'django.utils.functional.' in str(type(tester.str_field))
Expand Down
4 changes: 3 additions & 1 deletion xblock/test/test_completable.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from xblock.fields import ScopeIds
from xblock.runtime import Runtime
from xblock.completable import CompletableXBlockMixin, XBlockCompletionMode
from xblock.test.tools import TestKey


@ddt.ddt
Expand Down Expand Up @@ -77,7 +78,8 @@ def _make_block(self, runtime=None, block_type=None):
"""
block_type = block_type if block_type else self.TestBuddyXBlock
runtime = runtime if runtime else mock.Mock(spec=Runtime)
scope_ids = ScopeIds("user_id", "test_buddy", "def_id", "usage_id")
test_key = TestKey("test_buddy", "test_id")
scope_ids = ScopeIds("user_id", "test_buddy", test_key, test_key)
return block_type(runtime=runtime, scope_ids=scope_ids)

def test_has_custom_completion_property(self):
Expand Down
Loading