Skip to content

Commit 2082432

Browse files
feat: XBlock overrides (#778)
* feat: add ability to override XBlock with xblock.v1.overrides entry_point * test: xblock overrides * docs: update changelog entry and add tutorial for overriding XBlock * chore: bump version to 5.1.0 --------- Co-authored-by: Kyle McCormick <[email protected]>
1 parent dc13e24 commit 2082432

File tree

6 files changed

+180
-11
lines changed

6 files changed

+180
-11
lines changed

CHANGELOG.rst

+7
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ Change history for XBlock
55
Unreleased
66
----------
77

8+
5.1.0 - 2024-08-07
9+
------------------
10+
11+
* added ability to override an XBlock with the 'xblock.v1.overrides' entry point
12+
* added ability to override an XBlock Aside with the 'xblock_asides.v1.overrides' entry point
13+
14+
815
5.0.0 - 2024-05-30
916
------------------
1017

docs/xblock-tutorial/edx_platform/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ XBlocks and the edX Platform
1111
edx_lms
1212
devstack
1313
edx
14+
overrides
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
.. _Replace a Preinstalled XBlock With a Custom Implementation:
2+
3+
##########################################################
4+
Replace a Preinstalled XBlock With a Custom Implementation
5+
##########################################################
6+
7+
In XBlock ``v5.1.0``, the ability was introduced to override an XBlock with a custom
8+
implementation.
9+
10+
This can be done by:
11+
12+
1. Creating an XBlock in a new or existing package installed into ``edx-platform``.
13+
14+
2. Adding the ``xblock.v1.overrides`` entry point in ``setup.py``, pointing to your
15+
custom XBlock.
16+
17+
This works with updated logic in ``load_class``'s ``default_select``, which gives
18+
load priority to a class with the ``.overrides`` suffix.
19+
20+
This can be disabled by providing a different ``select`` kwarg to ``load_class`` which
21+
ignores or otherwise changes override logic.
22+
23+
*******
24+
Example
25+
*******
26+
27+
Imagine there is an XBlock installed ``edx-platform``:
28+
29+
.. code:: python
30+
31+
# edx-platform/xblocks/video_block.py
32+
class VideoBlock(XBlock):
33+
...
34+
35+
# edx-platform/setup.py
36+
setup(
37+
# ...
38+
39+
entry_points={
40+
"xblock.v1": [
41+
"video = xblocks.video_block::VideoBlock"
42+
# ...
43+
]
44+
}
45+
)
46+
47+
If you then create your own Python package with a custom version of that XBlock...
48+
49+
.. code:: python
50+
51+
# your_plugin/xblocks/video_block.py
52+
class YourVideoBlock(XBlock):
53+
...
54+
55+
# your_plugin/setup.py
56+
setup(
57+
# ...
58+
entry_points={
59+
"xblock.v1.overrides": [
60+
"video = your_plugin.xblocks.video_block::YourVideoBlock"
61+
# ...
62+
],
63+
}
64+
)
65+
66+
And install that package into your virtual environment, then your block should be
67+
loaded instead of the existing implementation.
68+
69+
.. note::
70+
71+
The ``load_class`` code will throw an error in the following cases:
72+
73+
1. There are multiple classes attempting to override one XBlock implementation.
74+
75+
2. There is an override provided where an existing XBlock implementation is not found.

xblock/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
XBlock Courseware Components
33
"""
44

5-
__version__ = '5.0.0'
5+
__version__ = '5.1.0'

xblock/plugin.py

+61-9
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,17 @@ def __init__(self, all_entry_points):
2828
super().__init__(msg)
2929

3030

31-
def default_select(identifier, all_entry_points): # pylint: disable=inconsistent-return-statements
31+
class AmbiguousPluginOverrideError(AmbiguousPluginError):
32+
"""Raised when a class name produces more than one override for an entry_point."""
33+
34+
35+
def _default_select_no_override(identifier, all_entry_points): # pylint: disable=inconsistent-return-statements
3236
"""
33-
Raise an exception when we have ambiguous entry points.
37+
Selects plugin for the given identifier, raising on error:
38+
39+
Raises:
40+
- PluginMissingError when we don't have an entry point.
41+
- AmbiguousPluginError when we have ambiguous entry points.
3442
"""
3543

3644
if len(all_entry_points) == 0:
@@ -41,6 +49,37 @@ def default_select(identifier, all_entry_points): # pylint: disable=inconsisten
4149
raise AmbiguousPluginError(all_entry_points)
4250

4351

52+
def default_select(identifier, all_entry_points):
53+
"""
54+
Selects plugin for the given identifier with the ability for a Plugin to override
55+
the default entry point.
56+
57+
Raises:
58+
- PluginMissingError when we don't have an entry point or entry point to override.
59+
- AmbiguousPluginError when we have ambiguous entry points.
60+
"""
61+
62+
# Split entry points into overrides and non-overrides
63+
overrides = []
64+
block_entry_points = []
65+
66+
for block_entry_point in all_entry_points:
67+
if block_entry_point.group.endswith('.overrides'):
68+
overrides.append(block_entry_point)
69+
else:
70+
block_entry_points.append(block_entry_point)
71+
72+
# Get the default entry point
73+
default_plugin = _default_select_no_override(identifier, block_entry_points)
74+
75+
# If we have an unambiguous override, that gets priority. Otherwise, return default.
76+
if len(overrides) == 1:
77+
return overrides[0]
78+
elif len(overrides) > 1:
79+
raise AmbiguousPluginOverrideError(overrides)
80+
return default_plugin
81+
82+
4483
class Plugin:
4584
"""Base class for a system that uses entry_points to load plugins.
4685
@@ -75,12 +114,20 @@ def _load_class_entry_point(cls, entry_point):
75114
def load_class(cls, identifier, default=None, select=None):
76115
"""Load a single class specified by identifier.
77116
78-
If `identifier` specifies more than a single class, and `select` is not None,
79-
then call `select` on the list of entry_points. Otherwise, choose
80-
the first one and log a warning.
117+
By default, this returns the class mapped to `identifier` from entry_points
118+
matching `{cls.entry_points}.overrides` or `{cls.entry_points}`, in that order.
81119
82-
If `default` is provided, return it if no entry_point matching
83-
`identifier` is found. Otherwise, will raise a PluginMissingError
120+
If multiple classes are found for either `{cls.entry_points}.overrides` or
121+
`{cls.entry_points}`, it will raise an `AmbiguousPluginError`.
122+
123+
If no classes are found for `{cls.entry_points}`, it will raise a `PluginMissingError`.
124+
125+
Args:
126+
- identifier: The class to match on.
127+
128+
Kwargs:
129+
- default: A class to return if no entry_point matching `identifier` is found.
130+
- select: A function to override our default_select functionality.
84131
85132
If `select` is provided, it should be a callable of the form::
86133
@@ -100,7 +147,11 @@ def select(identifier, all_entry_points):
100147
if select is None:
101148
select = default_select
102149

103-
all_entry_points = list(importlib.metadata.entry_points(group=cls.entry_point, name=identifier))
150+
all_entry_points = [
151+
*importlib.metadata.entry_points(group=f'{cls.entry_point}.overrides', name=identifier),
152+
*importlib.metadata.entry_points(group=cls.entry_point, name=identifier)
153+
]
154+
104155
for extra_identifier, extra_entry_point in iter(cls.extra_entry_points):
105156
if identifier == extra_identifier:
106157
all_entry_points.append(extra_entry_point)
@@ -146,7 +197,7 @@ def load_classes(cls, fail_silently=True):
146197
raise
147198

148199
@classmethod
149-
def register_temp_plugin(cls, class_, identifier=None, dist='xblock'):
200+
def register_temp_plugin(cls, class_, identifier=None, dist='xblock', group='xblock.v1'):
150201
"""Decorate a function to run with a temporary plugin available.
151202
152203
Use it like this in tests::
@@ -164,6 +215,7 @@ def test_the_thing():
164215
entry_point = Mock(
165216
dist=Mock(key=dist),
166217
load=Mock(return_value=class_),
218+
group=group
167219
)
168220
entry_point.name = identifier
169221

xblock/test/test_plugin.py

+35-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from xblock.core import XBlock
88
from xblock import plugin
9-
from xblock.plugin import AmbiguousPluginError, PluginMissingError
9+
from xblock.plugin import AmbiguousPluginError, AmbiguousPluginOverrideError, PluginMissingError
1010

1111

1212
class AmbiguousBlock1(XBlock):
@@ -21,6 +21,10 @@ class UnambiguousBlock(XBlock):
2121
"""A dummy class to find as a plugin."""
2222

2323

24+
class OverriddenBlock(XBlock):
25+
"""A dummy class to find as a plugin."""
26+
27+
2428
@XBlock.register_temp_plugin(AmbiguousBlock1, "bad_block")
2529
@XBlock.register_temp_plugin(AmbiguousBlock2, "bad_block")
2630
@XBlock.register_temp_plugin(UnambiguousBlock, "good_block")
@@ -52,6 +56,36 @@ def boom(identifier, entry_points):
5256
XBlock.load_class("bad_block", select=boom)
5357

5458

59+
@XBlock.register_temp_plugin(OverriddenBlock, "overridden_block", group='xblock.v1.overrides')
60+
@XBlock.register_temp_plugin(UnambiguousBlock, "overridden_block")
61+
def test_plugin_override():
62+
# Trying to load a block that is overridden returns the correct override
63+
override = XBlock.load_class("overridden_block")
64+
assert override is OverriddenBlock
65+
66+
67+
@XBlock.register_temp_plugin(OverriddenBlock, "overridden_block", group='xblock.v1.overrides')
68+
def test_plugin_override_missing_original():
69+
# Trying to override a block that has no original block should raise an error
70+
with pytest.raises(PluginMissingError, match="overridden_block"):
71+
XBlock.load_class("overridden_block")
72+
73+
74+
@XBlock.register_temp_plugin(AmbiguousBlock1, "overridden_block", group='xblock.v1.overrides')
75+
@XBlock.register_temp_plugin(AmbiguousBlock2, "overridden_block", group='xblock.v1.overrides')
76+
@XBlock.register_temp_plugin(OverriddenBlock, "overridden_block")
77+
def test_plugin_override_ambiguous():
78+
79+
# Trying to load a block that is overridden, but ambigous, errors.
80+
expected_msg = (
81+
"Ambiguous entry points for overridden_block: "
82+
"xblock.test.test_plugin.AmbiguousBlock1, "
83+
"xblock.test.test_plugin.AmbiguousBlock2"
84+
)
85+
with pytest.raises(AmbiguousPluginOverrideError, match=expected_msg):
86+
XBlock.load_class("overridden_block")
87+
88+
5589
def test_nosuch_plugin():
5690
# We can provide a default class to return for missing plugins.
5791
cls = XBlock.load_class("nosuch_block", default=UnambiguousBlock)

0 commit comments

Comments
 (0)