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
33 changes: 32 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 @@ -15,8 +16,13 @@
import re
import time
import traceback
import typing as t
import warnings

from bson.objectid import ObjectId
from opaque_keys.edx.keys import UsageKey, DefinitionKey
from opaque_keys.edx.locator import LocalId

import dateutil.parser
from lxml import etree
import pytz
Expand Down Expand Up @@ -241,6 +247,11 @@ def __hash__(self):
return hash(('xblock.fields.Scope', self.user, self.block))



OptionalUserId: t.TypeAlias = int | str | None
DefinitionId: t.TypeAlias = DefinitionKey | UsageKey | ObjectId | LocalId | str


class ScopeIds(namedtuple('ScopeIds', 'user_id block_type def_id usage_id')):
"""
A simple wrapper to collect all of the ids needed to correctly identify an XBlock
Expand All @@ -250,6 +261,26 @@ 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 not isinstance(self.user_id, OptionalUserId):
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, DefinitionId):
raise TypeError(f"got {self.def_id=}; should be one of: {DefinitionId}")
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
80 changes: 2 additions & 78 deletions xblock/runtime.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"""
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
import gettext
from io import BytesIO, StringIO
import importlib
import itertools
import json
import logging
import re
Expand All @@ -20,7 +21,6 @@
from web_fragments.fragment import Fragment

from xblock.core import XBlock, XBlockAside, XML_NAMESPACES
from xblock.fields import Field, BlockScope, Scope, ScopeIds, UserScope
from xblock.field_data import FieldData
from xblock.exceptions import (
NoSuchViewError,
Expand Down Expand Up @@ -357,82 +357,6 @@ def create_definition(self, block_type, slug=None):
raise NotImplementedError()


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 = {}

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

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

def create_aside(self, definition_id, usage_id, aside_type):
"""Create the aside."""
return (
self.ASIDE_DEFINITION_ID(definition_id, aside_type),
self.ASIDE_USAGE_ID(usage_id, aside_type),
)

def get_usage_id_from_aside(self, aside_id):
"""Extract the usage_id from the aside's usage_id."""
return aside_id.usage_id

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

def create_usage(self, def_id):
"""Make a usage, storing its definition id."""
usage_id = self._next_id("u")
self._usages[usage_id] = def_id
return usage_id

def get_definition_id(self, usage_id):
"""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."""
prefix = "d"
if slug:
prefix += "_" + slug
def_id = self._next_id(prefix)
self._definitions[def_id] = block_type
return def_id

def get_block_type(self, def_id):
"""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

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

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


class Runtime(metaclass=ABCMeta):
"""
Access to the runtime environment for XBlocks.
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
Loading