From b954f18c85d5dbabfa7a2725563f303d5a849fbe Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Wed, 6 May 2026 00:38:40 +0800 Subject: [PATCH 01/10] refactor: Load extra context on demand --- docs/api.rst | 18 +- docs/conf.py | 4 + docs/conf.rst | 2 - docs/ext.rst | 8 +- docs/tmpl.rst | 19 +- docs/usage.rst | 6 - src/sphinxnotes/render/__init__.py | 12 +- src/sphinxnotes/render/ctxnodes.py | 17 +- src/sphinxnotes/render/ext/adhoc.py | 9 - src/sphinxnotes/render/ext/derive.py | 2 - src/sphinxnotes/render/ext/extractx.py | 64 +++--- src/sphinxnotes/render/extractx.py | 183 +++++------------- src/sphinxnotes/render/jinja.py | 18 +- src/sphinxnotes/render/pipeline.py | 11 -- src/sphinxnotes/render/template.py | 4 +- .../roots/test-extra-context-rebuild/conf.py | 1 + .../test-extra-context-rebuild/index.rst | 11 ++ tests/roots/test-extra-context/conf.py | 8 +- tests/roots/test-extra-context/index.rst | 1 - tests/test_e2e.py | 11 ++ 20 files changed, 143 insertions(+), 266 deletions(-) create mode 100644 tests/roots/test-extra-context-rebuild/conf.py create mode 100644 tests/roots/test-extra-context-rebuild/index.rst diff --git a/docs/api.rst b/docs/api.rst index 270f453..8f472f0 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -92,24 +92,16 @@ Extra Context ============= See :doc:`tmpl` for built-in extra-context names such as ``doc`` and -``sphinx``, plus usage examples. +``env``, plus usage examples. .. autodecorator:: sphinxnotes.render.extra_context -.. autoclass:: sphinxnotes.render.ParsingPhaseExtraContext - :members: phase, generate +.. autoclass:: sphinxnotes.render.ExtraContext + :members: generate :undoc-members: -.. autoclass:: sphinxnotes.render.ParsedPhaseExtraContext - :members: phase, generate - :undoc-members: - -.. autoclass:: sphinxnotes.render.ResolvingPhaseExtraContext - :members: phase, generate - :undoc-members: - -.. autoclass:: sphinxnotes.render.GlobalExtraContext - :members: phase, generate +.. autoclass:: sphinxnotes.render.ExtraContextRequest + :members: :undoc-members: Filters diff --git a/docs/conf.py b/docs/conf.py index e17d68d..dc2896e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -110,6 +110,10 @@ } extensions.append('sphinxnotes.project') + +import sphinxnotes.project.sphinxnotes_render_ext as project_render_ext +for name in ('autoconfval', 'autoobj'): + project_render_ext.DATA_DEFINE_DIRECTIVES[name]['template'].pop('extra', None) primary_domain = 'any' # -- Eat your own dog food -------------------------------------------------- diff --git a/docs/conf.rst b/docs/conf.rst index 14bf32e..4bc9a7a 100644 --- a/docs/conf.rst +++ b/docs/conf.rst @@ -25,7 +25,5 @@ The extension provides the following configuration: - ``text`` (str): the Jinja2 template text. - ``on`` (str, optional): same as :rst:dir:`data.template:on` - ``debug`` (bool, optional): same as :rst:dir:`data.template:debug` - - ``extra`` (list[str], optional): same as :rst:dir:`data.template:extra` See :ref:`usage-custom-directive` for example. - diff --git a/docs/ext.rst b/docs/ext.rst index 6368bc5..9d6025f 100644 --- a/docs/ext.rst +++ b/docs/ext.rst @@ -69,11 +69,8 @@ Extending Extra Contexts Extra contexts are registered by a :py:deco:`sphinxnotes.render.extra_context` class decorator. -The decorated class must be one of the following classes: -:py:class:`~sphinxnotes.render.ParsingPhaseExtraContext`, -:py:class:`~sphinxnotes.render.ParsedPhaseExtraContext`, -:py:class:`~sphinxnotes.render.ResolvingPhaseExtraContext`, -:py:class:`~sphinxnotes.render.GlobalExtraContext`. +The decorated class must be a subclass of +:py:class:`~sphinxnotes.render.ExtraContext`. .. literalinclude:: ../tests/roots/test-extra-context/conf.py :language: python @@ -88,7 +85,6 @@ The decorated class must be one of the following classes: :style: grid .. data.render:: - :extra: cat {{ load_extra('cat').name }} diff --git a/docs/tmpl.rst b/docs/tmpl.rst index 104f637..51a862e 100644 --- a/docs/tmpl.rst +++ b/docs/tmpl.rst @@ -158,20 +158,13 @@ sources (such as Sphinx application, JSON file, and etc.). Unlike main context which comes from the directive/role itself, extra context lets you fetch data that was prepared beforehand. -Extra contexts are typically generated on demand at different construction stages, -so you need to declare them in advance, and load it in the template using the -``load_extra()`` function: - -The way of declaring extra context is vary depending on the extension you use. -For ``sphinxnotes.render.ext`` extension, :rst:dir:`data.template:extra`, -:rst:dir:`data.render:extra` and the ``templat.extra`` field of -:confval:`render_ext_data_define_directives` are for this. +Extra contexts are generated on demand. Load them in the template using the +``load_extra()`` function. .. example:: :style: grid .. data.render:: - :extra: doc {% set doc = load_extra('doc') %} @@ -191,7 +184,6 @@ The following extra contexts are available: :style: grid .. data.render:: - :extra: app {% set app = load_extra('app') %} @@ -207,7 +199,6 @@ The following extra contexts are available: :style: grid .. data.render:: - :extra: env {% set env = load_extra('env') %} @@ -224,7 +215,6 @@ The following extra contexts are available: :style: grid .. data.render:: - :extra: markup {% set m = load_extra('markup') @@ -247,7 +237,6 @@ The following extra contexts are available: :style: grid .. data.render:: - :extra: section Section Title: "{{ load_extra('section').title }}" @@ -261,7 +250,6 @@ The following extra contexts are available: :style: grid .. data.render:: - :extra: doc Document title: "{{ load_extra('doc').title }}". @@ -331,7 +319,6 @@ Each template has a render phase that determines when it is processed: .. data.render:: :on: parsing - :extra: doc env {% set doc = load_extra('doc') %} {% set env = load_extra('env') %} @@ -354,7 +341,6 @@ Each template has a render phase that determines when it is processed: .. data.render:: :on: parsed - :extra: doc env {% set doc = load_extra('doc') %} {% set env = load_extra('env') %} @@ -378,7 +364,6 @@ Each template has a render phase that determines when it is processed: .. data.render:: :on: resolving - :extra: doc env {% set doc = load_extra('doc') %} {% set env = load_extra('env') %} diff --git a/docs/usage.rst b/docs/usage.rst index 4490e1b..fd3162b 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -57,11 +57,6 @@ Directives Enable :ref:`debug report ` for template rendering. - .. rst:directive:option:: extra - :type: space separted list - - List of :ref:`extra-context` to be used in the template. - The content of the directive should be Jinja2 Template, please refer to ::doc:`tmpl`. @@ -116,7 +111,6 @@ Directives .. rst:directive:option:: on .. rst:directive:option:: debug - .. rst:directive:option:: extra The options of this directive are same to :rst:dir:`data.template`. diff --git a/src/sphinxnotes/render/__init__.py b/src/sphinxnotes/render/__init__.py index e66cc0e..9ddb9c1 100644 --- a/src/sphinxnotes/render/__init__.py +++ b/src/sphinxnotes/render/__init__.py @@ -26,10 +26,8 @@ from .ctxnodes import pending_node from .extractx import ( extra_context, - ParsingPhaseExtraContext, - ParsedPhaseExtraContext, - ResolvingPhaseExtraContext, - GlobalExtraContext, + ExtraContext, + ExtraContextRequest, ) from .pipeline import BaseContextRole, BaseContextDirective from .sources import ( @@ -58,10 +56,8 @@ 'Template', 'UnresolvedContext', 'ResolvedContext', - 'ParsingPhaseExtraContext', - 'ParsedPhaseExtraContext', - 'ResolvingPhaseExtraContext', - 'GlobalExtraContext', + 'ExtraContext', + 'ExtraContextRequest', 'extra_context', 'pending_node', 'BaseContextRole', diff --git a/src/sphinxnotes/render/ctxnodes.py b/src/sphinxnotes/render/ctxnodes.py index a7ade16..676b2a0 100644 --- a/src/sphinxnotes/render/ctxnodes.py +++ b/src/sphinxnotes/render/ctxnodes.py @@ -12,6 +12,7 @@ UnresolvedContext, ResolvedContext, ) +from .extractx import ExtraContextGenerator from .markup import MarkupRenderer from .jinja import TemplateRenderer from .utils import ( @@ -21,7 +22,7 @@ ) if TYPE_CHECKING: - from typing import Any, Callable + from typing import Callable from .markup import Host from .ctx import ResolvedContext @@ -31,8 +32,6 @@ class pending_node(nodes.Element): # The context to be rendered by Jinja template. ctx: UnresolvedContext | ResolvedContext - # The extra context as supplement to ctx. - extra: dict[str, Any] #: Jinja template for rendering the context. template: Template #: Whether rendering to inline nodes. @@ -59,7 +58,6 @@ def __init__( except Exception as exc: self._ctx_pickle_error = exc self.ctx = ctx - self.extra = {} self.template = tmpl self.inline = inline self.rendered = False @@ -131,14 +129,19 @@ def err_report() -> Report: report.text(f'Resolved context (type: {type(ctx)}):') report.code(pformat(ctx), lang='python') - report.text('Extra context (only keys):') - report.code(pformat(list(self.extra.keys())), lang='python') report.text(f'Template (phase: {self.template.phase}):') report.code(self.template.text, lang='jinja') # 2. Render the template and context to markup text. try: - markup = TemplateRenderer(self.template.text).render(ctx, extra=self.extra) + extras = ExtraContextGenerator(self, host) + report.text('Available extra context (just keys):') + report.code(pformat(sorted(extras.names())), lang='python') + markup = TemplateRenderer(self.template.text).render( + ctx, + load_extra=extras.load, + extra_names=sorted(extras.names()), + ) except Exception as e: report = err_report() report.text('Failed to render Jinja template:') diff --git a/src/sphinxnotes/render/ext/adhoc.py b/src/sphinxnotes/render/ext/adhoc.py index 00e3f2a..2a8c3e7 100644 --- a/src/sphinxnotes/render/ext/adhoc.py +++ b/src/sphinxnotes/render/ext/adhoc.py @@ -50,19 +50,15 @@ class TemplateDefineDirective(SphinxDirective): option_spec = { 'on': phase_option_spec, 'debug': directives.flag, - 'extra': directives.unchanged, } has_content = True @override def run(self) -> list[nodes.Node]: - extra = self.options.get('extra', '') - self.env.temp_data[TEMPLATE_KEY] = Template( '\n'.join(self.content), phase=self.options.get('on', Phase.default()), debug='debug' in self.options, - extra=extra.split() if extra else [], ) return [] @@ -135,7 +131,6 @@ class DataRenderDirective(BaseContextDirective): option_spec = { 'on': phase_option_spec, 'debug': directives.flag, - 'extra': directives.unchanged, } has_content = True @@ -145,14 +140,10 @@ def current_context(self) -> UnresolvedContext | ResolvedContext: @override def current_template(self) -> Template: - extra_str = self.options.get('extra', '') - extra_list = extra_str.split() if extra_str else [] - return Template( '\n'.join(self.content), phase=self.options.get('on', Phase.default()), debug='debug' in self.options, - extra=extra_list, ) diff --git a/src/sphinxnotes/render/ext/derive.py b/src/sphinxnotes/render/ext/derive.py index 681e881..032f2b9 100644 --- a/src/sphinxnotes/render/ext/derive.py +++ b/src/sphinxnotes/render/ext/derive.py @@ -31,7 +31,6 @@ Optional('on', default='parsing'): Or('parsing', 'parsed', 'resolving'), 'text': str, Optional('debug', default=False): bool, - Optional('extra', default=[]): list, }, } ) @@ -50,7 +49,6 @@ def _validate_directive_define(d: dict, config: Config) -> tuple[Schema, Templat text=tmpldef['text'], phase=Phase[tmpldef['on'].title()], debug=tmpldef['debug'], - extra=tmpldef['extra'], ) return schema, template diff --git a/src/sphinxnotes/render/ext/extractx.py b/src/sphinxnotes/render/ext/extractx.py index 9529982..c4961f4 100644 --- a/src/sphinxnotes/render/ext/extractx.py +++ b/src/sphinxnotes/render/ext/extractx.py @@ -13,8 +13,11 @@ from typing import TYPE_CHECKING, override, Any from sphinx.util.docutils import SphinxDirective, SphinxRole +from sphinx.transforms import SphinxTransform -from .. import meta, extra_context, GlobalExtraContext, ParsingPhaseExtraContext +from .. import meta, extra_context, ExtraContext +from ..extractx import ExtraContextRequest +from ..template import HostWrapper # FIXME: from ..utils import find_current_section @@ -26,54 +29,59 @@ @extra_context('markup') -class MarkupExtraContext(ParsingPhaseExtraContext): +class MarkupExtraContext(ExtraContext): @override - def generate(self, directive: SphinxDirective | SphinxRole) -> Any: - isdir = isinstance(directive, SphinxDirective) + def generate(self, request: ExtraContextRequest) -> Any: + host = request.host + if not isinstance(host, (SphinxDirective, SphinxRole)): + raise ValueError( + f'Extra context "markup" is not available at phase {request.phase}.' + ) + isdir = isinstance(host, SphinxDirective) return { 'type': 'directive' if isdir else 'role', - 'name': directive.name, - 'lineno': directive.lineno, - 'rawtext': directive.block_text if isdir else directive.rawtext, + 'name': host.name, + 'lineno': host.lineno, + 'rawtext': host.block_text if isdir else host.rawtext, } @extra_context('doc') -class DocExtraContext(ParsingPhaseExtraContext): +class DocExtraContext(ExtraContext): @override - def generate(self, directive: SphinxDirective | SphinxRole) -> Any: - doctree = ( - directive.state.document - if isinstance(directive, SphinxDirective) - else directive.inliner.document - ) - return proxy(doctree) + def generate(self, request: ExtraContextRequest) -> Any: + return proxy(HostWrapper(request.host).doctree) @extra_context('section') -class SectionExtraContext(ParsingPhaseExtraContext): +class SectionExtraContext(ExtraContext): @override - def generate(self, directive: SphinxDirective | SphinxRole) -> Any: - parent = ( - directive.state.parent - if isinstance(directive, SphinxDirective) - else directive.inliner.parent - ) + def generate(self, request: ExtraContextRequest) -> Any: + if request.node.parent is not None: + parent = request.node.parent + elif isinstance(request.host, SphinxDirective): + parent = request.host.state.parent + elif isinstance(request.host, SphinxRole): + parent = request.host.inliner.parent + elif isinstance(request.host, SphinxTransform): + parent = request.host.document + else: + parent = None return proxy(find_current_section(parent)) @extra_context('app') -class SphinxAppExtraContext(GlobalExtraContext): +class SphinxAppExtraContext(ExtraContext): @override - def generate(self, env: BuildEnvironment) -> Any: - return proxy(env.app) + def generate(self, request: ExtraContextRequest) -> Any: + return proxy(request.env.app) @extra_context('env') -class SphinxBuildEnvExtraContext(GlobalExtraContext): +class SphinxBuildEnvExtraContext(ExtraContext): @override - def generate(self, env: BuildEnvironment) -> Any: - return proxy(env) + def generate(self, request: ExtraContextRequest) -> Any: + return proxy(request.env) def setup(app: Sphinx): diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index 0cf278f..114c9a5 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -1,74 +1,31 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING from abc import ABC, abstractmethod +from dataclasses import dataclass from sphinx.util.docutils import SphinxDirective, SphinxRole from sphinx.transforms import SphinxTransform from .template import Phase -from .ctxnodes import pending_node -from .utils import Report, Reporter - if TYPE_CHECKING: - from typing import Any, Callable + from typing import Any from sphinx.environment import BuildEnvironment + from .ctxnodes import pending_node -# ============================ -# ExtraContext ABC definitions -# ============================ - - -class _ExtraContext(ABC): - """Base class of extra context.""" - - phase: ClassVar[Phase | None] = None - - @abstractmethod - def generate(self, *args, **kwargs) -> Any: ... - - -class ParsingPhaseExtraContext(_ExtraContext): - """Extra context generated during the :py:data:`~Phase.Parsing` phase. - The ``generate`` method receives the current directive or role being executed. - """ - - phase = Phase.Parsing - - @abstractmethod - def generate(self, directive: SphinxDirective | SphinxRole) -> Any: ... - - -class ParsedPhaseExtraContext(_ExtraContext): - """Extra context generated during the :py:data:`~Phase.Parsed` phase. - The ``generate`` method receives the current Sphinx transform. - """ - - phase = Phase.Parsed - - @abstractmethod - def generate(self, transform: SphinxTransform) -> Any: ... - - -class ResolvingPhaseExtraContext(_ExtraContext): - """Extra context generated during the :py:data:`~Phase.Resolving` phase. - The ``generate`` method receives the current Sphinx transform. - """ - - phase = Phase.Resolving - - @abstractmethod - def generate(self, transform: SphinxTransform) -> Any: ... +@dataclass(frozen=True) +class ExtraContextRequest: + phase: Phase + node: pending_node + env: BuildEnvironment + host: SphinxDirective | SphinxRole | SphinxTransform -class GlobalExtraContext(_ExtraContext): - """Extra context available in all phases. - The ``generate`` method receives the Sphinx build environment. - """ - phase = None +class ExtraContext(ABC): + """Base class of extra context.""" @abstractmethod - def generate(self, env: BuildEnvironment) -> Any: ... + def generate(self, request: ExtraContextRequest) -> Any: ... # ========================== @@ -77,17 +34,17 @@ def generate(self, env: BuildEnvironment) -> Any: ... class _ExtraContextRegistry: - ctxs: dict[str, _ExtraContext] + ctxs: dict[str, ExtraContext] def __init__(self) -> None: self.ctxs = {} - def register(self, name: str, ctx: _ExtraContext) -> None: + def register(self, name: str, ctx: ExtraContext) -> None: if name in self.ctxs: raise ValueError(f'Extra context "{name}" already registered') self.ctxs[name] = ctx - def get(self, name: str) -> _ExtraContext | None: + def get(self, name: str) -> ExtraContext | None: if name not in self.ctxs: return None return self.ctxs[name] @@ -95,41 +52,18 @@ def get(self, name: str) -> _ExtraContext | None: def get_names(self) -> set[str]: return set(self.ctxs.keys()) - def get_names_at_phase(self, phase: Phase | None) -> set[str]: - return {name for name, ctx in self.ctxs.items() if ctx.phase == phase} - - def get_names_before_phase(self, phase: Phase | None) -> set[str]: - return { - name - for name, ctx in self.ctxs.items() - if phase is None or ctx.phase is None or phase >= ctx.phase - } - - -# Global registry instance. _REGISTRY = _ExtraContextRegistry() def extra_context(name: str): """Decorator to register an extra context. - The phase is determined by which ExtraContext class is used: - - :py:class:`GlobalExtraContext` - available in all phases - :py:class:`ParsingPhaseExtraContext` - available during Parsing phase - :py:class:`ParsedPhaseExtraContext` - available during Parsed phase - :py:class:`ResolvingPhaseExtraContext` - available during Resolving phase - Example:: @extra_context('doc') - class DocExtraContext(ParsingPhaseExtraContext): - def generate(self, ctx): - return proxy(HostWrapper(ctx).doctree) + class DocExtraContext(ExtraContext): + def generate(self, request): + return proxy(request.host.document) :param name: The context name, used in templates via ``load_extra('name')``. """ @@ -148,61 +82,32 @@ def decorator(cls): class ExtraContextGenerator: node: pending_node - todo: set[str] - report: Report + request: ExtraContextRequest + cache: dict[str, Any] + + def __init__( + self, + node: pending_node, + host: SphinxDirective | SphinxRole | SphinxTransform, + ) -> None: + self.node = node + self.request = ExtraContextRequest(node.template.phase, node, host.env, host) + self.cache = {} - env: ClassVar[BuildEnvironment] + def names(self) -> set[str]: + return _REGISTRY.get_names() - def __init__(self, node: pending_node) -> None: - self.node = node - self.report = Report( - 'Extra Context Generation Report', - 'ERROR', - source=node.source, - line=node.line, - ) - Reporter(node).append(self.report) - - # Initialize todo with requested extra contexts, validate they exist - total = _REGISTRY.get_names() - avail = _REGISTRY.get_names_before_phase(node.template.phase) - requested = set(node.template.extra) - self.todo = requested & avail - - # Report errors for non-existent contexts - if nonexist := requested - total: - self.report.text(f'Extra contexts {nonexist} are non-exist.') - if nonavail := requested & total - avail: - self.report.text( - f'Extra contexts {nonavail} are not available ' - f'at phase {node.template.phase}.' + def load(self, name: str) -> Any: + if name in self.cache: + return self.cache[name] + + ctx = _REGISTRY.get(name) + if ctx is None: + raise ValueError( + f'Extra context "{name}" is not registered. ' + f'Available: {sorted(self.names())}' ) - def on_anytime(self, env: BuildEnvironment) -> None: - self._generate(GlobalExtraContext, lambda ctx: ctx.generate(env)) - - def on_parsing(self, directive: SphinxDirective | SphinxRole) -> None: - self._generate(ParsingPhaseExtraContext, lambda ctx: ctx.generate(directive)) - - def on_parsed(self, transform: SphinxTransform) -> None: - self._generate(ParsedPhaseExtraContext, lambda ctx: ctx.generate(transform)) - - def on_resolving(self, transform: SphinxTransform) -> None: - self._generate(ResolvingPhaseExtraContext, lambda ctx: ctx.generate(transform)) - - def _generate(self, cls: type[_ExtraContext], gen: Callable[..., Any]) -> None: - # Get all context names available for this phase - avail = _REGISTRY.get_names_at_phase(cls.phase) - # Find which ones are requested and not yet generated - todo = avail & self.todo - - for name in todo: - ctx = _REGISTRY.get(name) - if ctx is None: - continue - try: - self.node.extra[name] = gen(ctx) - self.todo.discard(name) - except Exception: - self.report.text(f'Failed to generate extra context "{name}":') - self.report.traceback() + value = ctx.generate(self.request) + self.cache[name] = value + return value diff --git a/src/sphinxnotes/render/jinja.py b/src/sphinxnotes/render/jinja.py index 66e02d7..02ce9ef 100644 --- a/src/sphinxnotes/render/jinja.py +++ b/src/sphinxnotes/render/jinja.py @@ -32,18 +32,17 @@ class TemplateRenderer: def render( self, data: ParsedData | dict[str, Any], - extra: dict[str, Any] | None = None, + load_extra: Callable[[str], Any] | None = None, + extra_names: list[str] | None = None, debug: Report | None = None, ) -> str: - if extra is None: - extra = {} if debug: debug.text('Starting Jinja template rendering...') debug.text('Data:') debug.code(pformat(data), lang='python') debug.text('Available extra context (just keys):') - debug.code(pformat(list(extra.keys())), lang='python') + debug.code(pformat(extra_names or []), lang='python') # Convert data to context dict. if isinstance(data, ParsedData): @@ -53,15 +52,14 @@ def render( # Inject load_extra() function for accessing extra context. # TODO: move to extractx.py - def load_extra(name: str): - if name not in extra: + def _load_extra(name: str): + if load_extra is None: raise ValueError( - f'Extra context "{name}" is not available. ' - f'Available: {list(extra.keys())}' + 'Extra context loading is not available in this template render.' ) - return extra[name] + return load_extra(name) - ctx['load_extra'] = load_extra + ctx['load_extra'] = _load_extra text = self._render(ctx, debug=debug is not None) diff --git a/src/sphinxnotes/render/pipeline.py b/src/sphinxnotes/render/pipeline.py index 0bc886b..4f4eeff 100644 --- a/src/sphinxnotes/render/pipeline.py +++ b/src/sphinxnotes/render/pipeline.py @@ -13,9 +13,6 @@ from .template import HostWrapper, Phase, Template, Host from .ctx import UnresolvedContext, ResolvedContext from .ctxnodes import pending_node -from .extractx import ExtraContextGenerator - - if TYPE_CHECKING: from sphinx.application import Sphinx @@ -123,9 +120,6 @@ def render_queue(self) -> list[pending_node]: host = cast(Host, self) - # Generate global extra context for later use. - ExtraContextGenerator(pending).on_anytime(host.env) - # Perform render. pending.render(host) @@ -187,9 +181,6 @@ def process_pending_node(self, n: pending_node) -> bool: host = cast(SphinxDirective | SphinxRole, self) # Set source and line. host.set_source_info(n) - # Generate and save parsing phase extra context for later use. - ExtraContextGenerator(n).on_parsing(host) - return n.template.phase == Phase.Parsing @@ -253,7 +244,6 @@ class _ParsedHookTransform(SphinxTransform, Pipeline): @override def process_pending_node(self, n: pending_node) -> bool: - ExtraContextGenerator(n).on_parsed(self) return n.template.phase == Phase.Parsed @override @@ -271,7 +261,6 @@ class _ResolvingHookTransform(SphinxPostTransform, Pipeline): @override def process_pending_node(self, n: pending_node) -> bool: - ExtraContextGenerator(n).on_resolving(self) return n.template.phase == Phase.Resolving @override diff --git a/src/sphinxnotes/render/template.py b/src/sphinxnotes/render/template.py index 013be6b..3b70274 100644 --- a/src/sphinxnotes/render/template.py +++ b/src/sphinxnotes/render/template.py @@ -1,5 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum from docutils import nodes @@ -38,8 +38,6 @@ class Template: phase: Phase = Phase.default() #: Enable debug output (shown as :py:class:`docutils.nodes.system_message` in document.) debug: bool = False - #: Names of extra context to be generated and available in the template. - extra: list[str] = field(default_factory=list) #: Possible render host of :meth:`pending_node.render`. diff --git a/tests/roots/test-extra-context-rebuild/conf.py b/tests/roots/test-extra-context-rebuild/conf.py new file mode 100644 index 0000000..294e8a2 --- /dev/null +++ b/tests/roots/test-extra-context-rebuild/conf.py @@ -0,0 +1 @@ +extensions = ['sphinxnotes.render.ext'] diff --git a/tests/roots/test-extra-context-rebuild/index.rst b/tests/roots/test-extra-context-rebuild/index.rst new file mode 100644 index 0000000..286b680 --- /dev/null +++ b/tests/roots/test-extra-context-rebuild/index.rst @@ -0,0 +1,11 @@ +Extra Context Rebuild Test +========================== + +.. data.render:: + :on: resolving + + {% set doc = load_extra('doc') %} + {% set env = load_extra('env') %} + + doc-sections={{ doc.sections | length }} + all-docs={{ env.all_docs | length }} diff --git a/tests/roots/test-extra-context/conf.py b/tests/roots/test-extra-context/conf.py index 10bef32..5e40a9b 100644 --- a/tests/roots/test-extra-context/conf.py +++ b/tests/roots/test-extra-context/conf.py @@ -2,16 +2,16 @@ from os import path import json -from sphinx.environment import BuildEnvironment from sphinxnotes.render import ( extra_context, - GlobalExtraContext, + ExtraContext, + ExtraContextRequest, ) @extra_context('cat') -class CatExtraContext(GlobalExtraContext): - def generate(self, env: BuildEnvironment): +class CatExtraContext(ExtraContext): + def generate(self, request: ExtraContextRequest): with open(path.join(path.dirname(__file__), 'cat.json')) as f: return json.loads(f.read()) diff --git a/tests/roots/test-extra-context/index.rst b/tests/roots/test-extra-context/index.rst index dcd20d2..70a84b9 100644 --- a/tests/roots/test-extra-context/index.rst +++ b/tests/roots/test-extra-context/index.rst @@ -2,6 +2,5 @@ Extra Context Test ================== .. data.render:: - :extra: cat {{ load_extra('cat') }} diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 3bb8757..97956c0 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -59,6 +59,17 @@ def test_extra_context_custom_loader(app, status, warning): assert 'mimi' in html +@pytest.mark.sphinx('html', testroot='extra-context-rebuild') +def test_extra_context_rebuild(app, status, warning): + app.build() + app.build() + + html = (app.outdir / 'index.html').read_text(encoding='utf-8') + + assert 'doc-sections=0' in html + assert 'all-docs=1' in html + + # =========================== # Test sphinxnotes.render.ext # =========================== From 07470132c153a8d662b55ff44554d350464c2ace Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Wed, 6 May 2026 13:21:13 +0800 Subject: [PATCH 02/10] refactor: Inject template globals explicitly --- docs/conf.py | 1 - src/sphinxnotes/render/ctxnodes.py | 7 +++---- src/sphinxnotes/render/extractx.py | 2 +- src/sphinxnotes/render/jinja.py | 17 +++-------------- tests/test_jinja.py | 12 ++++++++++++ 5 files changed, 19 insertions(+), 20 deletions(-) create mode 100644 tests/test_jinja.py diff --git a/docs/conf.py b/docs/conf.py index dc2896e..94fff25 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -110,7 +110,6 @@ } extensions.append('sphinxnotes.project') - import sphinxnotes.project.sphinxnotes_render_ext as project_render_ext for name in ('autoconfval', 'autoobj'): project_render_ext.DATA_DEFINE_DIRECTIVES[name]['template'].pop('extra', None) diff --git a/src/sphinxnotes/render/ctxnodes.py b/src/sphinxnotes/render/ctxnodes.py index 676b2a0..6822aa2 100644 --- a/src/sphinxnotes/render/ctxnodes.py +++ b/src/sphinxnotes/render/ctxnodes.py @@ -12,7 +12,7 @@ UnresolvedContext, ResolvedContext, ) -from .extractx import ExtraContextGenerator +from .extractx import ExtraContextLoader from .markup import MarkupRenderer from .jinja import TemplateRenderer from .utils import ( @@ -134,13 +134,12 @@ def err_report() -> Report: # 2. Render the template and context to markup text. try: - extras = ExtraContextGenerator(self, host) + extras = ExtraContextLoader(self, host) report.text('Available extra context (just keys):') report.code(pformat(sorted(extras.names())), lang='python') markup = TemplateRenderer(self.template.text).render( ctx, - load_extra=extras.load, - extra_names=sorted(extras.names()), + globals={'load_extra': extras.load}, ) except Exception as e: report = err_report() diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index 114c9a5..bec89b4 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -80,7 +80,7 @@ def decorator(cls): # ======================== -class ExtraContextGenerator: +class ExtraContextLoader: node: pending_node request: ExtraContextRequest cache: dict[str, Any] diff --git a/src/sphinxnotes/render/jinja.py b/src/sphinxnotes/render/jinja.py index 02ce9ef..c261a87 100644 --- a/src/sphinxnotes/render/jinja.py +++ b/src/sphinxnotes/render/jinja.py @@ -32,8 +32,7 @@ class TemplateRenderer: def render( self, data: ParsedData | dict[str, Any], - load_extra: Callable[[str], Any] | None = None, - extra_names: list[str] | None = None, + globals: dict[str, Any] | None = None, debug: Report | None = None, ) -> str: if debug: @@ -41,8 +40,6 @@ def render( debug.text('Data:') debug.code(pformat(data), lang='python') - debug.text('Available extra context (just keys):') - debug.code(pformat(extra_names or []), lang='python') # Convert data to context dict. if isinstance(data, ParsedData): @@ -50,16 +47,8 @@ def render( elif isinstance(data, dict): ctx = data.copy() - # Inject load_extra() function for accessing extra context. - # TODO: move to extractx.py - def _load_extra(name: str): - if load_extra is None: - raise ValueError( - 'Extra context loading is not available in this template render.' - ) - return load_extra(name) - - ctx['load_extra'] = _load_extra + if globals: + ctx.update(globals) text = self._render(ctx, debug=debug is not None) diff --git a/tests/test_jinja.py b/tests/test_jinja.py new file mode 100644 index 0000000..bffed1e --- /dev/null +++ b/tests/test_jinja.py @@ -0,0 +1,12 @@ +from sphinxnotes.render.jinja import TemplateRenderer + + +def test_template_renderer_injects_template_globals(): + text = "{{ load_extra('cat') }}" + + rendered = TemplateRenderer(text).render( + {}, + globals={'load_extra': lambda name: f'loaded:{name}'}, + ) + + assert rendered == 'loaded:cat' From 95a736279afe18d72097471b976585c91aaf7adc Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Wed, 6 May 2026 13:31:49 +0800 Subject: [PATCH 03/10] chore --- src/sphinxnotes/render/ctxnodes.py | 18 ++++++++++-------- src/sphinxnotes/render/extractx.py | 9 +-------- src/sphinxnotes/render/jinja.py | 22 ++++++++-------------- 3 files changed, 19 insertions(+), 30 deletions(-) diff --git a/src/sphinxnotes/render/ctxnodes.py b/src/sphinxnotes/render/ctxnodes.py index 6822aa2..15918be 100644 --- a/src/sphinxnotes/render/ctxnodes.py +++ b/src/sphinxnotes/render/ctxnodes.py @@ -64,7 +64,7 @@ def __init__( # Init hook lists. self._unresolved_context_hooks = [] - self._resolved_data_hooks = [] + self._resolved_context_hooks = [] self._markup_text_hooks = [] self._rendered_nodes_hooks = [] @@ -124,7 +124,7 @@ def err_report() -> Report: else: ctx = self.ctx - for hook in self._resolved_data_hooks: + for hook in self._resolved_context_hooks: hook(self, ctx) report.text(f'Resolved context (type: {type(ctx)}):') @@ -132,14 +132,16 @@ def err_report() -> Report: report.text(f'Template (phase: {self.template.phase}):') report.code(self.template.text, lang='jinja') + extras = ExtraContextLoader(self, host) + report.text('Available extra context names:') + report.code(pformat(sorted(extras.names())), lang='python') + # 2. Render the template and context to markup text. try: - extras = ExtraContextLoader(self, host) - report.text('Available extra context (just keys):') - report.code(pformat(sorted(extras.names())), lang='python') markup = TemplateRenderer(self.template.text).render( ctx, - globals={'load_extra': extras.load}, + globals_={'load_extra': extras.load}, + debug=self.template.debug, ) except Exception as e: report = err_report() @@ -229,7 +231,7 @@ def unwrap_and_replace_self_inline(self, inliner: Report.Inliner) -> None: type RenderedNodesHook = Callable[[pending_node, list[nodes.Node]], None] _unresolved_context_hooks: list[UnresolvedContextHook] - _resolved_data_hooks: list[ResolvedContextHook] + _resolved_context_hooks: list[ResolvedContextHook] _markup_text_hooks: list[MarkupTextHook] _rendered_nodes_hooks: list[RenderedNodesHook] @@ -237,7 +239,7 @@ def hook_unresolved_context(self, hook: UnresolvedContextHook) -> None: self._unresolved_context_hooks.append(hook) def hook_resolved_context(self, hook: ResolvedContextHook) -> None: - self._resolved_data_hooks.append(hook) + self._resolved_context_hooks.append(hook) def hook_markup_text(self, hook: MarkupTextHook) -> None: self._markup_text_hooks.append(hook) diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index bec89b4..7713ca5 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -56,14 +56,7 @@ def get_names(self) -> set[str]: def extra_context(name: str): - """Decorator to register an extra context. - - Example:: - - @extra_context('doc') - class DocExtraContext(ExtraContext): - def generate(self, request): - return proxy(request.host.document) + """Decorator to register an :py:class:`ExtraContext`. :param name: The context name, used in templates via ``load_extra('name')``. """ diff --git a/src/sphinxnotes/render/jinja.py b/src/sphinxnotes/render/jinja.py index c261a87..db60e32 100644 --- a/src/sphinxnotes/render/jinja.py +++ b/src/sphinxnotes/render/jinja.py @@ -23,6 +23,7 @@ from typing import Any from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment + from .ctx import ResolvedContext @dataclass @@ -31,28 +32,21 @@ class TemplateRenderer: def render( self, - data: ParsedData | dict[str, Any], - globals: dict[str, Any] | None = None, - debug: Report | None = None, + data: ResolvedContext, + globals_: dict[str, Any] | None = None, + debug: bool = False, ) -> str: - if debug: - debug.text('Starting Jinja template rendering...') - - debug.text('Data:') - debug.code(pformat(data), lang='python') - # Convert data to context dict. if isinstance(data, ParsedData): ctx = data.asdict() elif isinstance(data, dict): ctx = data.copy() - if globals: - ctx.update(globals) - - text = self._render(ctx, debug=debug is not None) + # Inject globals. + if globals_: + ctx.update(globals_) - return text + return self._render(ctx, debug=debug) def _render(self, ctx: dict[str, Any], debug: bool = False) -> str: extensions = [ From cde7ba9f211c1b83488183a2487bed4d08c968d0 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Wed, 6 May 2026 13:41:28 +0800 Subject: [PATCH 04/10] refactor: Replace extra context loader with closure --- src/sphinxnotes/render/ctxnodes.py | 8 +++--- src/sphinxnotes/render/extractx.py | 40 +++++++++--------------------- src/sphinxnotes/render/jinja.py | 6 ++--- tests/test_extractx.py | 36 +++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 35 deletions(-) create mode 100644 tests/test_extractx.py diff --git a/src/sphinxnotes/render/ctxnodes.py b/src/sphinxnotes/render/ctxnodes.py index 15918be..0e11182 100644 --- a/src/sphinxnotes/render/ctxnodes.py +++ b/src/sphinxnotes/render/ctxnodes.py @@ -12,7 +12,7 @@ UnresolvedContext, ResolvedContext, ) -from .extractx import ExtraContextLoader +from .extractx import ExtraContextRequest, build_load_extra, extra_context_names from .markup import MarkupRenderer from .jinja import TemplateRenderer from .utils import ( @@ -132,15 +132,15 @@ def err_report() -> Report: report.text(f'Template (phase: {self.template.phase}):') report.code(self.template.text, lang='jinja') - extras = ExtraContextLoader(self, host) + extra_request = ExtraContextRequest(self.template.phase, self, host.env, host) report.text('Available extra context names:') - report.code(pformat(sorted(extras.names())), lang='python') + report.code(pformat(sorted(extra_context_names())), lang='python') # 2. Render the template and context to markup text. try: markup = TemplateRenderer(self.template.text).render( ctx, - globals_={'load_extra': extras.load}, + globals={'load_extra': build_load_extra(extra_request)}, debug=self.template.debug, ) except Exception as e: diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index 7713ca5..0332799 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -15,9 +15,13 @@ @dataclass(frozen=True) class ExtraContextRequest: + #: The render phase of the current template. phase: Phase + #: The pending node being rendered. node: pending_node + #: The current Sphinx build environment. env: BuildEnvironment + #: The current render host. host: SphinxDirective | SphinxRole | SphinxTransform @@ -68,39 +72,19 @@ def decorator(cls): return decorator -# ======================== -# Extra Context Generation -# ======================== +def extra_context_names() -> set[str]: + return _REGISTRY.get_names() -class ExtraContextLoader: - node: pending_node - request: ExtraContextRequest - cache: dict[str, Any] - - def __init__( - self, - node: pending_node, - host: SphinxDirective | SphinxRole | SphinxTransform, - ) -> None: - self.node = node - self.request = ExtraContextRequest(node.template.phase, node, host.env, host) - self.cache = {} - - def names(self) -> set[str]: - return _REGISTRY.get_names() - - def load(self, name: str) -> Any: - if name in self.cache: - return self.cache[name] - +def build_load_extra(request: ExtraContextRequest): + def load_extra(name: str) -> Any: ctx = _REGISTRY.get(name) if ctx is None: raise ValueError( f'Extra context "{name}" is not registered. ' - f'Available: {sorted(self.names())}' + f'Available: {sorted(extra_context_names())}' ) - value = ctx.generate(self.request) - self.cache[name] = value - return value + return ctx.generate(request) + + return load_extra diff --git a/src/sphinxnotes/render/jinja.py b/src/sphinxnotes/render/jinja.py index db60e32..720586c 100644 --- a/src/sphinxnotes/render/jinja.py +++ b/src/sphinxnotes/render/jinja.py @@ -33,7 +33,7 @@ class TemplateRenderer: def render( self, data: ResolvedContext, - globals_: dict[str, Any] | None = None, + globals: dict[str, Any] | None = None, debug: bool = False, ) -> str: # Convert data to context dict. @@ -43,8 +43,8 @@ def render( ctx = data.copy() # Inject globals. - if globals_: - ctx.update(globals_) + if globals: + ctx.update(globals) return self._render(ctx, debug=debug) diff --git a/tests/test_extractx.py b/tests/test_extractx.py new file mode 100644 index 0000000..9a38867 --- /dev/null +++ b/tests/test_extractx.py @@ -0,0 +1,36 @@ +from types import SimpleNamespace + +from sphinxnotes.render.extractx import ( + ExtraContext, + ExtraContextRequest, + _REGISTRY, + build_load_extra, +) +from sphinxnotes.render.template import Template +from sphinxnotes.render.ctxnodes import pending_node + + +class CountingExtraContext(ExtraContext): + def __init__(self) -> None: + self.calls = 0 + + def generate(self, request): + self.calls += 1 + return self.calls + + +def test_extra_context_loader_does_not_cache_values(): + name = 'test_no_cache' + ctx = CountingExtraContext() + _REGISTRY.register(name, ctx) + + try: + node = pending_node({}, Template('')) + host = SimpleNamespace(env=SimpleNamespace()) + request = ExtraContextRequest(Template('').phase, node, host.env, host) + load_extra = build_load_extra(request) + + assert load_extra(name) == 1 + assert load_extra(name) == 2 + finally: + _REGISTRY.ctxs.pop(name, None) From a33d5a217bd6e563ae6579e86296b3cf3dadc462 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Wed, 6 May 2026 14:00:47 +0800 Subject: [PATCH 05/10] docs: Clarify ExtraContextRequest.host --- src/sphinxnotes/render/extractx.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index 0332799..5d8a866 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -21,7 +21,10 @@ class ExtraContextRequest: node: pending_node #: The current Sphinx build environment. env: BuildEnvironment - #: The current render host. + #: The Sphinx execution object associated with this render: + #: a :py:class:`~sphinx.util.docutils.SphinxDirective` or + #: :py:class:`~sphinx.util.docutils.SphinxRole` during :py:data:`Phase.Parsing`, + #: or a :py:class:`~sphinx.transforms.SphinxTransform` during later phases. host: SphinxDirective | SphinxRole | SphinxTransform From a7d2a5b5a655e929e77213a8bc9dff13a4c037f1 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Wed, 6 May 2026 14:12:57 +0800 Subject: [PATCH 06/10] update --- docs/conf.py | 3 --- src/sphinxnotes/render/ctxnodes.py | 6 +++--- src/sphinxnotes/render/ext/extractx.py | 1 - src/sphinxnotes/render/extractx.py | 2 +- src/sphinxnotes/render/template.py | 11 +++++------ 5 files changed, 9 insertions(+), 14 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 94fff25..e17d68d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -110,9 +110,6 @@ } extensions.append('sphinxnotes.project') -import sphinxnotes.project.sphinxnotes_render_ext as project_render_ext -for name in ('autoconfval', 'autoobj'): - project_render_ext.DATA_DEFINE_DIRECTIVES[name]['template'].pop('extra', None) primary_domain = 'any' # -- Eat your own dog food -------------------------------------------------- diff --git a/src/sphinxnotes/render/ctxnodes.py b/src/sphinxnotes/render/ctxnodes.py index 0e11182..fe13c79 100644 --- a/src/sphinxnotes/render/ctxnodes.py +++ b/src/sphinxnotes/render/ctxnodes.py @@ -12,7 +12,7 @@ UnresolvedContext, ResolvedContext, ) -from .extractx import ExtraContextRequest, build_load_extra, extra_context_names +from .extractx import ExtraContextRequest, extra_context_loader, extra_context_names from .markup import MarkupRenderer from .jinja import TemplateRenderer from .utils import ( @@ -132,7 +132,7 @@ def err_report() -> Report: report.text(f'Template (phase: {self.template.phase}):') report.code(self.template.text, lang='jinja') - extra_request = ExtraContextRequest(self.template.phase, self, host.env, host) + extractx_req = ExtraContextRequest(self.template.phase, self, host.env, host) report.text('Available extra context names:') report.code(pformat(sorted(extra_context_names())), lang='python') @@ -140,7 +140,7 @@ def err_report() -> Report: try: markup = TemplateRenderer(self.template.text).render( ctx, - globals={'load_extra': build_load_extra(extra_request)}, + globals={'load_extra': extra_context_loader(extractx_req)}, debug=self.template.debug, ) except Exception as e: diff --git a/src/sphinxnotes/render/ext/extractx.py b/src/sphinxnotes/render/ext/extractx.py index c4961f4..128a10e 100644 --- a/src/sphinxnotes/render/ext/extractx.py +++ b/src/sphinxnotes/render/ext/extractx.py @@ -25,7 +25,6 @@ if TYPE_CHECKING: from sphinx.application import Sphinx - from sphinx.environment import BuildEnvironment @extra_context('markup') diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index 5d8a866..e89c1bd 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -79,7 +79,7 @@ def extra_context_names() -> set[str]: return _REGISTRY.get_names() -def build_load_extra(request: ExtraContextRequest): +def extra_context_loader(request: ExtraContextRequest): def load_extra(name: str) -> Any: ctx = _REGISTRY.get(name) if ctx is None: diff --git a/src/sphinxnotes/render/template.py b/src/sphinxnotes/render/template.py index 3b70274..45ec853 100644 --- a/src/sphinxnotes/render/template.py +++ b/src/sphinxnotes/render/template.py @@ -40,12 +40,11 @@ class Template: debug: bool = False -#: Possible render host of :meth:`pending_node.render`. -type Host = ParseHost | ResolveHost -#: Host of source parse phase (Phase.Parsing, Phase.Parsed). -type ParseHost = SphinxDirective | SphinxRole -#: Host of source parse phase (Phase.Parsing, Phase.Parsed). -type ResolveHost = SphinxTransform +#: The Sphinx execution object associated with this render: +#: a :py:class:`~sphinx.util.docutils.SphinxDirective` or +#: :py:class:`~sphinx.util.docutils.SphinxRole` during :py:data:`Phase.Parsing`, +#: or a :py:class:`~sphinx.transforms.SphinxTransform` during later phases. +type Host = SphinxDirective | SphinxRole | SphinxTransform @dataclass From 686cc7913e163ae8e552d5f775d1e6e1ecddc928 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Wed, 6 May 2026 14:26:37 +0800 Subject: [PATCH 07/10] refactor: Shorten extra context request args --- src/sphinxnotes/render/ext/extractx.py | 36 +++++++++++++------------- src/sphinxnotes/render/extractx.py | 2 +- tests/roots/test-extra-context/conf.py | 2 +- tests/test_extractx.py | 8 +++--- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/sphinxnotes/render/ext/extractx.py b/src/sphinxnotes/render/ext/extractx.py index 128a10e..b049633 100644 --- a/src/sphinxnotes/render/ext/extractx.py +++ b/src/sphinxnotes/render/ext/extractx.py @@ -30,11 +30,11 @@ @extra_context('markup') class MarkupExtraContext(ExtraContext): @override - def generate(self, request: ExtraContextRequest) -> Any: - host = request.host + def generate(self, req: ExtraContextRequest) -> Any: + host = req.host if not isinstance(host, (SphinxDirective, SphinxRole)): raise ValueError( - f'Extra context "markup" is not available at phase {request.phase}.' + f'Extra context "markup" is not available at phase {req.phase}.' ) isdir = isinstance(host, SphinxDirective) return { @@ -48,22 +48,22 @@ def generate(self, request: ExtraContextRequest) -> Any: @extra_context('doc') class DocExtraContext(ExtraContext): @override - def generate(self, request: ExtraContextRequest) -> Any: - return proxy(HostWrapper(request.host).doctree) + def generate(self, req: ExtraContextRequest) -> Any: + return proxy(HostWrapper(req.host).doctree) @extra_context('section') class SectionExtraContext(ExtraContext): @override - def generate(self, request: ExtraContextRequest) -> Any: - if request.node.parent is not None: - parent = request.node.parent - elif isinstance(request.host, SphinxDirective): - parent = request.host.state.parent - elif isinstance(request.host, SphinxRole): - parent = request.host.inliner.parent - elif isinstance(request.host, SphinxTransform): - parent = request.host.document + def generate(self, req: ExtraContextRequest) -> Any: + if req.node.parent is not None: + parent = req.node.parent + elif isinstance(req.host, SphinxDirective): + parent = req.host.state.parent + elif isinstance(req.host, SphinxRole): + parent = req.host.inliner.parent + elif isinstance(req.host, SphinxTransform): + parent = req.host.document else: parent = None return proxy(find_current_section(parent)) @@ -72,15 +72,15 @@ def generate(self, request: ExtraContextRequest) -> Any: @extra_context('app') class SphinxAppExtraContext(ExtraContext): @override - def generate(self, request: ExtraContextRequest) -> Any: - return proxy(request.env.app) + def generate(self, req: ExtraContextRequest) -> Any: + return proxy(req.env.app) @extra_context('env') class SphinxBuildEnvExtraContext(ExtraContext): @override - def generate(self, request: ExtraContextRequest) -> Any: - return proxy(request.env) + def generate(self, req: ExtraContextRequest) -> Any: + return proxy(req.env) def setup(app: Sphinx): diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index e89c1bd..becbed6 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -32,7 +32,7 @@ class ExtraContext(ABC): """Base class of extra context.""" @abstractmethod - def generate(self, request: ExtraContextRequest) -> Any: ... + def generate(self, req: ExtraContextRequest) -> Any: ... # ========================== diff --git a/tests/roots/test-extra-context/conf.py b/tests/roots/test-extra-context/conf.py index 5e40a9b..29a4525 100644 --- a/tests/roots/test-extra-context/conf.py +++ b/tests/roots/test-extra-context/conf.py @@ -11,7 +11,7 @@ @extra_context('cat') class CatExtraContext(ExtraContext): - def generate(self, request: ExtraContextRequest): + def generate(self, req: ExtraContextRequest): with open(path.join(path.dirname(__file__), 'cat.json')) as f: return json.loads(f.read()) diff --git a/tests/test_extractx.py b/tests/test_extractx.py index 9a38867..fdfb365 100644 --- a/tests/test_extractx.py +++ b/tests/test_extractx.py @@ -4,7 +4,7 @@ ExtraContext, ExtraContextRequest, _REGISTRY, - build_load_extra, + extra_context_loader, ) from sphinxnotes.render.template import Template from sphinxnotes.render.ctxnodes import pending_node @@ -14,7 +14,7 @@ class CountingExtraContext(ExtraContext): def __init__(self) -> None: self.calls = 0 - def generate(self, request): + def generate(self, req): self.calls += 1 return self.calls @@ -27,8 +27,8 @@ def test_extra_context_loader_does_not_cache_values(): try: node = pending_node({}, Template('')) host = SimpleNamespace(env=SimpleNamespace()) - request = ExtraContextRequest(Template('').phase, node, host.env, host) - load_extra = build_load_extra(request) + req = ExtraContextRequest(Template('').phase, node, host.env, host) + load_extra = extra_context_loader(req) assert load_extra(name) == 1 assert load_extra(name) == 2 From 763d4a642849a232092aea28461efe75e588e17e Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Wed, 6 May 2026 14:32:54 +0800 Subject: [PATCH 08/10] chore: make fmt --- src/sphinxnotes/render/ctxnodes.py | 2 +- src/sphinxnotes/render/extractx.py | 2 ++ src/sphinxnotes/render/jinja.py | 1 - src/sphinxnotes/render/pipeline.py | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sphinxnotes/render/ctxnodes.py b/src/sphinxnotes/render/ctxnodes.py index fe13c79..397cd50 100644 --- a/src/sphinxnotes/render/ctxnodes.py +++ b/src/sphinxnotes/render/ctxnodes.py @@ -22,7 +22,7 @@ ) if TYPE_CHECKING: - from typing import Callable + from typing import Callable, Any from .markup import Host from .ctx import ResolvedContext diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index becbed6..c4fa1ab 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -7,6 +7,7 @@ from sphinx.transforms import SphinxTransform from .template import Phase + if TYPE_CHECKING: from typing import Any from sphinx.environment import BuildEnvironment @@ -59,6 +60,7 @@ def get(self, name: str) -> ExtraContext | None: def get_names(self) -> set[str]: return set(self.ctxs.keys()) + _REGISTRY = _ExtraContextRegistry() diff --git a/src/sphinxnotes/render/jinja.py b/src/sphinxnotes/render/jinja.py index 720586c..b4ad3c4 100644 --- a/src/sphinxnotes/render/jinja.py +++ b/src/sphinxnotes/render/jinja.py @@ -10,7 +10,6 @@ from __future__ import annotations from dataclasses import dataclass -from pprint import pformat from typing import TYPE_CHECKING, Callable, ClassVar, override from jinja2.sandbox import SandboxedEnvironment diff --git a/src/sphinxnotes/render/pipeline.py b/src/sphinxnotes/render/pipeline.py index 4f4eeff..05853e4 100644 --- a/src/sphinxnotes/render/pipeline.py +++ b/src/sphinxnotes/render/pipeline.py @@ -13,6 +13,7 @@ from .template import HostWrapper, Phase, Template, Host from .ctx import UnresolvedContext, ResolvedContext from .ctxnodes import pending_node + if TYPE_CHECKING: from sphinx.application import Sphinx From 3878965399ee509bae0de922e86d82c901103e37 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Wed, 6 May 2026 15:01:37 +0800 Subject: [PATCH 09/10] refactor: Restrict section extra context phases --- docs/tmpl.rst | 8 ++++---- src/sphinxnotes/render/ext/extractx.py | 11 +++++++---- src/sphinxnotes/render/extractx.py | 5 ++++- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/tmpl.rst b/docs/tmpl.rst index 51a862e..4fea734 100644 --- a/docs/tmpl.rst +++ b/docs/tmpl.rst @@ -206,7 +206,7 @@ The following extra contexts are available: documents found. ``markup`` - :Phase: parsing and later + :Phase: :term:`parsing` and later Information about the current directive or role invocation, such as its type, name, source text, and line number. @@ -228,10 +228,10 @@ The following extra contexts are available: {% endfor %} ``section`` - :Phase: parsing and later + :Phase: :term:`parsed` and :term:`resolving` A proxy to the current :py:class:`docutils.nodes.section` node, when one - exists. + exists. This extra context is not available during the parsing phase. .. example:: :style: grid @@ -242,7 +242,7 @@ The following extra contexts are available: "{{ load_extra('section').title }}" ``doc`` - :Phase: parsing and later + :Phase: :term:`parsing` and later A proxy to the current :py:class:`docutils.notes.document` node. diff --git a/src/sphinxnotes/render/ext/extractx.py b/src/sphinxnotes/render/ext/extractx.py index b049633..6faa726 100644 --- a/src/sphinxnotes/render/ext/extractx.py +++ b/src/sphinxnotes/render/ext/extractx.py @@ -17,7 +17,7 @@ from .. import meta, extra_context, ExtraContext from ..extractx import ExtraContextRequest -from ..template import HostWrapper +from ..template import HostWrapper, Phase # FIXME: from ..utils import find_current_section @@ -56,16 +56,19 @@ def generate(self, req: ExtraContextRequest) -> Any: class SectionExtraContext(ExtraContext): @override def generate(self, req: ExtraContextRequest) -> Any: + if req.phase == Phase.Parsing: + raise ValueError( + 'Extra context "section" is not available at ' + f'phase {req.phase}.' + ) if req.node.parent is not None: parent = req.node.parent elif isinstance(req.host, SphinxDirective): parent = req.host.state.parent elif isinstance(req.host, SphinxRole): parent = req.host.inliner.parent - elif isinstance(req.host, SphinxTransform): - parent = req.host.document else: - parent = None + assert False return proxy(find_current_section(parent)) diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index c4fa1ab..a0f2034 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -90,6 +90,9 @@ def load_extra(name: str) -> Any: f'Available: {sorted(extra_context_names())}' ) - return ctx.generate(request) + try: + return ctx.generate(request) + except Exception as e: + raise ValueError(f'Failed to load extra context "{name}".') from e return load_extra From d34761b5af8570264207ed634f46258dbcbc9a22 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Wed, 6 May 2026 15:07:19 +0800 Subject: [PATCH 10/10] docs: Minor docs changes --- docs/tmpl.rst | 7 ++++--- src/sphinxnotes/render/ext/extractx.py | 4 +--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/tmpl.rst b/docs/tmpl.rst index 4fea734..adaed74 100644 --- a/docs/tmpl.rst +++ b/docs/tmpl.rst @@ -206,7 +206,7 @@ The following extra contexts are available: documents found. ``markup`` - :Phase: :term:`parsing` and later + :Phase: :term:`parsing` Information about the current directive or role invocation, such as its type, name, source text, and line number. @@ -237,12 +237,13 @@ The following extra contexts are available: :style: grid .. data.render:: + :on: parsed - Section Title: + Section Title: "{{ load_extra('section').title }}" ``doc`` - :Phase: :term:`parsing` and later + :Phase: all A proxy to the current :py:class:`docutils.notes.document` node. diff --git a/src/sphinxnotes/render/ext/extractx.py b/src/sphinxnotes/render/ext/extractx.py index 6faa726..7b241fe 100644 --- a/src/sphinxnotes/render/ext/extractx.py +++ b/src/sphinxnotes/render/ext/extractx.py @@ -13,7 +13,6 @@ from typing import TYPE_CHECKING, override, Any from sphinx.util.docutils import SphinxDirective, SphinxRole -from sphinx.transforms import SphinxTransform from .. import meta, extra_context, ExtraContext from ..extractx import ExtraContextRequest @@ -58,8 +57,7 @@ class SectionExtraContext(ExtraContext): def generate(self, req: ExtraContextRequest) -> Any: if req.phase == Phase.Parsing: raise ValueError( - 'Extra context "section" is not available at ' - f'phase {req.phase}.' + f'Extra context "section" is not available at phase {req.phase}.' ) if req.node.parent is not None: parent = req.node.parent