Skip to content
Open
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
175 changes: 175 additions & 0 deletions docs/decisions/0001-service-entry-points.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
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. "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()``.

**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
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions docs/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
1 change: 1 addition & 0 deletions xblock/reference/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 61 additions & 1 deletion xblock/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -433,6 +434,43 @@ 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 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
: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.
Expand Down Expand Up @@ -1096,11 +1134,33 @@ 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_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.")
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):
Expand Down
99 changes: 99 additions & 0 deletions xblock/test/test_plugin_services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""
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


@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


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')