From 033fa5abfffd7d90fbfb227002b6eb1ca4e70cc1 Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Wed, 10 Jun 2026 12:07:32 -0500 Subject: [PATCH 1/3] feat: poc implementation of extendable services Co-Authored-By: Claude Fable 5 --- docs/decisions/0001-service-entry-points.rst | 170 +++++++++++++++++++ xblock/runtime.py | 57 +++++++ xblock/test/test_plugin_services.py | 83 +++++++++ 3 files changed, 310 insertions(+) create mode 100644 docs/decisions/0001-service-entry-points.rst create mode 100644 xblock/test/test_plugin_services.py diff --git a/docs/decisions/0001-service-entry-points.rst b/docs/decisions/0001-service-entry-points.rst new file mode 100644 index 000000000..047689996 --- /dev/null +++ b/docs/decisions/0001-service-entry-points.rst @@ -0,0 +1,170 @@ +0001 Plugin-provided runtime services via entry points +###################################################### + +Status +****** + +Proposed + +Context +******* + +XBlocks consume capabilities from their environment through *runtime +services*: a block declares ``@XBlock.needs("name")`` or +``@XBlock.wants("name")`` and calls ``self.runtime.service(self, "name")``. +The base ``Runtime.service()`` resolves the name against the ``_services`` +dict that the runtime application populated at construction time. + +This makes the *consumption* side of services fully generic, but the +*provision* side closed: only the application that instantiates the runtime +can decide which services exist. In Open edX — by far the largest user of +this library — service wiring is hardcoded in several places +(``ModuleStoreRuntime`` service dicts for LMS/Studio/preview, and the +``if/elif`` chain in the newer ``XBlockRuntime``), and there is no supported +way for a separately installed package to offer a new service. + +The need is real and recurring. The motivating case is an AI-extensions +plugin that wants to offer an ``"ai_extensions"`` service so that blocks like +ORA can call LLM workflows without pinning provider SDKs or importing plugin +internals (see the community thread in the References). But the same gap +applies to any optional capability a pip-installed package might offer to +blocks: translation backends, proctoring integrations, institution-specific +storage, and so on. + +Two facts about the existing design make this library the right place to +close the gap: + +1. **Every runtime already funnels through ``Runtime.service()``.** Open edX + runtimes either populate ``_services`` and delegate to the base method + (``ModuleStoreRuntime``), or run their own chain and fall back to the base + method (``XBlockRuntime``). The xblock-sdk workbench uses the base + behavior directly. A fallback added here is therefore reached by every + known runtime without any changes to host applications. + +2. **The library already has the discovery machinery and the stated intent.** + ``xblock/plugin.py`` loads XBlocks (``xblock.v1``) and asides + (``xblock_asides.v1``) from entry points, with caching, ambiguity + detection, and an ``.overrides`` group. The reference ``Service`` class in + ``xblock/reference/plugins.py`` has documented the goal for years: services + should *"be able to load through Stevedore, and have a plug-in mechanism + similar to XBlock."* + +Decision +******** + +Add a third entry-point group to the XBlock framework, ``xblock.service.v1``, +and a fallback in ``Runtime.service()`` — the ``_load_service_from_entry_point`` +method — that consults it. + +A package provides a service by declaring:: + + entry_points={ + "xblock.service.v1": [ + "my_service = my_package.services:MyService", + ], + } + +where the entry-point name is the service name blocks declare with +``needs``/``wants``. Resolution order in ``Runtime.service()`` becomes: + +1. Reject undeclared requests (unchanged): a block that never declared the + service still gets ``NoSuchServiceError``. +2. Return the runtime-provided service from ``_services`` if present + (unchanged). +3. **New:** if the runtime has nothing, try + ``_load_service_from_entry_point(block, service_name)``, which loads the + provider class from the ``xblock.service.v1`` group and instantiates it as + ``provider_class(runtime=self, xblock=block)``. +4. Apply ``need``/``want`` semantics to the result (unchanged): ``None`` for + a wanted-but-absent service, ``NoSuchServiceError`` for a needed one. + +Reasoning behind the specific choices +===================================== + +**Why a fallback in the base class rather than a hook in each runtime.** +Placing the lookup after the ``_services`` miss, inside the one method every +runtime inherits, gives complete coverage (all Open edX runtimes, the +workbench, third-party runtimes that don't override ``service()``) for a +single small change, and gives a hard guarantee: *runtime-provided services +always shadow plugin-provided ones*. A pip package cannot replace or +intercept ``user``, ``field-data``, ``i18n``, or any other service the host +application provides deliberately. Runtimes that override ``service()`` +entirely keep that freedom — the fallback only exists in the default path +they opt into by calling ``super().service()``. + +**Why entry points rather than configuration.** Entry points are how this +library already discovers XBlocks and asides, so providers and operators deal +with one consistent model: installing a package is the act that makes its +plugins available, and the trust decision is the install decision — exactly +as it is for XBlocks themselves. A settings-based registry would be +runtime-application-specific (this library is not Django-bound) and would put +the burden of wiring on every operator instead of on the providing package. + +**Why the existing ``Plugin`` loader.** Reusing ``Plugin.load_class`` buys, +for free: per-process caching of hits *and misses* (steady-state cost of the +fallback is one dict lookup); loud ``AmbiguousPluginError`` when two installed +packages claim the same service name, instead of last-write-wins — the exact +failure mode that makes monkey-patching unacceptable; a sanctioned override +path (``xblock.service.v1.overrides``) when replacing a default implementation +is intentional; and ``register_temp_plugin`` for tests. + +**Why ``provider_class(runtime=…, xblock=…)``.** This mirrors the +constructor of the reference ``Service`` class, gives the provider the two +context objects almost every service needs (and from which the rest — user, +usage key, learning context — is reachable), and keeps the contract so small +that providers do not need to import ``xblock`` at all. Note that the +fallback returns an *instance*, never a class: some runtimes +(``ModuleStoreRuntime``) call callable services with ``(block)``, and a +class-valued service would be invoked accidentally. Instantiation is +per-request for now; providers with expensive set-up are expected to cache it +themselves (module- or class-level), consistent with the long-standing +"don't over-initialize" guidance in ``reference/plugins.py``. Memoizing per +``(runtime, service_name)`` in the base class is a possible follow-up once +real-world usage shows it is needed. + +**Why the ``needs``/``wants`` gate stays in front.** The declaration check +runs before any entry-point lookup, so a plugin-provided service is only ever +handed to blocks that explicitly asked for it. ``wants`` gives blocks a +portable soft-dependency: the same block works on installs with and without +the providing package, enabling features conditionally. + +Rejected alternatives +===================== + +* **Wiring extension points into each host-application runtime** (new + ``openedx.*`` entry-point group, an ``XBLOCK_EXTRA_SERVICES`` Django + setting, or an openedx-filters filter at resolution time) — all viable, but + each covers only the call sites it patches, must be replicated for every + current and future runtime, and lives in repositories whose architectural + direction is to *shrink* their XBlock-runtime surface, not grow it. These + were prototyped and documented by the openedx-ai-extensions project (see + References) before converging here. + +Consequences +************ + +* Installed packages can provide named runtime services to consenting blocks + on any runtime that uses the default resolution path; no host-application + changes are required. +* The service namespace becomes shared between runtime applications and + installed packages. Runtimes always win, and duplicate provider claims + fail loudly, but a future registry of well-known service names would help + providers avoid accidental collisions. +* Operators implicitly accept a package's service registrations by installing + it, as with XBlocks. If field experience shows a need for finer control, a + block-list mechanism can be layered on without changing the provider + contract. +* The behavior of every existing runtime and block is unchanged unless a + package registering ``xblock.service.v1`` entry points is installed. + +References +********** + +* Community discussion: https://discuss.openedx.org/t/plugin-provided-xblock-runtime-services/18682 +* Prior analysis and prototypes of the platform-side alternatives: + ADR-0005 and ADR-0011 in https://github.com/openedx/openedx-ai-extensions +* Original pluggability intent: ``xblock/reference/plugins.py`` (``Service`` + docstring) +* Discovery machinery reused: ``xblock/plugin.py`` +* Open edX platform ADR *Role of XBlocks* (scope reduction of the platform + runtime): ``docs/decisions/0006-role-of-xblock.rst`` in edx-platform diff --git a/xblock/runtime.py b/xblock/runtime.py index 8aa822dda..7ec6be05b 100644 --- a/xblock/runtime.py +++ b/xblock/runtime.py @@ -22,6 +22,7 @@ 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.plugin import Plugin, PluginMissingError from xblock.exceptions import ( NoSuchViewError, NoSuchHandlerError, @@ -433,6 +434,41 @@ def get_aside_type_from_usage(self, aside_id): return aside_id.aside_type +class ServiceProvider(Plugin): + """ + Entry-point loader for runtime services contributed by installed packages. + + A package can offer an XBlock runtime service by registering a provider + class under the ``xblock.service.v1`` entry-point group:: + + # in the providing package's setup.py / pyproject.toml + entry_points={ + "xblock.service.v1": [ + "my_service = my_package.services:MyService", + ], + } + + The entry-point name is the service name that XBlocks declare with + ``@XBlock.needs`` / ``@XBlock.wants`` and pass to + ``self.runtime.service(self, name)``. + + Services that the runtime itself provides (via the ``services`` constructor + argument or a ``service()`` override) always take precedence; entry points + are only consulted when the runtime does not offer the requested service. + + The provider class is instantiated per service request as + ``provider_class(runtime=runtime, xblock=block)``, mirroring + :class:`xblock.reference.plugins.Service`. Providers with expensive set-up + should cache that state themselves (e.g. at module or class level). + + If two installed packages register the same service name, lookup raises + :class:`xblock.plugin.AmbiguousPluginError` rather than silently picking + one. A deliberate replacement can be registered under the + ``xblock.service.v1.overrides`` group, which takes priority. + """ + entry_point = 'xblock.service.v1' + + class Runtime(metaclass=ABCMeta): """ Access to the runtime environment for XBlocks. @@ -1097,10 +1133,31 @@ def service(self, block, service_name): if declaration is None: raise NoSuchServiceError(f"Service {service_name!r} was not requested.") service = self._services.get(service_name) + if service is None: + service = self._load_service_from_entry_point(block, service_name) if service is None and declaration == "need": raise NoSuchServiceError(f"Service {service_name!r} is not available.") return service + def _load_service_from_entry_point(self, block, service_name): + """ + Fall back to a service provider registered by an installed package + under the ``xblock.service.v1`` entry-point group. + + Only reached when the runtime itself does not provide `service_name`, + so runtime-provided services always shadow plugin-provided ones. + + Returns an instance of the provider class, or None if no installed + package provides `service_name`. Lookup results (including misses) are + cached by :meth:`xblock.plugin.Plugin.load_class`, so the steady-state + cost of a miss is a single dict lookup. + """ + try: + service_class = ServiceProvider.load_class(service_name) + except PluginMissingError: + return None + return service_class(runtime=self, xblock=block) + # Querying def query(self, block): diff --git a/xblock/test/test_plugin_services.py b/xblock/test/test_plugin_services.py new file mode 100644 index 000000000..1ce7ee4bf --- /dev/null +++ b/xblock/test/test_plugin_services.py @@ -0,0 +1,83 @@ +""" +Tests for runtime services provided by installed packages via the +``xblock.service.v1`` entry-point group. +""" +import pytest + +from xblock.core import XBlock +from xblock.exceptions import NoSuchServiceError +from xblock.fields import ScopeIds +from xblock.runtime import ServiceProvider +from xblock.test.tools import TestRuntime + + +class DummyAIService: + """A service provider class, as a plugin package would define it.""" + + def __init__(self, **kwargs): + self.runtime = kwargs.get('runtime') + self.xblock = kwargs.get('xblock') + + def run_profile(self, profile_id, user_input): + """A representative service method.""" + return f"ran {profile_id} with {user_input!r}" + + +@XBlock.wants('ai_extensions') +class WantsAIBlock(XBlock): + """An XBlock that can optionally use the ai_extensions service.""" + + +@XBlock.needs('ai_extensions') +class NeedsAIBlock(XBlock): + """An XBlock that requires the ai_extensions service.""" + + +def make_block(block_class, runtime=None): + """Construct a block of `block_class` in a fresh TestRuntime.""" + runtime = runtime or TestRuntime() + return runtime.construct_xblock_from_class( + block_class, ScopeIds('user', 'test', 'def_id', 'usage_id'), + ) + + +@ServiceProvider.register_temp_plugin( + DummyAIService, identifier='ai_extensions', group='xblock.service.v1', +) +def test_plugin_service_loaded_from_entry_point(): + block = make_block(WantsAIBlock) + service = block.runtime.service(block, 'ai_extensions') + assert isinstance(service, DummyAIService) + assert service.runtime is block.runtime + assert service.xblock is block + assert service.run_profile('profile-1', 'hi') == "ran profile-1 with 'hi'" + + +@ServiceProvider.register_temp_plugin( + DummyAIService, identifier='ai_extensions', group='xblock.service.v1', +) +def test_runtime_service_shadows_plugin_service(): + sentinel = object() + runtime = TestRuntime(services={'ai_extensions': sentinel}) + block = make_block(WantsAIBlock, runtime=runtime) + assert block.runtime.service(block, 'ai_extensions') is sentinel + + +def test_missing_plugin_service_wanted_returns_none(): + block = make_block(WantsAIBlock) + assert block.runtime.service(block, 'ai_extensions') is None + + +def test_missing_plugin_service_needed_raises(): + block = make_block(NeedsAIBlock) + with pytest.raises(NoSuchServiceError): + block.runtime.service(block, 'ai_extensions') + + +@ServiceProvider.register_temp_plugin( + DummyAIService, identifier='ai_extensions', group='xblock.service.v1', +) +def test_undeclared_plugin_service_still_raises(): + block = make_block(XBlock) # declares neither needs nor wants + with pytest.raises(NoSuchServiceError): + block.runtime.service(block, 'ai_extensions') From ea3e7b0e208ea8fcffd3637f1846c5a44cc1212d Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Wed, 10 Jun 2026 16:17:52 -0500 Subject: [PATCH 2/3] feat: addressing feedback on tests and key-presence Co-Authored-By: Claude Fable 5 --- docs/decisions/0001-service-entry-points.rst | 7 ++++++- docs/index.rst | 1 + xblock/runtime.py | 9 ++++++--- xblock/test/test_plugin_services.py | 16 ++++++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/decisions/0001-service-entry-points.rst b/docs/decisions/0001-service-entry-points.rst index 047689996..ee8ec2497 100644 --- a/docs/decisions/0001-service-entry-points.rst +++ b/docs/decisions/0001-service-entry-points.rst @@ -88,7 +88,12 @@ workbench, third-party runtimes that don't override ``service()``) for a single small change, and gives a hard guarantee: *runtime-provided services always shadow plugin-provided ones*. A pip package cannot replace or intercept ``user``, ``field-data``, ``i18n``, or any other service the host -application provides deliberately. Runtimes that override ``service()`` +application provides deliberately. "Provided" is decided by key presence in +``_services``, not truthiness: runtimes use an explicit ``None`` to mean +"this service exists but is disabled here" — the Open edX LMS maps +``completion`` to ``None`` for anonymous users, and this library's own test +suite passes ``services={'i18n': None}`` — and a plugin must not resurrect a +service the runtime switched off. Runtimes that override ``service()`` entirely keep that freedom — the fallback only exists in the default path they opt into by calling ``super().service()``. diff --git a/docs/index.rst b/docs/index.rst index bfbd1b137..6c7f12b2d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,5 +22,6 @@ in depth and guides developers through the process of creating an XBlock. plugins exceptions fragments + decisions/0001-service-entry-points xblock-tutorial/index xblock-utils/index diff --git a/xblock/runtime.py b/xblock/runtime.py index 7ec6be05b..b3434fde7 100644 --- a/xblock/runtime.py +++ b/xblock/runtime.py @@ -454,7 +454,9 @@ class under the ``xblock.service.v1`` entry-point group:: Services that the runtime itself provides (via the ``services`` constructor argument or a ``service()`` override) always take precedence; entry points - are only consulted when the runtime does not offer the requested service. + are only consulted when the runtime has no entry for the requested name. + A runtime entry explicitly set to None counts as provided (it means the + runtime deliberately disabled the service) and is never overridden. The provider class is instantiated per service request as ``provider_class(runtime=runtime, xblock=block)``, mirroring @@ -1132,8 +1134,9 @@ def service(self, block, service_name): declaration = block.service_declaration(service_name) if declaration is None: raise NoSuchServiceError(f"Service {service_name!r} was not requested.") - service = self._services.get(service_name) - if service is None: + if service_name in self._services: + service = self._services[service_name] + else: service = self._load_service_from_entry_point(block, service_name) if service is None and declaration == "need": raise NoSuchServiceError(f"Service {service_name!r} is not available.") diff --git a/xblock/test/test_plugin_services.py b/xblock/test/test_plugin_services.py index 1ce7ee4bf..33e6daf8c 100644 --- a/xblock/test/test_plugin_services.py +++ b/xblock/test/test_plugin_services.py @@ -63,6 +63,22 @@ def test_runtime_service_shadows_plugin_service(): assert block.runtime.service(block, 'ai_extensions') is sentinel +@ServiceProvider.register_temp_plugin( + DummyAIService, identifier='ai_extensions', group='xblock.service.v1', +) +def test_runtime_none_service_disables_plugin_service(): + wants_block = make_block( + WantsAIBlock, runtime=TestRuntime(services={'ai_extensions': None}), + ) + assert wants_block.runtime.service(wants_block, 'ai_extensions') is None + + needs_block = make_block( + NeedsAIBlock, runtime=TestRuntime(services={'ai_extensions': None}), + ) + with pytest.raises(NoSuchServiceError): + needs_block.runtime.service(needs_block, 'ai_extensions') + + def test_missing_plugin_service_wanted_returns_none(): block = make_block(WantsAIBlock) assert block.runtime.service(block, 'ai_extensions') is None From a47654f320c86a2c1e905ca1d4276f47357f07c2 Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Wed, 10 Jun 2026 18:21:14 -0500 Subject: [PATCH 3/3] fix: correcting docs for build --- docs/plugins.rst | 6 ++++++ xblock/reference/plugins.py | 1 + 2 files changed, 7 insertions(+) diff --git a/docs/plugins.rst b/docs/plugins.rst index cbcc43a8d..68f945086 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -7,5 +7,11 @@ Plugins API .. autoclass:: xblock.plugin.Plugin :members: +.. autoclass:: xblock.plugin.AmbiguousPluginError + :members: + +.. autoclass:: xblock.reference.plugins.Service + :members: + .. autoclass:: xblock.reference.plugins.Filesystem :members: diff --git a/xblock/reference/plugins.py b/xblock/reference/plugins.py index 17825942e..d8609d8ab 100644 --- a/xblock/reference/plugins.py +++ b/xblock/reference/plugins.py @@ -70,6 +70,7 @@ class Service: necessarily a finished interface. Possible goals: + * Right now, they derive from object. We'd like there to be a common superclass. * We'd like to be able to provide both language-level and