Skip to content

Commit 033fa5a

Browse files
felipemontoyaclaude
andcommitted
feat: poc implementation of extendable services
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 142a111 commit 033fa5a

3 files changed

Lines changed: 310 additions & 0 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
0001 Plugin-provided runtime services via entry points
2+
######################################################
3+
4+
Status
5+
******
6+
7+
Proposed
8+
9+
Context
10+
*******
11+
12+
XBlocks consume capabilities from their environment through *runtime
13+
services*: a block declares ``@XBlock.needs("name")`` or
14+
``@XBlock.wants("name")`` and calls ``self.runtime.service(self, "name")``.
15+
The base ``Runtime.service()`` resolves the name against the ``_services``
16+
dict that the runtime application populated at construction time.
17+
18+
This makes the *consumption* side of services fully generic, but the
19+
*provision* side closed: only the application that instantiates the runtime
20+
can decide which services exist. In Open edX — by far the largest user of
21+
this library — service wiring is hardcoded in several places
22+
(``ModuleStoreRuntime`` service dicts for LMS/Studio/preview, and the
23+
``if/elif`` chain in the newer ``XBlockRuntime``), and there is no supported
24+
way for a separately installed package to offer a new service.
25+
26+
The need is real and recurring. The motivating case is an AI-extensions
27+
plugin that wants to offer an ``"ai_extensions"`` service so that blocks like
28+
ORA can call LLM workflows without pinning provider SDKs or importing plugin
29+
internals (see the community thread in the References). But the same gap
30+
applies to any optional capability a pip-installed package might offer to
31+
blocks: translation backends, proctoring integrations, institution-specific
32+
storage, and so on.
33+
34+
Two facts about the existing design make this library the right place to
35+
close the gap:
36+
37+
1. **Every runtime already funnels through ``Runtime.service()``.** Open edX
38+
runtimes either populate ``_services`` and delegate to the base method
39+
(``ModuleStoreRuntime``), or run their own chain and fall back to the base
40+
method (``XBlockRuntime``). The xblock-sdk workbench uses the base
41+
behavior directly. A fallback added here is therefore reached by every
42+
known runtime without any changes to host applications.
43+
44+
2. **The library already has the discovery machinery and the stated intent.**
45+
``xblock/plugin.py`` loads XBlocks (``xblock.v1``) and asides
46+
(``xblock_asides.v1``) from entry points, with caching, ambiguity
47+
detection, and an ``.overrides`` group. The reference ``Service`` class in
48+
``xblock/reference/plugins.py`` has documented the goal for years: services
49+
should *"be able to load through Stevedore, and have a plug-in mechanism
50+
similar to XBlock."*
51+
52+
Decision
53+
********
54+
55+
Add a third entry-point group to the XBlock framework, ``xblock.service.v1``,
56+
and a fallback in ``Runtime.service()`` — the ``_load_service_from_entry_point``
57+
method — that consults it.
58+
59+
A package provides a service by declaring::
60+
61+
entry_points={
62+
"xblock.service.v1": [
63+
"my_service = my_package.services:MyService",
64+
],
65+
}
66+
67+
where the entry-point name is the service name blocks declare with
68+
``needs``/``wants``. Resolution order in ``Runtime.service()`` becomes:
69+
70+
1. Reject undeclared requests (unchanged): a block that never declared the
71+
service still gets ``NoSuchServiceError``.
72+
2. Return the runtime-provided service from ``_services`` if present
73+
(unchanged).
74+
3. **New:** if the runtime has nothing, try
75+
``_load_service_from_entry_point(block, service_name)``, which loads the
76+
provider class from the ``xblock.service.v1`` group and instantiates it as
77+
``provider_class(runtime=self, xblock=block)``.
78+
4. Apply ``need``/``want`` semantics to the result (unchanged): ``None`` for
79+
a wanted-but-absent service, ``NoSuchServiceError`` for a needed one.
80+
81+
Reasoning behind the specific choices
82+
=====================================
83+
84+
**Why a fallback in the base class rather than a hook in each runtime.**
85+
Placing the lookup after the ``_services`` miss, inside the one method every
86+
runtime inherits, gives complete coverage (all Open edX runtimes, the
87+
workbench, third-party runtimes that don't override ``service()``) for a
88+
single small change, and gives a hard guarantee: *runtime-provided services
89+
always shadow plugin-provided ones*. A pip package cannot replace or
90+
intercept ``user``, ``field-data``, ``i18n``, or any other service the host
91+
application provides deliberately. Runtimes that override ``service()``
92+
entirely keep that freedom — the fallback only exists in the default path
93+
they opt into by calling ``super().service()``.
94+
95+
**Why entry points rather than configuration.** Entry points are how this
96+
library already discovers XBlocks and asides, so providers and operators deal
97+
with one consistent model: installing a package is the act that makes its
98+
plugins available, and the trust decision is the install decision — exactly
99+
as it is for XBlocks themselves. A settings-based registry would be
100+
runtime-application-specific (this library is not Django-bound) and would put
101+
the burden of wiring on every operator instead of on the providing package.
102+
103+
**Why the existing ``Plugin`` loader.** Reusing ``Plugin.load_class`` buys,
104+
for free: per-process caching of hits *and misses* (steady-state cost of the
105+
fallback is one dict lookup); loud ``AmbiguousPluginError`` when two installed
106+
packages claim the same service name, instead of last-write-wins — the exact
107+
failure mode that makes monkey-patching unacceptable; a sanctioned override
108+
path (``xblock.service.v1.overrides``) when replacing a default implementation
109+
is intentional; and ``register_temp_plugin`` for tests.
110+
111+
**Why ``provider_class(runtime=…, xblock=…)``.** This mirrors the
112+
constructor of the reference ``Service`` class, gives the provider the two
113+
context objects almost every service needs (and from which the rest — user,
114+
usage key, learning context — is reachable), and keeps the contract so small
115+
that providers do not need to import ``xblock`` at all. Note that the
116+
fallback returns an *instance*, never a class: some runtimes
117+
(``ModuleStoreRuntime``) call callable services with ``(block)``, and a
118+
class-valued service would be invoked accidentally. Instantiation is
119+
per-request for now; providers with expensive set-up are expected to cache it
120+
themselves (module- or class-level), consistent with the long-standing
121+
"don't over-initialize" guidance in ``reference/plugins.py``. Memoizing per
122+
``(runtime, service_name)`` in the base class is a possible follow-up once
123+
real-world usage shows it is needed.
124+
125+
**Why the ``needs``/``wants`` gate stays in front.** The declaration check
126+
runs before any entry-point lookup, so a plugin-provided service is only ever
127+
handed to blocks that explicitly asked for it. ``wants`` gives blocks a
128+
portable soft-dependency: the same block works on installs with and without
129+
the providing package, enabling features conditionally.
130+
131+
Rejected alternatives
132+
=====================
133+
134+
* **Wiring extension points into each host-application runtime** (new
135+
``openedx.*`` entry-point group, an ``XBLOCK_EXTRA_SERVICES`` Django
136+
setting, or an openedx-filters filter at resolution time) — all viable, but
137+
each covers only the call sites it patches, must be replicated for every
138+
current and future runtime, and lives in repositories whose architectural
139+
direction is to *shrink* their XBlock-runtime surface, not grow it. These
140+
were prototyped and documented by the openedx-ai-extensions project (see
141+
References) before converging here.
142+
143+
Consequences
144+
************
145+
146+
* Installed packages can provide named runtime services to consenting blocks
147+
on any runtime that uses the default resolution path; no host-application
148+
changes are required.
149+
* The service namespace becomes shared between runtime applications and
150+
installed packages. Runtimes always win, and duplicate provider claims
151+
fail loudly, but a future registry of well-known service names would help
152+
providers avoid accidental collisions.
153+
* Operators implicitly accept a package's service registrations by installing
154+
it, as with XBlocks. If field experience shows a need for finer control, a
155+
block-list mechanism can be layered on without changing the provider
156+
contract.
157+
* The behavior of every existing runtime and block is unchanged unless a
158+
package registering ``xblock.service.v1`` entry points is installed.
159+
160+
References
161+
**********
162+
163+
* Community discussion: https://discuss.openedx.org/t/plugin-provided-xblock-runtime-services/18682
164+
* Prior analysis and prototypes of the platform-side alternatives:
165+
ADR-0005 and ADR-0011 in https://github.com/openedx/openedx-ai-extensions
166+
* Original pluggability intent: ``xblock/reference/plugins.py`` (``Service``
167+
docstring)
168+
* Discovery machinery reused: ``xblock/plugin.py``
169+
* Open edX platform ADR *Role of XBlocks* (scope reduction of the platform
170+
runtime): ``docs/decisions/0006-role-of-xblock.rst`` in edx-platform

xblock/runtime.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from xblock.core import XBlock, XBlockAside, XML_NAMESPACES
2323
from xblock.fields import Field, BlockScope, Scope, ScopeIds, UserScope
2424
from xblock.field_data import FieldData
25+
from xblock.plugin import Plugin, PluginMissingError
2526
from xblock.exceptions import (
2627
NoSuchViewError,
2728
NoSuchHandlerError,
@@ -433,6 +434,41 @@ def get_aside_type_from_usage(self, aside_id):
433434
return aside_id.aside_type
434435

435436

437+
class ServiceProvider(Plugin):
438+
"""
439+
Entry-point loader for runtime services contributed by installed packages.
440+
441+
A package can offer an XBlock runtime service by registering a provider
442+
class under the ``xblock.service.v1`` entry-point group::
443+
444+
# in the providing package's setup.py / pyproject.toml
445+
entry_points={
446+
"xblock.service.v1": [
447+
"my_service = my_package.services:MyService",
448+
],
449+
}
450+
451+
The entry-point name is the service name that XBlocks declare with
452+
``@XBlock.needs`` / ``@XBlock.wants`` and pass to
453+
``self.runtime.service(self, name)``.
454+
455+
Services that the runtime itself provides (via the ``services`` constructor
456+
argument or a ``service()`` override) always take precedence; entry points
457+
are only consulted when the runtime does not offer the requested service.
458+
459+
The provider class is instantiated per service request as
460+
``provider_class(runtime=runtime, xblock=block)``, mirroring
461+
:class:`xblock.reference.plugins.Service`. Providers with expensive set-up
462+
should cache that state themselves (e.g. at module or class level).
463+
464+
If two installed packages register the same service name, lookup raises
465+
:class:`xblock.plugin.AmbiguousPluginError` rather than silently picking
466+
one. A deliberate replacement can be registered under the
467+
``xblock.service.v1.overrides`` group, which takes priority.
468+
"""
469+
entry_point = 'xblock.service.v1'
470+
471+
436472
class Runtime(metaclass=ABCMeta):
437473
"""
438474
Access to the runtime environment for XBlocks.
@@ -1097,10 +1133,31 @@ def service(self, block, service_name):
10971133
if declaration is None:
10981134
raise NoSuchServiceError(f"Service {service_name!r} was not requested.")
10991135
service = self._services.get(service_name)
1136+
if service is None:
1137+
service = self._load_service_from_entry_point(block, service_name)
11001138
if service is None and declaration == "need":
11011139
raise NoSuchServiceError(f"Service {service_name!r} is not available.")
11021140
return service
11031141

1142+
def _load_service_from_entry_point(self, block, service_name):
1143+
"""
1144+
Fall back to a service provider registered by an installed package
1145+
under the ``xblock.service.v1`` entry-point group.
1146+
1147+
Only reached when the runtime itself does not provide `service_name`,
1148+
so runtime-provided services always shadow plugin-provided ones.
1149+
1150+
Returns an instance of the provider class, or None if no installed
1151+
package provides `service_name`. Lookup results (including misses) are
1152+
cached by :meth:`xblock.plugin.Plugin.load_class`, so the steady-state
1153+
cost of a miss is a single dict lookup.
1154+
"""
1155+
try:
1156+
service_class = ServiceProvider.load_class(service_name)
1157+
except PluginMissingError:
1158+
return None
1159+
return service_class(runtime=self, xblock=block)
1160+
11041161
# Querying
11051162

11061163
def query(self, block):
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""
2+
Tests for runtime services provided by installed packages via the
3+
``xblock.service.v1`` entry-point group.
4+
"""
5+
import pytest
6+
7+
from xblock.core import XBlock
8+
from xblock.exceptions import NoSuchServiceError
9+
from xblock.fields import ScopeIds
10+
from xblock.runtime import ServiceProvider
11+
from xblock.test.tools import TestRuntime
12+
13+
14+
class DummyAIService:
15+
"""A service provider class, as a plugin package would define it."""
16+
17+
def __init__(self, **kwargs):
18+
self.runtime = kwargs.get('runtime')
19+
self.xblock = kwargs.get('xblock')
20+
21+
def run_profile(self, profile_id, user_input):
22+
"""A representative service method."""
23+
return f"ran {profile_id} with {user_input!r}"
24+
25+
26+
@XBlock.wants('ai_extensions')
27+
class WantsAIBlock(XBlock):
28+
"""An XBlock that can optionally use the ai_extensions service."""
29+
30+
31+
@XBlock.needs('ai_extensions')
32+
class NeedsAIBlock(XBlock):
33+
"""An XBlock that requires the ai_extensions service."""
34+
35+
36+
def make_block(block_class, runtime=None):
37+
"""Construct a block of `block_class` in a fresh TestRuntime."""
38+
runtime = runtime or TestRuntime()
39+
return runtime.construct_xblock_from_class(
40+
block_class, ScopeIds('user', 'test', 'def_id', 'usage_id'),
41+
)
42+
43+
44+
@ServiceProvider.register_temp_plugin(
45+
DummyAIService, identifier='ai_extensions', group='xblock.service.v1',
46+
)
47+
def test_plugin_service_loaded_from_entry_point():
48+
block = make_block(WantsAIBlock)
49+
service = block.runtime.service(block, 'ai_extensions')
50+
assert isinstance(service, DummyAIService)
51+
assert service.runtime is block.runtime
52+
assert service.xblock is block
53+
assert service.run_profile('profile-1', 'hi') == "ran profile-1 with 'hi'"
54+
55+
56+
@ServiceProvider.register_temp_plugin(
57+
DummyAIService, identifier='ai_extensions', group='xblock.service.v1',
58+
)
59+
def test_runtime_service_shadows_plugin_service():
60+
sentinel = object()
61+
runtime = TestRuntime(services={'ai_extensions': sentinel})
62+
block = make_block(WantsAIBlock, runtime=runtime)
63+
assert block.runtime.service(block, 'ai_extensions') is sentinel
64+
65+
66+
def test_missing_plugin_service_wanted_returns_none():
67+
block = make_block(WantsAIBlock)
68+
assert block.runtime.service(block, 'ai_extensions') is None
69+
70+
71+
def test_missing_plugin_service_needed_raises():
72+
block = make_block(NeedsAIBlock)
73+
with pytest.raises(NoSuchServiceError):
74+
block.runtime.service(block, 'ai_extensions')
75+
76+
77+
@ServiceProvider.register_temp_plugin(
78+
DummyAIService, identifier='ai_extensions', group='xblock.service.v1',
79+
)
80+
def test_undeclared_plugin_service_still_raises():
81+
block = make_block(XBlock) # declares neither needs nor wants
82+
with pytest.raises(NoSuchServiceError):
83+
block.runtime.service(block, 'ai_extensions')

0 commit comments

Comments
 (0)