From cf335f0fd028c05e9c06729078692e404c48969b Mon Sep 17 00:00:00 2001 From: Fox Piacenti Date: Wed, 10 Jun 2026 12:34:02 -0500 Subject: [PATCH 1/5] refactor: fold web_fragments into XBlock python project (#917) Part of: https://github.com/openedx/web-fragments/issues/309 Bumps version to 6.2.0 --- CHANGELOG.rst | 6 + Makefile | 2 +- docs/fragments.rst | 11 + docs/index.rst | 1 + pyproject.toml | 6 +- tox.ini | 4 +- uv.lock | 11 - web_fragments/__init__.py | 3 + web_fragments/apps.py | 12 + web_fragments/examples/__init__.py | 0 web_fragments/examples/urls.py | 12 + web_fragments/examples/views.py | 25 ++ web_fragments/fragment.py | 269 ++++++++++++++++++ .../web_fragments/standalone_fragment.html | 9 + web_fragments/test_utils/__init__.py | 15 + web_fragments/tests/test_fragment.py | 250 ++++++++++++++++ web_fragments/tests/test_views.py | 94 ++++++ web_fragments/views.py | 57 ++++ xblock/__init__.py | 2 +- xblock/test/settings.py | 15 +- xblock/test/test_core_capabilities.py | 10 +- 21 files changed, 790 insertions(+), 24 deletions(-) create mode 100644 docs/fragments.rst create mode 100644 web_fragments/__init__.py create mode 100644 web_fragments/apps.py create mode 100644 web_fragments/examples/__init__.py create mode 100644 web_fragments/examples/urls.py create mode 100644 web_fragments/examples/views.py create mode 100644 web_fragments/fragment.py create mode 100644 web_fragments/templates/web_fragments/standalone_fragment.html create mode 100644 web_fragments/test_utils/__init__.py create mode 100644 web_fragments/tests/test_fragment.py create mode 100644 web_fragments/tests/test_views.py create mode 100644 web_fragments/views.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2b6a3b0f4..85ee6cd6a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,12 @@ Change history for XBlock Unreleased ---------- +6.2.0 - 2026-06-09 +------------------ + +* Migrated web_fragments into XBlock's project. You should remove web_fragments as a separate dependency if you + currently depend on it directly. + 5.3.0 - 2025-12-19 ------------------ diff --git a/Makefile b/Makefile index 9d9314bf9..4342b0ddf 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ help: ## display this help message quality: ## check coding style with pycodestyle and pylint pycodestyle - pylint xblock + pylint xblock web_fragments validate: test diff --git a/docs/fragments.rst b/docs/fragments.rst new file mode 100644 index 000000000..2993036b6 --- /dev/null +++ b/docs/fragments.rst @@ -0,0 +1,11 @@ +.. _Fragments API: + +############# +Fragments API +############# + +.. automodule:: web_fragments.fragment + :members: + +.. autoclass:: web_fragments.views.FragmentView + :members: diff --git a/docs/index.rst b/docs/index.rst index 629ce2428..bfbd1b137 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,5 +21,6 @@ in depth and guides developers through the process of creating an XBlock. runtime plugins exceptions + fragments xblock-tutorial/index xblock-utils/index diff --git a/pyproject.toml b/pyproject.toml index e307b7271..dc1213899 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ dependencies = [ "pytz", "pyyaml", "simplejson", - "web-fragments", "webob>=1.6.0", ] @@ -54,10 +53,15 @@ local_scheme = "no-local-version" include-package-data = true [tool.setuptools.packages.find] +include = [ + "xblock*", + "web_fragments*", +] [tool.setuptools.package-data] "xblock.utils" = ["public/*", "templates/*", "templatetags/*"] "xblock.test.utils" = ["data/*"] +"web_fragments" = ["templates/*"] [dependency-groups] # Base test packages, no Django version pinned diff --git a/tox.ini b/tox.ini index 2400723e6..eea317a84 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ requires = tox-uv>=1 [pytest] DJANGO_SETTINGS_MODULE = xblock.test.settings -addopts = --cov xblock +addopts = --cov xblock --cov web_fragments filterwarnings = always norecursedirs = .* docs requirements @@ -14,7 +14,7 @@ dependency_groups = django42: django42 django52: test commands = - python -Wd -m pytest {posargs:xblock} + python -Wd -m pytest {posargs:xblock web_fragments} python -m coverage xml allowlist_externals = make diff --git a/uv.lock b/uv.lock index 6272db8f9..423cbe2ba 100644 --- a/uv.lock +++ b/uv.lock @@ -1529,15 +1529,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" }, ] -[[package]] -name = "web-fragments" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/1c/938b2e2a7908937361dcaaeb7afe17ca0f1ca9e68c335c72820b772c5b24/web_fragments-4.0.0.tar.gz", hash = "sha256:e82488beb4e8666b9e37a10a81258142f404f4e1964b31d3010154896832f90b", size = 15590, upload-time = "2026-03-10T14:30:43.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/f8/28aa823f4a618481908bc3bb815e5961cc6e79682e7ceb568bcc4d6f10f4/web_fragments-4.0.0-py2.py3-none-any.whl", hash = "sha256:0d5f59c63b2ac5ee95f76f5904c2f20d0e83d6a1425680fcc676485b13f85d32", size = 15580, upload-time = "2026-03-10T14:30:41.406Z" }, -] - [[package]] name = "webob" version = "1.8.10" @@ -1563,7 +1554,6 @@ dependencies = [ { name = "pytz" }, { name = "pyyaml" }, { name = "simplejson" }, - { name = "web-fragments" }, { name = "webob" }, ] @@ -1691,7 +1681,6 @@ requires-dist = [ { name = "pytz" }, { name = "pyyaml" }, { name = "simplejson" }, - { name = "web-fragments" }, { name = "webob", specifier = ">=1.6.0" }, ] provides-extras = ["django"] diff --git a/web_fragments/__init__.py b/web_fragments/__init__.py new file mode 100644 index 000000000..41db97264 --- /dev/null +++ b/web_fragments/__init__.py @@ -0,0 +1,3 @@ +""" +Web fragments. +""" diff --git a/web_fragments/apps.py b/web_fragments/apps.py new file mode 100644 index 000000000..45c691585 --- /dev/null +++ b/web_fragments/apps.py @@ -0,0 +1,12 @@ +""" +Web Fragments Django application initialization. +""" +from django.apps import AppConfig + + +class WebFragmentsConfig(AppConfig): + """ + Configuration for the Web Fragments Django application. + """ + + name = 'web_fragments' diff --git a/web_fragments/examples/__init__.py b/web_fragments/examples/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/web_fragments/examples/urls.py b/web_fragments/examples/urls.py new file mode 100644 index 000000000..990182c36 --- /dev/null +++ b/web_fragments/examples/urls.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python + +""" +Provides a URL for testing +""" +from django.urls import path + +from web_fragments.examples.views import EXAMPLE_FRAGMENT_VIEW_NAME, ExampleFragmentView + +urlpatterns = [ + path('test_fragment', ExampleFragmentView.as_view(), name=EXAMPLE_FRAGMENT_VIEW_NAME), +] diff --git a/web_fragments/examples/views.py b/web_fragments/examples/views.py new file mode 100644 index 000000000..b3a95233c --- /dev/null +++ b/web_fragments/examples/views.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python + +""" +Example fragment view. +""" +from web_fragments.fragment import Fragment +from web_fragments.test_utils import TEST_CSS, TEST_HTML, TEST_JS +from web_fragments.views import FragmentView + +EXAMPLE_FRAGMENT_VIEW_NAME = 'example_fragment_view' + + +class ExampleFragmentView(FragmentView): + """ + Simple fragment view for testing. + """ + + def render_to_fragment(self, request, **kwargs): + """ + Returns a simple fragment + """ + fragment = Fragment(TEST_HTML) + fragment.add_javascript(TEST_JS) + fragment.add_css(TEST_CSS) + return fragment diff --git a/web_fragments/fragment.py b/web_fragments/fragment.py new file mode 100644 index 000000000..6a3b5c6f7 --- /dev/null +++ b/web_fragments/fragment.py @@ -0,0 +1,269 @@ +""" +Python representation of a web fragment. +""" + +from collections import namedtuple + +FragmentResource = namedtuple("FragmentResource", "kind, data, mimetype, placement") + +JS_API_VERSION = 1 + + +class Fragment: + """ + A fragment of a web page to be included on another page. + + A fragment consists of HTML for the body of the page, and a series of + resources needed by the body. Resources are specified with a MIME type + (such as "application/javascript" or "text/css") that determines how they + are inserted into the page. The resource is provided either as literal + text, or as a URL. Text will be included on the page, wrapped + appropriately for the MIME type. URLs will be used as-is on the page. + + Resources are only inserted into the page once, even if many Fragments + in the page ask for them. Determining duplicates is done by simple text + matching. + """ + def __init__(self, content=None): + #: The html content for this Fragment + self.content = "" + + self._resources = [] + self.js_init_fn = None + self.js_init_version = None + self.json_init_args = None + + if content is not None: + self.add_content(content) + + @property + def resources(self): + """ + Returns list of unique FragmentResource named tuples by order of first + appearance. + """ + seen = set() + # seen.add always returns None, so 'not seen.add(x)' is always True, + # but will only be called if the value is not already in seen (because + # 'and' short-circuits) + return [x for x in self._resources if x not in seen and not seen.add(x)] + + def to_dict(self): + """ + Returns the fragment in a dictionary representation. + """ + return { + 'content': self.content, + 'resources': [r._asdict() for r in self.resources], + 'js_init_fn': self.js_init_fn, + 'js_init_version': self.js_init_version, + 'json_init_args': self.json_init_args + } + + @classmethod + def from_dict(cls, pods): + """ + Returns a new Fragment from a dictionary representation. + """ + frag = cls() + frag.content = pods['content'] + frag._resources = [FragmentResource(**d) for d in pods['resources']] + frag.js_init_fn = pods['js_init_fn'] + frag.js_init_version = pods['js_init_version'] + frag.json_init_args = pods['json_init_args'] + return frag + + def add_content(self, content): + """ + Add content to this fragment. + + `content` is a Unicode string, HTML to append to the body of the + fragment. It must not contain a ```` tag, or otherwise assume + that it is the only content on the page. + """ + assert isinstance(content, str) + self.content += content + + def _default_placement(self, mimetype): + """ + Decide where a resource will go, if the user didn't say. + """ + if mimetype == 'application/javascript': + return 'foot' + return 'head' + + def add_resource(self, text, mimetype, placement=None): + """ + Add a resource needed by this Fragment. + + Other helpers, such as :func:`add_css` or :func:`add_javascript` are + more convenient for those common types of resource. + + `text`: the actual text of this resource, as a unicode string. + + `mimetype`: the MIME type of the resource. + + `placement`: where on the page the resource should be placed: + + None: let the Fragment choose based on the MIME type. + + "head": put this resource in the ```` of the page. + + "foot": put this resource at the end of the ```` of the + page. + + """ + if not placement: + placement = self._default_placement(mimetype) + res = FragmentResource('text', text, mimetype, placement) + self._resources.append(res) + + def add_resource_url(self, url, mimetype, placement=None): + """ + Add a resource by URL needed by this Fragment. + + Other helpers, such as :func:`add_css_url` or + :func:`add_javascript_url` are more convenent for those common types of + resource. + + `url`: the URL to the resource. + + Other parameters are as defined for :func:`add_resource`. + """ + if not placement: + placement = self._default_placement(mimetype) + self._resources.append(FragmentResource('url', url, mimetype, placement)) + + def add_css(self, text): + """ + Add literal CSS to the Fragment. + """ + self.add_resource(text, 'text/css') + + def add_css_url(self, url): + """ + Add a CSS URL to the Fragment. + """ + self.add_resource_url(url, 'text/css') + + def add_javascript(self, text): + """ + Add literal Javascript to the Fragment. + """ + self.add_resource(text, 'application/javascript') + + def add_javascript_url(self, url): + """ + Add a Javascript URL to the Fragment. + """ + self.add_resource_url(url, 'application/javascript') + + def add_fragment_resources(self, fragment): + """ + Add all the resources from a single fragment to my resources. + + This is used to aggregate resources from another fragment that + should be considered part of the current fragment. + + The content from the Fragment is ignored. The caller must collect + together the content into this Fragment's content. + """ + self._resources.extend(fragment.resources) + + def add_resources(self, fragments): + """ + Add all the resources from `fragments` to my resources. + + This is used to aggregate resources from a sequence of fragments that + should be considered part of the current fragment. + + The content from the Fragments is ignored. The caller must collect + together the content into this Fragment's content. + """ + for fragment in fragments: + self.add_fragment_resources(fragment) + + def initialize_js(self, js_func, json_args=None): + """ + Register a Javascript function to initialize the Javascript resources. + + `js_func` is the name of a Javascript function defined by one of the + Javascript resources. As part of setting up the browser's runtime + environment, the function will be invoked, passing a runtime object + and a DOM element. + """ + self.js_init_fn = js_func + self.js_init_version = JS_API_VERSION + if json_args: + self.json_init_args = json_args + + def body_html(self): + """ + Get the body HTML for this Fragment. + + Returns a Unicode string, the HTML content for the ```` section + of the page. + """ + return self.content + + def head_html(self): + """ + Get the head HTML for this Fragment. + + Returns a Unicode string, the HTML content for the ```` section + of the page. + """ + return self.resources_to_html("head") + + def foot_html(self): + """ + Get the foot HTML for this Fragment. + + Returns a Unicode string, the HTML content for the end of the + ```` section of the page. + """ + return self.resources_to_html("foot") + + def resources_to_html(self, placement): + """ + Get some resource HTML for this Fragment. + + `placement` is "head" or "foot". + + Returns a unicode string, the HTML for the head or foot of the page. + """ + # - non url js could be wrapped in an anonymous function + # - non url css could be rewritten to match the wrapper tag + + return '\n'.join( + self.resource_to_html(resource) + for resource in self.resources + if resource.placement == placement + ) + + @staticmethod + def resource_to_html(resource): + """ + Returns `resource` wrapped in the appropriate html tag for it's mimetype. + """ + if resource.mimetype == "text/css": + if resource.kind == "text": + return f"" + if resource.kind == "url": + return f"" + + raise Exception(f"Unrecognized resource kind {resource.kind}") + + if resource.mimetype == "application/javascript": + if resource.kind == "text": + return f"" + if resource.kind == "url": + return f"" + + raise Exception(f"Unrecognized resource kind {resource.kind}") + + if resource.mimetype == "text/html": + assert resource.kind == "text" + return resource.data + + raise Exception("Unrecognized mimetype {resource.mimetype}") diff --git a/web_fragments/templates/web_fragments/standalone_fragment.html b/web_fragments/templates/web_fragments/standalone_fragment.html new file mode 100644 index 000000000..ce9e625b4 --- /dev/null +++ b/web_fragments/templates/web_fragments/standalone_fragment.html @@ -0,0 +1,9 @@ + + + {{ head_html|safe }} + + + {{ body_html|safe }} + {{ foot_html|safe }} + + diff --git a/web_fragments/test_utils/__init__.py b/web_fragments/test_utils/__init__.py new file mode 100644 index 000000000..c7a07aa51 --- /dev/null +++ b/web_fragments/test_utils/__init__.py @@ -0,0 +1,15 @@ +""" +Test utilities. +""" +TEST_HTML = '

Hello, world!

' +TEST_CSS = 'body {background-color:red;}' +TEST_CSS_URL = '/css/test.css' +TEST_JS = 'window.alert("Hello");' +TEST_JS_URL = '/js/test.js' +TEST_JS_INIT_FN = 'mock_initialize' +TEST_JSON_INIT_ARGS = {'test_value': 1} + +CSS_EXPECTED_HTML = "" +CSS_LINK_EXPECTED_HTML = "" +JS_EXPECTED_HTML = "" +JS_LINK_EXPECTED_HTML = "" diff --git a/web_fragments/tests/test_fragment.py b/web_fragments/tests/test_fragment.py new file mode 100644 index 000000000..809b03046 --- /dev/null +++ b/web_fragments/tests/test_fragment.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python + +""" +Unit tests for the Fragment class. +""" +import ddt +import pytest + +from django.test import TestCase + +from web_fragments.fragment import Fragment, FragmentResource +from web_fragments.test_utils import ( + CSS_EXPECTED_HTML, + CSS_LINK_EXPECTED_HTML, + JS_EXPECTED_HTML, + JS_LINK_EXPECTED_HTML, + TEST_CSS, + TEST_CSS_URL, + TEST_HTML, + TEST_JS, + TEST_JS_INIT_FN, + TEST_JS_URL, + TEST_JSON_INIT_ARGS +) + +EXPECTED_JS_INIT_VERSION = 1 + +EXPECTED_RESOURCES = [ + { + 'kind': 'text', + 'data': TEST_CSS, + 'mimetype': 'text/css', + 'placement': 'head', + }, + { + 'kind': 'url', + 'data': TEST_CSS_URL, + 'mimetype': 'text/css', + 'placement': 'head', + }, + { + 'kind': 'text', + 'data': TEST_JS, + 'mimetype': 'application/javascript', + 'placement': 'foot', + }, + { + 'kind': 'url', + 'data': TEST_JS_URL, + 'mimetype': 'application/javascript', + 'placement': 'foot', + }, +] + + +@ddt.ddt +class TestFragment(TestCase): + """ + Unit tests for fragments. + """ + def create_test_fragment(self): + """ + Creates a fragment for use in unit tests. + """ + fragment = Fragment() + fragment.add_content(TEST_HTML) + fragment.add_css(TEST_CSS) + fragment.add_css_url(TEST_CSS_URL) + fragment.add_javascript(TEST_JS) + fragment.add_javascript_url(TEST_JS_URL) + fragment.initialize_js(TEST_JS_INIT_FN, json_args=TEST_JSON_INIT_ARGS) + return fragment + + def validate_fragment(self, fragment=None, fragment_dict=None): + """ + Validates that the fields of a fragment are all correct. + """ + fragment_dict = fragment_dict if fragment_dict else fragment.to_dict() + assert fragment_dict['content'] == TEST_HTML + assert fragment_dict['js_init_fn'] == TEST_JS_INIT_FN + assert fragment_dict['js_init_version'] == EXPECTED_JS_INIT_VERSION + assert fragment_dict['json_init_args'] == TEST_JSON_INIT_ARGS + assert fragment_dict['resources'] == EXPECTED_RESOURCES + + def test_to_dict(self): + """ + Tests the to_dict method. + """ + fragment = self.create_test_fragment() + fragment_dict = fragment.to_dict() + self.validate_fragment(fragment_dict=fragment_dict) + + def test_from_dict(self): + """ + Tests the from_dict method. + """ + test_dict = { + 'content': TEST_HTML, + 'resources': EXPECTED_RESOURCES, + 'js_init_fn': TEST_JS_INIT_FN, + 'js_init_version': EXPECTED_JS_INIT_VERSION, + 'json_init_args': TEST_JSON_INIT_ARGS, + } + fragment = Fragment.from_dict(test_dict) + self.validate_fragment(fragment) + + def test_body_html(self): + """ + Tests the body_html method. + """ + fragment = self.create_test_fragment() + html = fragment.body_html() + assert html == TEST_HTML + + def test_head_html(self): + """ + Tests the head_html method. + """ + fragment = self.create_test_fragment() + html = fragment.head_html().replace('\n', '') + assert CSS_EXPECTED_HTML.format(css=TEST_CSS) in html + assert CSS_LINK_EXPECTED_HTML.format(css_url=TEST_CSS_URL) in html + + def test_foot_html(self): + """ + Tests the foot_html method. + """ + fragment = self.create_test_fragment() + html = fragment.foot_html().replace('\n', '') + assert JS_EXPECTED_HTML.format(js=TEST_JS) in html + assert JS_LINK_EXPECTED_HTML.format(js_url=TEST_JS_URL) in html + + def test_add_resource(self): + """ + Tests the add_resource method. + """ + fragment = Fragment() + fragment.add_resource(TEST_CSS, 'text/css') + fragment.add_resource(TEST_JS, 'application/javascript') + fragment.add_resource(TEST_JS, 'application/javascript', placement='bottom') + assert fragment.to_dict()['resources'] == [ + { + 'kind': 'text', + 'data': TEST_CSS, + 'mimetype': 'text/css', + 'placement': 'head', + }, + { + 'kind': 'text', + 'data': TEST_JS, + 'mimetype': 'application/javascript', + 'placement': 'foot', + }, + { + 'kind': 'text', + 'data': TEST_JS, + 'mimetype': 'application/javascript', + 'placement': 'bottom', + }, + ] + + def test_add_resource_url(self): + """ + Tests the add_resource_url method. + """ + fragment = Fragment() + fragment.add_resource_url(TEST_CSS_URL, 'text/css') + fragment.add_resource_url(TEST_JS_URL, 'application/javascript') + fragment.add_resource_url(TEST_JS_URL, 'application/javascript', placement='bottom') + assert fragment.to_dict()['resources'] == [ + { + 'kind': 'url', + 'data': TEST_CSS_URL, + 'mimetype': 'text/css', + 'placement': 'head', + }, + { + 'kind': 'url', + 'data': TEST_JS_URL, + 'mimetype': 'application/javascript', + 'placement': 'foot', + }, + { + 'kind': 'url', + 'data': TEST_JS_URL, + 'mimetype': 'application/javascript', + 'placement': 'bottom', + }, + ] + + def test_add_resources(self): + """ + Tests the add_resources method. + """ + source_fragment = self.create_test_fragment() + test_fragment = Fragment('

new fragment

') + test_fragment.add_resources([source_fragment]) + + @ddt.data( + ( + FragmentResource('text', TEST_HTML, 'text/html', 'body'), + TEST_HTML + ), + ( + FragmentResource('text', TEST_CSS, 'text/css', 'head'), + CSS_EXPECTED_HTML.format(css=TEST_CSS)), + ( + FragmentResource('url', TEST_CSS_URL, 'text/css', 'head'), + CSS_LINK_EXPECTED_HTML.format(css_url=TEST_CSS_URL) + ), + ( + FragmentResource('text', TEST_JS, 'application/javascript', 'body'), + JS_EXPECTED_HTML.format(js=TEST_JS)), + ( + FragmentResource('url', TEST_JS_URL, 'application/javascript', 'foot'), + JS_LINK_EXPECTED_HTML.format(js_url=TEST_JS_URL) + ), + ) + @ddt.unpack + def test_resource_to_html(self, resource, expected_html): + """ + Tests the resource_to_html method. + """ + actual_html = Fragment.resource_to_html(resource).replace('\n', '') + assert actual_html == expected_html + + @ddt.data( + FragmentResource('unknown', TEST_HTML, 'text/html', 'body'), + FragmentResource('text', TEST_HTML, 'text/unknown', 'body'), + FragmentResource('unknown', TEST_CSS, 'text/css', 'head'), + FragmentResource('unknown', TEST_JS, 'application/javascript', 'body'), + FragmentResource('text', TEST_HTML, 'unknown', 'body'), + ) + def test_resource_to_html_exception(self, resource): + """ + Tests the resource_to_html method. + """ + with pytest.raises(Exception): + Fragment.resource_to_html(resource) + + def test_initialize_js(self): + """ + Tests for initialize_js method. + """ + fragment = Fragment() + fragment.initialize_js(TEST_JS_INIT_FN) + fragment_dict = fragment.to_dict() + assert fragment_dict['js_init_fn'] == TEST_JS_INIT_FN + assert fragment_dict['js_init_version'] == EXPECTED_JS_INIT_VERSION + assert fragment_dict['json_init_args'] is None diff --git a/web_fragments/tests/test_views.py b/web_fragments/tests/test_views.py new file mode 100644 index 000000000..011781255 --- /dev/null +++ b/web_fragments/tests/test_views.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python + +""" +Unit tests for web fragment views +""" +import json + +import ddt +import pytest + +from django.test import TestCase +from django.test.client import RequestFactory +from django.urls import reverse + +from web_fragments.examples.views import EXAMPLE_FRAGMENT_VIEW_NAME, ExampleFragmentView +from web_fragments.test_utils import TEST_HTML +from web_fragments.views import FragmentView + + +@ddt.ddt +class TestViews(TestCase): + """ + Unit tests for web fragment views. + """ + + def setUp(self): + super().setUp() + self.requests_factory = RequestFactory() + + def create_mock_request(self, method=None, arguments=None, http_accept='text/html'): + """ + Creates a mock request to the test fragment view. + """ + url = reverse(EXAMPLE_FRAGMENT_VIEW_NAME) + ('/?' + arguments if arguments else '') + method = method if method else self.requests_factory.get + return method(url, HTTP_ACCEPT=http_accept) + + def invoke_test_view(self, method=None, arguments=None, http_accept='text/html', expected_status_code=200): + """ + Invokes the test view with the specified arguments (if provided). + """ + request = self.create_mock_request(method=method, arguments=arguments, http_accept=http_accept) + response = ExampleFragmentView.as_view()(request) + assert response.status_code == expected_status_code + return response + + @ddt.data( + ('format=json', 'text/html'), + (None, 'application/web-fragment'), + ) + @ddt.unpack + def test_get_json(self, arguments, http_accept): + """ + Test that the view returns the correct JSON when requested. + """ + response = self.invoke_test_view(arguments=arguments, http_accept=http_accept) + fragment_json = json.loads(response.content.decode(response.charset)) + assert fragment_json['content'] == TEST_HTML + + @ddt.data( + ('format=html', 'text/html'), + (None, 'text/html'), + ) + @ddt.unpack + def test_get_html(self, arguments, http_accept): + """ + Test fragment getter when html is requested + """ + response = self.invoke_test_view(arguments=arguments, http_accept=http_accept) + assert TEST_HTML in response.content.decode(response.charset) + + def test_render_fragment_error(self): + """ + Verifies that render_fragment throws an unimplemented error on the base class. + """ + class MockFragmentView(FragmentView): + """ + Mock fragment view to verify the default render_fragment method + """ + def render_to_fragment(self, request, **kwargs): # pylint: disable=useless-super-delegation + super().render_to_fragment(request, **kwargs) + + view = MockFragmentView() + request = self.create_mock_request() + with pytest.raises(NotImplementedError): + view.render_to_fragment(request) + + def test_render_with_no_fragment(self): + """ + Verifies that a fragment view can render with no fragment. + """ + request = self.create_mock_request() + response = ExampleFragmentView().render_standalone_response(request, None) + assert response.status_code == 204 diff --git a/web_fragments/views.py b/web_fragments/views.py new file mode 100644 index 000000000..d60a438ea --- /dev/null +++ b/web_fragments/views.py @@ -0,0 +1,57 @@ +""" +Django view implementation of web fragments. +""" +from abc import ABCMeta, abstractmethod + +from django.http import HttpResponse, JsonResponse +from django.template.loader import get_template +from django.views.generic import View + +WEB_FRAGMENT_RESPONSE_TYPE = 'application/web-fragment' +STANDALONE_TEMPLATE_NAME = 'web_fragments/standalone_fragment.html' + + +class FragmentView(View, metaclass=ABCMeta): + """ + Base class for Django web fragment views. + """ + + def get(self, request, *args, **kwargs): + """ + Render a fragment to HTML or return JSON describing it, based on the request. + """ + fragment = self.render_to_fragment(request, **kwargs) + response_format = request.GET.get('format') or request.POST.get('format') or 'html' + if response_format == 'json' or WEB_FRAGMENT_RESPONSE_TYPE in request.headers.get('accept', 'text/html'): + return JsonResponse(fragment.to_dict()) + + return self.render_standalone_response(request, fragment, **kwargs) + + def render_standalone_response(self, request, fragment, **kwargs): + """ + Renders a standalone page as a response for the specified fragment. + """ + if fragment is None: + return HttpResponse(status=204) + + html = self.render_to_standalone_html(request, fragment, **kwargs) + return HttpResponse(html) + + def render_to_standalone_html(self, request, fragment, **kwargs): + """ + Render the specified fragment to HTML for a standalone page. + """ + template = get_template(STANDALONE_TEMPLATE_NAME) + context = { + 'head_html': fragment.head_html(), + 'body_html': fragment.body_html(), + 'foot_html': fragment.foot_html(), + } + return template.render(context) + + @abstractmethod + def render_to_fragment(self, request, **kwargs): + """ + Render this view to a fragment. + """ + raise NotImplementedError() diff --git a/xblock/__init__.py b/xblock/__init__.py index 1890416b1..444f981b4 100644 --- a/xblock/__init__.py +++ b/xblock/__init__.py @@ -2,4 +2,4 @@ XBlock Courseware Components """ -__version__ = '6.1.0' +__version__ = '6.2.0' diff --git a/xblock/test/settings.py b/xblock/test/settings.py index 9f349dd4d..cf0acd1ca 100644 --- a/xblock/test/settings.py +++ b/xblock/test/settings.py @@ -99,12 +99,9 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - - # Uncomment the next line to enable the admin: 'django.contrib.admin', - # Uncomment the next line to enable admin documentation: - # 'django.contrib.admindocs', + 'web_fragments', ) # A sample logging configuration. The only tangible logging @@ -139,6 +136,16 @@ } } +ROOT_URLCONF = 'web_fragments.examples.urls' + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + }, +] + WORKBENCH = { 'reset_state_on_restart': ( os.environ.get('WORKBENCH_RESET_STATE_ON_RESTART', "false").lower() == "true" diff --git a/xblock/test/test_core_capabilities.py b/xblock/test/test_core_capabilities.py index 0919a35ee..c822ec9da 100644 --- a/xblock/test/test_core_capabilities.py +++ b/xblock/test/test_core_capabilities.py @@ -27,13 +27,15 @@ class AttrAssertionMixin(TestCase): """ A mixin to add attribute assertion methods to TestCases. """ - def assertHasAttr(self, obj, attr): + def assertHasAttr(self, obj, name, msg=None): "Assert that `obj` has the attribute named `attr`." - self.assertTrue(hasattr(obj, attr), f"{obj!r} doesn't have attribute {attr!r}") + msg = msg or f"{obj!r} doesn't have attribute {name!r}" + self.assertTrue(hasattr(obj, name), msg) - def assertNotHasAttr(self, obj, attr): + def assertNotHasAttr(self, obj, name, msg=None): "Assert that `obj` doesn't have the attribute named `attr`." - self.assertFalse(hasattr(obj, attr), f"{obj!r} has attribute {attr!r}") + msg = msg or f"{obj!r} has attribute {name!r}" + self.assertFalse(hasattr(obj, name), msg) class TestScopedStorage(AttrAssertionMixin, TestCase): From 125ad1d7344b8bc4b9e0e413ed05bb1eaf0686c7 Mon Sep 17 00:00:00 2001 From: Fox Danger Piacenti Date: Wed, 10 Jun 2026 14:32:34 -0500 Subject: [PATCH 2/5] feat: force release This commit contains no changes, but satisfies the requirements of the configured GitHub actions to publish a release to PyPi. From 033fa5abfffd7d90fbfb227002b6eb1ca4e70cc1 Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Wed, 10 Jun 2026 12:07:32 -0500 Subject: [PATCH 3/5] 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 4/5] 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 5/5] 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