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.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..adaed74 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') %} @@ -215,7 +206,7 @@ The following extra contexts are available: documents found. ``markup`` - :Phase: parsing and later + :Phase: :term:`parsing` Information about the current directive or role invocation, such as its type, name, source text, and line number. @@ -224,7 +215,6 @@ The following extra contexts are available: :style: grid .. data.render:: - :extra: markup {% set m = load_extra('markup') @@ -238,22 +228,22 @@ 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 .. data.render:: - :extra: section + :on: parsed - Section Title: + Section Title: "{{ load_extra('section').title }}" ``doc`` - :Phase: parsing and later + :Phase: all A proxy to the current :py:class:`docutils.notes.document` node. @@ -261,7 +251,6 @@ The following extra contexts are available: :style: grid .. data.render:: - :extra: doc Document title: "{{ load_extra('doc').title }}". @@ -331,7 +320,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 +342,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 +365,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..397cd50 100644 --- a/src/sphinxnotes/render/ctxnodes.py +++ b/src/sphinxnotes/render/ctxnodes.py @@ -12,6 +12,7 @@ UnresolvedContext, ResolvedContext, ) +from .extractx import ExtraContextRequest, extra_context_loader, extra_context_names 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, Any 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,14 +58,13 @@ 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 # Init hook lists. self._unresolved_context_hooks = [] - self._resolved_data_hooks = [] + self._resolved_context_hooks = [] self._markup_text_hooks = [] self._rendered_nodes_hooks = [] @@ -126,19 +124,25 @@ 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)}):') 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') + 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') + # 2. Render the template and context to markup text. try: - markup = TemplateRenderer(self.template.text).render(ctx, extra=self.extra) + markup = TemplateRenderer(self.template.text).render( + ctx, + globals={'load_extra': extra_context_loader(extractx_req)}, + debug=self.template.debug, + ) except Exception as e: report = err_report() report.text('Failed to render Jinja template:') @@ -227,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] @@ -235,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/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..7b241fe 100644 --- a/src/sphinxnotes/render/ext/extractx.py +++ b/src/sphinxnotes/render/ext/extractx.py @@ -14,7 +14,9 @@ from sphinx.util.docutils import SphinxDirective, SphinxRole -from .. import meta, extra_context, GlobalExtraContext, ParsingPhaseExtraContext +from .. import meta, extra_context, ExtraContext +from ..extractx import ExtraContextRequest +from ..template import HostWrapper, Phase # FIXME: from ..utils import find_current_section @@ -22,58 +24,64 @@ if TYPE_CHECKING: from sphinx.application import Sphinx - from sphinx.environment import BuildEnvironment @extra_context('markup') -class MarkupExtraContext(ParsingPhaseExtraContext): +class MarkupExtraContext(ExtraContext): @override - def generate(self, directive: SphinxDirective | SphinxRole) -> Any: - isdir = isinstance(directive, SphinxDirective) + 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 {req.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, req: ExtraContextRequest) -> Any: + return proxy(HostWrapper(req.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, req: ExtraContextRequest) -> Any: + if req.phase == Phase.Parsing: + raise ValueError( + f'Extra context "section" is not available at 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 + else: + assert False 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, req: ExtraContextRequest) -> Any: + return proxy(req.env.app) @extra_context('env') -class SphinxBuildEnvExtraContext(GlobalExtraContext): +class SphinxBuildEnvExtraContext(ExtraContext): @override - def generate(self, env: BuildEnvironment) -> Any: - return proxy(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 0cf278f..a0f2034 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -1,74 +1,39 @@ 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: + #: 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 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 -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, req: ExtraContextRequest) -> Any: ... # ========================== @@ -77,17 +42,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 +60,12 @@ 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) + """Decorator to register an :py:class:`ExtraContext`. :param name: The context name, used in templates via ``load_extra('name')``. """ @@ -141,68 +77,22 @@ def decorator(cls): return decorator -# ======================== -# Extra Context Generation -# ======================== +def extra_context_names() -> set[str]: + return _REGISTRY.get_names() -class ExtraContextGenerator: - node: pending_node - todo: set[str] - report: Report - - env: ClassVar[BuildEnvironment] - - 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 extra_context_loader(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(extra_context_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() + try: + return ctx.generate(request) + except Exception as e: + raise ValueError(f'Failed to load extra context "{name}".') from e + + return load_extra diff --git a/src/sphinxnotes/render/jinja.py b/src/sphinxnotes/render/jinja.py index 66e02d7..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 @@ -23,6 +22,7 @@ from typing import Any from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment + from .ctx import ResolvedContext @dataclass @@ -31,41 +31,21 @@ class TemplateRenderer: def render( self, - data: ParsedData | dict[str, Any], - extra: dict[str, Any] | None = None, - debug: Report | None = None, + data: ResolvedContext, + globals: dict[str, Any] | None = None, + debug: bool = False, ) -> 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') - # Convert data to context dict. if isinstance(data, ParsedData): ctx = data.asdict() 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 name not in extra: - raise ValueError( - f'Extra context "{name}" is not available. ' - f'Available: {list(extra.keys())}' - ) - return extra[name] - - ctx['load_extra'] = load_extra - - 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 = [ diff --git a/src/sphinxnotes/render/pipeline.py b/src/sphinxnotes/render/pipeline.py index 0bc886b..05853e4 100644 --- a/src/sphinxnotes/render/pipeline.py +++ b/src/sphinxnotes/render/pipeline.py @@ -13,8 +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 +121,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 +182,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 +245,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 +262,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..45ec853 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,16 +38,13 @@ 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`. -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 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..29a4525 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, req: 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 # =========================== diff --git a/tests/test_extractx.py b/tests/test_extractx.py new file mode 100644 index 0000000..fdfb365 --- /dev/null +++ b/tests/test_extractx.py @@ -0,0 +1,36 @@ +from types import SimpleNamespace + +from sphinxnotes.render.extractx import ( + ExtraContext, + ExtraContextRequest, + _REGISTRY, + extra_context_loader, +) +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, req): + 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()) + req = ExtraContextRequest(Template('').phase, node, host.env, host) + load_extra = extra_context_loader(req) + + assert load_extra(name) == 1 + assert load_extra(name) == 2 + finally: + _REGISTRY.ctxs.pop(name, 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'