From 0d8e8ae5e2ee8d15f52e3ca47b6cc908c36410ed Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sat, 4 Apr 2026 00:48:53 +0800 Subject: [PATCH 1/8] feat: Refactor extra context system - Add ABC hierarchy: ExtraContext, ParsingPhaseExtraContext, ParsedPhaseExtraContext, ResolvingPhaseExtraContext, GlobalExtraContext - Add @extra_context decorator for registering custom extra context - Template now has extra field to declare required context - Templates use load('name') to access extra context - Registry integration via REGISTRY.source - Update documentation and examples BREAKING CHANGE: Extra context access changed from {{ _name }} to {% set _name = load('name') %}; context names no longer prefixed with '_' --- docs/api.rst | 28 ++- docs/tmpl.rst | 150 ++++++++----- src/sphinxnotes/render/__init__.py | 27 ++- src/sphinxnotes/render/extractx.py | 261 +++++++++++++--------- src/sphinxnotes/render/pipeline.py | 14 +- src/sphinxnotes/render/render.py | 4 +- src/sphinxnotes/render/template.py | 19 +- src/sphinxnotes/render/utils/freestyle.py | 2 +- 8 files changed, 306 insertions(+), 199 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index a77c0de..6313f97 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -75,17 +75,27 @@ See :doc:`tmpl` for the higher-level guide. .. autoclass:: sphinxnotes.render.Phase :members: -Extra Context -------------- +Sources +------- + +See :doc:`tmpl` for built-in extra-context names such as ``doc`` and +``sphinx``, plus usage examples. + +.. autofunction:: sphinxnotes.render.extra_context + +.. autoclass:: sphinxnotes.render.ExtraContext -See :doc:`tmpl` for built-in extra-context names such as ``_doc`` and -``_sphinx``, plus usage examples. +.. autoclass:: sphinxnotes.render.ParsingPhaseExtraContext + :members: generate -.. autoclass:: sphinxnotes.render.GlobalExtraContxt +.. autoclass:: sphinxnotes.render.ParsedPhaseExtraContext + :members: generate -.. autoclass:: sphinxnotes.render.ParsePhaseExtraContext +.. autoclass:: sphinxnotes.render.ResolvingPhaseExtraContext + :members: generate -.. autoclass:: sphinxnotes.render.ResolvePhaseExtraContext +.. autoclass:: sphinxnotes.render.GlobalExtraContext + :members: generate .. autoclass:: sphinxnotes.render.ExtraContextRegistry :members: @@ -153,7 +163,7 @@ Registry ======== Developers can extend this extension (for example, to support more data types -or add new extra context) by adding new items to +or add new sources) by adding new items to :py:class:`sphinxnotes.render.REGISTRY`. .. autodata:: sphinxnotes.render.REGISTRY @@ -161,4 +171,4 @@ or add new extra context) by adding new items to .. autoclass:: sphinxnotes.render.Registry .. autoproperty:: data - .. autoproperty:: extra_context + .. autoproperty:: source diff --git a/docs/tmpl.rst b/docs/tmpl.rst index 8349261..71dfd04 100644 --- a/docs/tmpl.rst +++ b/docs/tmpl.rst @@ -153,8 +153,9 @@ the :rst:dir:`data.schema` directive. Extra Context ------------- -Templates may also receive extra context entries in addition to the main data -context. These entries are stored under names prefixed with ``_``. +Templates can access additional context through **extra context**. Extra context +must be explicitly declared using the ``:extra:`` option and loaded in the +template using the ``load()`` function. Built-in extra context ...................... @@ -163,23 +164,23 @@ Built-in extra context :header-rows: 1 * - Name - - Available in + - Available in Pahses - Description - * - ``_sphinx`` - - all phases + * - ``sphinx`` + - all - A proxy to the Sphinx application object. - * - ``_docutils`` - - all phases + * - ``docutils`` + - all - A mapping that exposes registered docutils directives and roles. - * - ``_markup`` - - parsing and later + * - ``markup`` + - :term:`parsing` and later - Information about the current directive or role invocation, such as its type, name, source text, and line number. - * - ``_section`` - - parsing and later + * - ``section`` + - :term:`parsing` and later - A proxy to the current section node, when one exists. - * - ``_doc`` - - parsing and later + * - ``doc`` + - :term:`parsing` and later - A proxy to the current document node. These values are wrapped for safer template access. In practice this means @@ -190,17 +191,25 @@ arbitrary Python object behavior. :style: grid .. data.render:: + :extra: doc Current document title is - "{{ _doc.title }}". + "{{ load('doc').title }}". Extending extra context ....................... -Extension authors can register more context generators through -:py:data:`sphinxnotes.render.REGISTRY`. +Extension authors can register custom extra context using the +:py:func:`~sphinxnotes.render.extra_context` decorator. -TODO. +.. code-block:: python + + from sphinxnotes.render import extra_context, ParsingPhaseExtraContext + + @extra_context('custom') + class CustomExtraContext(ParsingPhaseExtraContext): + def generate(self, directive): + return {'info': 'custom data'} Template ======== @@ -213,64 +222,81 @@ Render Phases Each :py:class:`~sphinxnotes.render.Template` has a render phase controlled by :py:class:`~sphinxnotes.render.Phase`. -``parsing`` (:py:data:`sphinxnotes.render.Phase.Parsing`) - Render immediately while the directive or role is running. +.. glossary:: - This is the default render phase. - Choose this when the template only needs local information and does not rely - on the final doctree or cross-document state. + ``parsing`` + Corresponding to :py:data:`sphinxnotes.render.Phase.Parsing`. + Render immediately while the directive or role is running. - .. example:: - :style: grid + This is the default render phase. + Choose this when the template only needs local information and does not rely + on the final doctree or cross-document state. - .. data.render:: - :on: parsing + .. example:: + :style: grid - - The current document has - {{ _doc.sections | length }} - section(s). - - The current project has - {{ _sphinx.env.all_docs | length }} - document(s). + .. data.render:: + :on: parsing + :extra: doc sphinx -``parsed`` (:py:data:`sphinxnotes.render.Phase.Parsed`) - Render after the current document has been parsed. + {% set doc = load('doc') %} + {% set sphinx = load('sphinx') %} - Choose this when the template needs the complete doctree of the current - document. + - The current document has + {{ doc.sections | length }} + section(s). + - The current project has + {{ sphinx.env.all_docs | length }} + document(s). - .. example:: - :style: grid + ``parsed`` + Corresponding to :py:data:`sphinxnotes.render.Phase.Parsed`. + Render after the current document has been parsed. - .. data.render:: - :on: parsed + Choose this when the template needs the complete doctree of the current + document. - - The current document has - {{ _doc.sections | length }} - section(s). - - The current project has - {{ _sphinx.env.all_docs | length }} - document(s). + .. example:: + :style: grid -``resolving`` (:py:data:`sphinxnotes.render.Phase.Resolving`) - Render late in the build, after references and other transforms are being - resolved. + .. data.render:: + :on: parsed + :extra: doc sphinx - Choose this when the template depends on project-wide state or on document - structure that is only stable near the end of the pipeline. + {% set doc = load('doc') %} + {% set sphinx = load('sphinx') %} - .. example:: - :style: grid + - The current document has + {{ doc.sections | length }} + section(s). + - The current project has + {{ sphinx.env.all_docs | length }} + document(s). - .. data.render:: - :on: resolving - - - The current document has - {{ _doc.sections | length }} - section(s). - - The current project has - {{ _sphinx.env.all_docs | length }} - document(s). + ``resolving`` + Corresponding to :py:data:`sphinxnotes.render.Phase.Resolving`. + Render late in the build, after references and other transforms are being + resolved. + + Choose this when the template depends on project-wide state or on document + structure that is only stable near the end of the pipeline. + + .. example:: + :style: grid + + .. data.render:: + :on: resolving + :extra: doc sphinx + + {% set doc = load('doc') %} + {% set sphinx = load('sphinx') %} + + - The current document has + {{ doc.sections | length }} + section(s). + - The current project has + {{ sphinx.env.all_docs | length }} + document(s). Debugging --------- diff --git a/src/sphinxnotes/render/__init__.py b/src/sphinxnotes/render/__init__.py index 41c0530..2adcf78 100644 --- a/src/sphinxnotes/render/__init__.py +++ b/src/sphinxnotes/render/__init__.py @@ -32,9 +32,12 @@ from .extractx import ( ExtraContextRegistry, ExtraContextGenerator, - GlobalExtraContxt, - ParsePhaseExtraContext, - ResolvePhaseExtraContext, + ExtraContext, + ParsingPhaseExtraContext, + ParsedPhaseExtraContext, + ResolvingPhaseExtraContext, + GlobalExtraContext, + extra_context, ) from .pipeline import BaseContextRole, BaseContextDirective from .sources import ( @@ -63,9 +66,14 @@ 'Host', 'PendingContext', 'ResolvedContext', - 'GlobalExtraContxt', - 'ParsePhaseExtraContext', - 'ResolvePhaseExtraContext', + 'ExtraContext', + 'ParsingPhaseExtraContext', + 'ParsedPhaseExtraContext', + 'ResolvingPhaseExtraContext', + 'GlobalExtraContext', + 'extra_context', + 'ExtraContextRegistry', + 'ExtraContextGenerator', 'pending_node', 'BaseContextRole', 'BaseContextDirective', @@ -84,12 +92,15 @@ def data(self) -> DataRegistry: return DATA_REGISTRY @property - def extra_context(cls) -> ExtraContextRegistry: - return ExtraContextGenerator.registry + def source(self) -> ExtraContextRegistry: + from .extractx import REGISTRY as SOURCE_REGISTRY + + return SOURCE_REGISTRY REGISTRY = Registry() + def setup(app: Sphinx): meta.pre_setup(app) diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index 2647425..3841d78 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -2,40 +2,66 @@ from typing import TYPE_CHECKING, override from abc import ABC, abstractmethod -from sphinx.util.docutils import SphinxDirective -from docutils.parsers.rst.directives import _directives -from docutils.parsers.rst.roles import _roles +from sphinx.util.docutils import SphinxDirective, SphinxRole +from sphinx.transforms import SphinxTransform from .render import HostWrapper from .ctxnodes import pending_node from .utils import find_current_section, Report, Reporter -from .utils.ctxproxy import proxy if TYPE_CHECKING: - from typing import Any, Callable, ClassVar + from typing import Any, Callable from sphinx.application import Sphinx - from .render import ParseHost, ResolveHost + from sphinx.environment import BuildEnvironment -class GlobalExtraContxt(ABC): - """Extra context available in any phase.""" +# =================================== +# ExtraContext ABC definitions +# =================================== + + +class ExtraContext(ABC): + """Base class for all extra context.""" + + +class ParsingPhaseExtraContext(ExtraContext): + """Extra context generated during the Parsing phase. + + The ``generate`` method receives the current directive or role being executed. + """ + + @abstractmethod + def generate(self, directive: SphinxDirective | SphinxRole) -> Any: ... + + +class ParsedPhaseExtraContext(ExtraContext): + """Extra context generated during the Parsed phase. + + The ``generate`` method receives the current Sphinx transform. + """ @abstractmethod - def generate(self) -> Any: ... + def generate(self, transform: SphinxTransform) -> Any: ... + +class ResolvingPhaseExtraContext(ExtraContext): + """Extra context generated during the Resolving phase. -class ParsePhaseExtraContext(ABC): - """Extra context generated during the :py:class:`~Phase.Parsing` phase.""" + The ``generate`` method receives the current Sphinx transform. + """ @abstractmethod - def generate(self, host: ParseHost) -> Any: ... + def generate(self, transform: SphinxTransform) -> Any: ... -class ResolvePhaseExtraContext(ABC): - """Extra context generated during the :py:class:`~Phase.Resolving` phase.""" +class GlobalExtraContext(ExtraContext): + """Extra context available in all phases. + + The ``generate`` method receives the Sphinx build environment. + """ @abstractmethod - def generate(self, host: ResolveHost) -> Any: ... + def generate(self, env: BuildEnvironment) -> Any: ... # ======================= @@ -44,56 +70,64 @@ def generate(self, host: ResolveHost) -> Any: ... class ExtraContextRegistry: - names: set[str] - parsing: dict[str, ParsePhaseExtraContext] - parsed: dict[str, ResolvePhaseExtraContext] - post_transform: dict[str, ResolvePhaseExtraContext] - global_: dict[str, GlobalExtraContxt] + _extra: dict[str, ExtraContext] def __init__(self) -> None: - self.names = set() - self.parsing = {} - self.parsed = {} - self.post_transform = {} - self.global_ = {} - - self.add_global_context('sphinx', _SphinxExtraContext()) - self.add_global_context('docutils', _DocutilsExtraContext()) - self.add_parsing_phase_context('markup', _MarkupExtraContext()) - self.add_parsing_phase_context('section', _SectionExtraContext()) - self.add_parsing_phase_context('doc', _DocExtraContext()) - - def _name_dedup(self, name: str) -> None: - # TODO: allow dup - if name in self.names: - raise ValueError(f'Context generator {name} already exists') - self.names.add(name) - - def add_parsing_phase_context( - self, name: str, ctxgen: ParsePhaseExtraContext - ) -> None: - """Register an extra context for the :py:class:`~Phase.Parsing` phase.""" - self._name_dedup(name) - self.parsing['_' + name] = ctxgen - - def add_parsed_phase_context( - self, name: str, ctxgen: ResolvePhaseExtraContext - ) -> None: - """Register an extra context for the :py:class:`~Phase.Parsed` phase.""" - self._name_dedup(name) - self.parsed['_' + name] = ctxgen - - def add_post_transform_phase_context( - self, name: str, ctxgen: ResolvePhaseExtraContext - ) -> None: - """Register an extra context for the :py:class:`~Phase.Resolving` phase.""" - self._name_dedup(name) - self.post_transform['_' + name] = ctxgen - - def add_global_context(self, name: str, ctxgen: GlobalExtraContxt): - """Register a global extra context available across phases.""" - self._name_dedup(name) - self.global_['_' + name] = ctxgen + self._extra = {} + + def register(self, name: str, ctx: ExtraContext) -> None: + """Register an extra context. + + :param name: The context name, used in templates via ``load('name')``. + :param ctx: The extra context instance. + """ + if name in self._extra: + raise ValueError(f'Extra context "{name}" already registered') + self._extra[name] = ctx + + def get(self, name: str) -> ExtraContext | None: + """Get a registered extra context by name.""" + return self._extra.get(name) + + @property + def names(self) -> list[str]: + """Return all registered extra context names.""" + return list(self._extra.keys()) + + +# Global registry instance +REGISTRY = ExtraContextRegistry() + + +def extra_context(name: str): + """Decorator to register an extra context. + + The phase is determined by which ExtraContext subclass 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, directive): + return proxy(HostWrapper(directive).doctree) + + :param name: The context name, used in templates via ``load('name')``. + """ + + def decorator(cls): + if not issubclass(cls, ExtraContext): + raise TypeError(f'{cls.__name__} must subclass an ExtraContext ABC') + + instance = cls() + REGISTRY.register(name, instance) + return cls + + return decorator # =================================== @@ -101,43 +135,54 @@ def add_global_context(self, name: str, ctxgen: GlobalExtraContxt): # =================================== -class _MarkupExtraContext(ParsePhaseExtraContext): +@extra_context('markup') +class MarkupExtraContext(ParsingPhaseExtraContext): @override - def generate(self, host: ParseHost) -> Any: - isdir = isinstance(host, SphinxDirective) + def generate(self, directive: SphinxDirective | SphinxRole) -> Any: + isdir = isinstance(directive, SphinxDirective) return { 'type': 'directive' if isdir else 'role', - 'name': host.name, - 'lineno': host.lineno, - 'rawtext': host.block_text if isdir else host.rawtext, + 'name': directive.name, + 'lineno': directive.lineno, + 'rawtext': directive.block_text if isdir else directive.rawtext, } -class _DocExtraContext(ParsePhaseExtraContext): +@extra_context('doc') +class DocExtraContext(ParsingPhaseExtraContext): @override - def generate(self, host: ParseHost) -> Any: - return proxy(HostWrapper(host).doctree) + def generate(self, directive: SphinxDirective | SphinxRole) -> Any: + from .utils.ctxproxy import proxy + return proxy(HostWrapper(directive).doctree) -class _SectionExtraContext(ParsePhaseExtraContext): + +@extra_context('section') +class SectionExtraContext(ParsingPhaseExtraContext): @override - def generate(self, host: ParseHost) -> Any: - parent = HostWrapper(host).parent - return proxy(find_current_section(parent)) + def generate(self, directive: SphinxDirective | SphinxRole) -> Any: + from .utils.ctxproxy import proxy + parent = HostWrapper(directive).parent + return proxy(find_current_section(parent)) -class _SphinxExtraContext(GlobalExtraContxt): - app: ClassVar[Sphinx] +@extra_context('sphinx') +class SphinxExtraContext(GlobalExtraContext): @override - def generate(self) -> Any: - return proxy(self.app) + def generate(self, env: BuildEnvironment) -> Any: + from .utils.ctxproxy import proxy + return proxy(env.app) -class _DocutilsExtraContext(GlobalExtraContxt): + +@extra_context('docutils') +class DocutilsExtraContext(GlobalExtraContext): @override - def generate(self) -> Any: - # FIXME: use unexported api + def generate(self, env: BuildEnvironment) -> Any: + from docutils.parsers.rst.directives import _directives + from docutils.parsers.rst.roles import _roles + return { 'directives': _directives, 'roles': _roles, @@ -145,7 +190,7 @@ def generate(self) -> Any: # ======================== -# Extra Context Management +# Extra Context Generation # ======================== @@ -153,8 +198,6 @@ class ExtraContextGenerator: node: pending_node report: Report - registry: ClassVar[ExtraContextRegistry] = ExtraContextRegistry() - def __init__(self, node: pending_node) -> None: self.node = node self.report = Report( @@ -165,25 +208,37 @@ def __init__(self, node: pending_node) -> None: ) Reporter(node).append(self.report) - def on_anytime(self) -> None: - for name, ctxgen in self.registry.global_.items(): - self._safegen(name, lambda: ctxgen.generate()) - - def on_parsing(self, host: ParseHost) -> None: - for name, ctxgen in self.registry.parsing.items(): - self._safegen(name, lambda: ctxgen.generate(host)) - - def on_parsed(self, host: ResolveHost) -> None: - for name, ctxgen in self.registry.parsed.items(): - self._safegen(name, lambda: ctxgen.generate(host)) - - def on_resolving(self, host: ResolveHost) -> None: - for name, ctxgen in self.registry.post_transform.items(): - self._safegen(name, lambda: ctxgen.generate(host)) + def on_anytime(self, app: Sphinx) -> None: + """Generate global extra context for requested names.""" + env = app.env + for name in self.node.template.extra: + ctx = REGISTRY.get(name) + if ctx is not None and isinstance(ctx, GlobalExtraContext): + self._safegen(name, lambda c=ctx: c.generate(env)) + + def on_parsing(self, directive: SphinxDirective | SphinxRole) -> None: + """Generate parsing phase extra context for requested names.""" + for name in self.node.template.extra: + ctx = REGISTRY.get(name) + if ctx is not None and isinstance(ctx, ParsingPhaseExtraContext): + self._safegen(name, lambda c=ctx: c.generate(directive)) + + def on_parsed(self, transform: SphinxTransform) -> None: + """Generate parsed phase extra context for requested names.""" + for name in self.node.template.extra: + ctx = REGISTRY.get(name) + if ctx is not None and isinstance(ctx, ParsedPhaseExtraContext): + self._safegen(name, lambda c=ctx: c.generate(transform)) + + def on_resolving(self, transform: SphinxTransform) -> None: + """Generate resolving phase extra context for requested names.""" + for name in self.node.template.extra: + ctx = REGISTRY.get(name) + if ctx is not None and isinstance(ctx, ResolvingPhaseExtraContext): + self._safegen(name, lambda c=ctx: c.generate(transform)) def _safegen(self, name: str, gen: Callable[[], Any]): try: - # ctxgen.generate can be user-defined code, exception of any kind are possible. self.node.extra[name] = gen() except Exception: self.report.text(f'Failed to generate extra context "{name}":') @@ -191,4 +246,4 @@ def _safegen(self, name: str, gen: Callable[[], Any]): def setup(app: Sphinx): - _SphinxExtraContext.app = app + pass diff --git a/src/sphinxnotes/render/pipeline.py b/src/sphinxnotes/render/pipeline.py index cc0779f..fc77d82 100644 --- a/src/sphinxnotes/render/pipeline.py +++ b/src/sphinxnotes/render/pipeline.py @@ -91,7 +91,7 @@ def queue_context( return pending @final - def render_queue(self) -> list[pending_node]: + def render_queue(self, app: Sphinx) -> list[pending_node]: """ Try rendering all pending nodes in queue. @@ -121,8 +121,8 @@ def render_queue(self) -> list[pending_node]: ns.append(pending) continue - # Generate global extra context for later use. - ExtraContextGenerator(pending).on_anytime() + # Generate global sources for later use. + ExtraContextGenerator(pending).on_anytime(app) host = cast(Host, self) pending.render(host) @@ -205,7 +205,7 @@ def run(self) -> list[nodes.Node]: self.queue_context(self.current_context(), self.current_template()) ns = [] - for x in self.render_queue(): + for x in self.render_queue(self.env.app): if not x.rendered: ns.append(x) continue @@ -233,7 +233,7 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: pending.inline = True ns, msgs = [], [] - for n in self.render_queue(): + for n in self.render_queue(self.env.app): if not n.rendered: ns.append(n) continue @@ -258,7 +258,7 @@ def apply(self, **kwargs): for pending in self.document.findall(pending_node): self.queue_pending_node(pending) - for n in self.render_queue(): + for n in self.render_queue(self.app): ... @@ -275,7 +275,7 @@ def process_pending_node(self, n: pending_node) -> bool: def apply(self, **kwargs): for pending in self.document.findall(pending_node): self.queue_pending_node(pending) - ns = self.render_queue() + ns = self.render_queue(self.app) # NOTE: Should no node left. assert len(ns) == 0 diff --git a/src/sphinxnotes/render/render.py b/src/sphinxnotes/render/render.py index 8af664e..7ad14f5 100644 --- a/src/sphinxnotes/render/render.py +++ b/src/sphinxnotes/render/render.py @@ -1,5 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from docutils import nodes @@ -34,6 +34,8 @@ 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/src/sphinxnotes/render/template.py b/src/sphinxnotes/render/template.py index 8a0f94b..8dfe835 100644 --- a/src/sphinxnotes/render/template.py +++ b/src/sphinxnotes/render/template.py @@ -41,7 +41,7 @@ def render( debug.text('Data:') debug.code(pformat(data), lang='python') - debug.text('Extra context (just key):') + debug.text('Available extra context (just keys):') debug.code(pformat(list(extra.keys())), lang='python') # Convert data to context dict. @@ -50,13 +50,16 @@ def render( elif isinstance(data, dict): ctx = data.copy() - # Merge extra context and main context. - conflicts = set() - for name, e in extra.items(): - if name not in ctx: - ctx[name] = e - else: - conflicts.add(name) + # Inject load() function for accessing extra context. + def load(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'] = load text = self._render(ctx, debug=debug is not None) diff --git a/src/sphinxnotes/render/utils/freestyle.py b/src/sphinxnotes/render/utils/freestyle.py index 2ccd2f0..a71b190 100644 --- a/src/sphinxnotes/render/utils/freestyle.py +++ b/src/sphinxnotes/render/utils/freestyle.py @@ -32,7 +32,7 @@ def __contains__(self, _): class FreeStyleDirective(SphinxDirective): """ - TODO: docutils/parsers/rst/directives/misc.py::Meta + Standard impl of "FreeStyle" directive: docutils/parsers/rst/directives/misc.py::Meta """ final_argument_whitespace = True From 740696cfcee11e043223785997d1c687bc0c5503 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sat, 4 Apr 2026 14:45:48 +0800 Subject: [PATCH 2/8] refactor: Simplify extra context API - Remove REGISTRY.source property (decorators handle registration) - Remove ExtraContextRegistry, ExtraContextGenerator, ExtraContext from __all__ - Change on_anytime to take env instead of app - Simplify ExtraContextGenerator by extracting _generate method - Fix 'Source' -> 'Extra Context' naming in comments and docs --- docs/api.rst | 10 +++----- src/sphinxnotes/render/__init__.py | 12 ---------- src/sphinxnotes/render/extractx.py | 37 +++++++++++------------------- src/sphinxnotes/render/pipeline.py | 4 ++-- 4 files changed, 19 insertions(+), 44 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 6313f97..4bfaca2 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -75,8 +75,8 @@ See :doc:`tmpl` for the higher-level guide. .. autoclass:: sphinxnotes.render.Phase :members: -Sources -------- +Extra Context +------------- See :doc:`tmpl` for built-in extra-context names such as ``doc`` and ``sphinx``, plus usage examples. @@ -97,9 +97,6 @@ See :doc:`tmpl` for built-in extra-context names such as ``doc`` and .. autoclass:: sphinxnotes.render.GlobalExtraContext :members: generate -.. autoclass:: sphinxnotes.render.ExtraContextRegistry - :members: - Base Roles and Directives ------------------------- @@ -163,7 +160,7 @@ Registry ======== Developers can extend this extension (for example, to support more data types -or add new sources) by adding new items to +or add new extra context) by adding new items to :py:class:`sphinxnotes.render.REGISTRY`. .. autodata:: sphinxnotes.render.REGISTRY @@ -171,4 +168,3 @@ or add new sources) by adding new items to .. autoclass:: sphinxnotes.render.Registry .. autoproperty:: data - .. autoproperty:: source diff --git a/src/sphinxnotes/render/__init__.py b/src/sphinxnotes/render/__init__.py index 2adcf78..5d7d42e 100644 --- a/src/sphinxnotes/render/__init__.py +++ b/src/sphinxnotes/render/__init__.py @@ -30,9 +30,6 @@ from .ctx import PendingContext, ResolvedContext from .ctxnodes import pending_node from .extractx import ( - ExtraContextRegistry, - ExtraContextGenerator, - ExtraContext, ParsingPhaseExtraContext, ParsedPhaseExtraContext, ResolvingPhaseExtraContext, @@ -66,14 +63,11 @@ 'Host', 'PendingContext', 'ResolvedContext', - 'ExtraContext', 'ParsingPhaseExtraContext', 'ParsedPhaseExtraContext', 'ResolvingPhaseExtraContext', 'GlobalExtraContext', 'extra_context', - 'ExtraContextRegistry', - 'ExtraContextGenerator', 'pending_node', 'BaseContextRole', 'BaseContextDirective', @@ -91,12 +85,6 @@ class Registry: def data(self) -> DataRegistry: return DATA_REGISTRY - @property - def source(self) -> ExtraContextRegistry: - from .extractx import REGISTRY as SOURCE_REGISTRY - - return SOURCE_REGISTRY - REGISTRY = Registry() diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index 3841d78..21fa997 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -208,41 +208,32 @@ def __init__(self, node: pending_node) -> None: ) Reporter(node).append(self.report) - def on_anytime(self, app: Sphinx) -> None: + def on_anytime(self, env: BuildEnvironment) -> None: """Generate global extra context for requested names.""" - env = app.env - for name in self.node.template.extra: - ctx = REGISTRY.get(name) - if ctx is not None and isinstance(ctx, GlobalExtraContext): - self._safegen(name, lambda c=ctx: c.generate(env)) + self._generate(GlobalExtraContext, lambda ctx: ctx.generate(env)) def on_parsing(self, directive: SphinxDirective | SphinxRole) -> None: """Generate parsing phase extra context for requested names.""" - for name in self.node.template.extra: - ctx = REGISTRY.get(name) - if ctx is not None and isinstance(ctx, ParsingPhaseExtraContext): - self._safegen(name, lambda c=ctx: c.generate(directive)) + self._generate(ParsingPhaseExtraContext, lambda ctx: ctx.generate(directive)) def on_parsed(self, transform: SphinxTransform) -> None: """Generate parsed phase extra context for requested names.""" - for name in self.node.template.extra: - ctx = REGISTRY.get(name) - if ctx is not None and isinstance(ctx, ParsedPhaseExtraContext): - self._safegen(name, lambda c=ctx: c.generate(transform)) + self._generate(ParsedPhaseExtraContext, lambda ctx: ctx.generate(transform)) def on_resolving(self, transform: SphinxTransform) -> None: """Generate resolving phase extra context for requested names.""" + self._generate(ResolvingPhaseExtraContext, lambda ctx: ctx.generate(transform)) + + def _generate(self, cls: type, gen: Callable[[ExtraContext], Any]) -> None: + """Generate extra context of the given type for all requested names.""" for name in self.node.template.extra: ctx = REGISTRY.get(name) - if ctx is not None and isinstance(ctx, ResolvingPhaseExtraContext): - self._safegen(name, lambda c=ctx: c.generate(transform)) - - def _safegen(self, name: str, gen: Callable[[], Any]): - try: - self.node.extra[name] = gen() - except Exception: - self.report.text(f'Failed to generate extra context "{name}":') - self.report.traceback() + if ctx is not None and isinstance(ctx, cls): + try: + self.node.extra[name] = gen(ctx) + except Exception: + self.report.text(f'Failed to generate extra context "{name}":') + self.report.traceback() def setup(app: Sphinx): diff --git a/src/sphinxnotes/render/pipeline.py b/src/sphinxnotes/render/pipeline.py index fc77d82..6081557 100644 --- a/src/sphinxnotes/render/pipeline.py +++ b/src/sphinxnotes/render/pipeline.py @@ -121,8 +121,8 @@ def render_queue(self, app: Sphinx) -> list[pending_node]: ns.append(pending) continue - # Generate global sources for later use. - ExtraContextGenerator(pending).on_anytime(app) + # Generate global extra context for later use. + ExtraContextGenerator(pending).on_anytime(app.env) host = cast(Host, self) pending.render(host) From 8be3c989c122936ec5239949af527879e92b7e64 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sat, 4 Apr 2026 14:56:16 +0800 Subject: [PATCH 3/8] refactor: Report errors for missing or wrong-type extra context When template requests an extra context that is not registered or has wrong phase type, _generate now reports an error instead of silently skipping. Co-Authored-By: MiMo v2 Pro --- src/sphinxnotes/render/extractx.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index 21fa997..28ac69b 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -228,12 +228,23 @@ def _generate(self, cls: type, gen: Callable[[ExtraContext], Any]) -> None: """Generate extra context of the given type for all requested names.""" for name in self.node.template.extra: ctx = REGISTRY.get(name) - if ctx is not None and isinstance(ctx, cls): - try: - self.node.extra[name] = gen(ctx) - except Exception: - self.report.text(f'Failed to generate extra context "{name}":') - self.report.traceback() + if ctx is None: + self.report.text( + f'Extra context "{name}" is not registered. ' + f'Available: {REGISTRY.names}' + ) + continue + if not isinstance(ctx, cls): + self.report.text( + f'Extra context "{name}" has wrong type: ' + f'expected {cls.__name__}, got {type(ctx).__name__}' + ) + continue + try: + self.node.extra[name] = gen(ctx) + except Exception: + self.report.text(f'Failed to generate extra context "{name}":') + self.report.traceback() def setup(app: Sphinx): From ec3341dcb261a464185e72a754e7e0d026f2d1a6 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sat, 4 Apr 2026 15:06:28 +0800 Subject: [PATCH 4/8] docs: Add phase cross-references to ExtraContext docstrings Co-Authored-By: MiMo v2 Pro --- src/sphinxnotes/render/extractx.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index 28ac69b..66480c0 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -25,7 +25,7 @@ class ExtraContext(ABC): class ParsingPhaseExtraContext(ExtraContext): - """Extra context generated during the Parsing phase. + """Extra context generated during the :py:data:`~Phase.Parsing` phase. The ``generate`` method receives the current directive or role being executed. """ @@ -35,7 +35,7 @@ def generate(self, directive: SphinxDirective | SphinxRole) -> Any: ... class ParsedPhaseExtraContext(ExtraContext): - """Extra context generated during the Parsed phase. + """Extra context generated during the :py:data:`~Phase.Parsed` phase. The ``generate`` method receives the current Sphinx transform. """ @@ -45,7 +45,7 @@ def generate(self, transform: SphinxTransform) -> Any: ... class ResolvingPhaseExtraContext(ExtraContext): - """Extra context generated during the Resolving phase. + """Extra context generated during the :py:data:`~Phase.Resolving` phase. The ``generate`` method receives the current Sphinx transform. """ From 994f70983b2e9d0ef8f8635cedb8e02ba8f89f8c Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sat, 4 Apr 2026 16:33:01 +0800 Subject: [PATCH 5/8] refactor: Simplify ExtraContext and improve generation logic - ExtraContext is now a type alias instead of ABC - Each phase class is its own ABC - ExtraContextGenerator validates requested contexts in __init__ - Uses todo set to track and generate only requested contexts Co-Authored-By: MiMo v2 Pro --- docs/api.rst | 5 +- src/sphinxnotes/render/extractx.py | 230 ++++++++++++++++------------- 2 files changed, 133 insertions(+), 102 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 4bfaca2..b4f07b6 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -87,16 +87,19 @@ See :doc:`tmpl` for built-in extra-context names such as ``doc`` and .. autoclass:: sphinxnotes.render.ParsingPhaseExtraContext :members: generate + :undoc-members: .. autoclass:: sphinxnotes.render.ParsedPhaseExtraContext :members: generate + :undoc-members: .. autoclass:: sphinxnotes.render.ResolvingPhaseExtraContext :members: generate + :undoc-members: .. autoclass:: sphinxnotes.render.GlobalExtraContext :members: generate - + :undoc-members: Base Roles and Directives ------------------------- diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index 66480c0..7002b33 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -15,16 +15,20 @@ from sphinx.environment import BuildEnvironment -# =================================== +# ============================ # ExtraContext ABC definitions -# =================================== +# ============================ +type ExtraContext = ( + ParsingPhaseExtraContext + | ParsingPhaseExtraContext + | ResolvingPhaseExtraContext + | GlobalExtraContext +) +"""Type alias of all available extra contexts.""" -class ExtraContext(ABC): - """Base class for all extra context.""" - -class ParsingPhaseExtraContext(ExtraContext): +class ParsingPhaseExtraContext(ABC): """Extra context generated during the :py:data:`~Phase.Parsing` phase. The ``generate`` method receives the current directive or role being executed. @@ -34,7 +38,7 @@ class ParsingPhaseExtraContext(ExtraContext): def generate(self, directive: SphinxDirective | SphinxRole) -> Any: ... -class ParsedPhaseExtraContext(ExtraContext): +class ParsedPhaseExtraContext(ABC): """Extra context generated during the :py:data:`~Phase.Parsed` phase. The ``generate`` method receives the current Sphinx transform. @@ -44,7 +48,7 @@ class ParsedPhaseExtraContext(ExtraContext): def generate(self, transform: SphinxTransform) -> Any: ... -class ResolvingPhaseExtraContext(ExtraContext): +class ResolvingPhaseExtraContext(ABC): """Extra context generated during the :py:data:`~Phase.Resolving` phase. The ``generate`` method receives the current Sphinx transform. @@ -54,7 +58,7 @@ class ResolvingPhaseExtraContext(ExtraContext): def generate(self, transform: SphinxTransform) -> Any: ... -class GlobalExtraContext(ExtraContext): +class GlobalExtraContext(ABC): """Extra context available in all phases. The ``generate`` method receives the Sphinx build environment. @@ -64,50 +68,66 @@ class GlobalExtraContext(ExtraContext): def generate(self, env: BuildEnvironment) -> Any: ... -# ======================= +# ========================== # Extra context registration -# ======================= +# ========================== -class ExtraContextRegistry: - _extra: dict[str, ExtraContext] +class _ExtraContextRegistry: + ctxs: dict[str, ExtraContext] def __init__(self) -> None: - self._extra = {} + self.ctxs = {} + + def is_extra_context(self, ctx: Any) -> bool: + return isinstance( + ctx, + ( + ParsingPhaseExtraContext, + ParsingPhaseExtraContext, + ResolvingPhaseExtraContext, + GlobalExtraContext, + ), + ) def register(self, name: str, ctx: ExtraContext) -> None: - """Register an extra context. - - :param name: The context name, used in templates via ``load('name')``. - :param ctx: The extra context instance. - """ - if name in self._extra: + if name in self.ctxs: raise ValueError(f'Extra context "{name}" already registered') - self._extra[name] = ctx + if not self.is_extra_context(ctx): + raise TypeError( + f'Invalid extra context instance "{name}", Expecting {ExtraContext}' + ) + self.ctxs[name] = ctx def get(self, name: str) -> ExtraContext | None: - """Get a registered extra context by name.""" - return self._extra.get(name) + if name not in self.ctxs: + return None + return self.ctxs[name] - @property - def names(self) -> list[str]: - """Return all registered extra context names.""" - return list(self._extra.keys()) + def get_names_by_phase(self, cls: type) -> set[str]: + return {name for name, ctx in self.ctxs.items() if isinstance(ctx, cls)} + def get_names(self) -> set[str]: + return set(self.ctxs.keys()) -# Global registry instance -REGISTRY = ExtraContextRegistry() + +# Global registry instance. +_REGISTRY = _ExtraContextRegistry() def extra_context(name: str): """Decorator to register an extra context. - The phase is determined by which ExtraContext subclass is used: + 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 + :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:: @@ -120,19 +140,74 @@ def generate(self, directive): """ def decorator(cls): - if not issubclass(cls, ExtraContext): - raise TypeError(f'{cls.__name__} must subclass an ExtraContext ABC') - - instance = cls() - REGISTRY.register(name, instance) + _REGISTRY.register(name, cls()) return cls return decorator -# =================================== +# ======================== +# Extra Context Generation +# ======================== + + +class ExtraContextGenerator: + node: pending_node + todo: set[str] + report: Report + + 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 + requested = set(node.template.extra) + avail = _REGISTRY.get_names() + self.todo = requested & avail + + # Report errors for non-existent contexts + if nonexist := requested - avail: + self.report.text(f'Extra contexts {nonexist} are not registered.') + + 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, gen: Callable[..., Any]) -> None: + # Get all context names available for this phase + avail = _REGISTRY.get_names_by_phase(cls) + # 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() + + +# ===================================== # Builtin extra context implementations -# =================================== +# ===================================== @extra_context('markup') @@ -169,11 +244,22 @@ def generate(self, directive: SphinxDirective | SphinxRole) -> Any: @extra_context('sphinx') class SphinxExtraContext(GlobalExtraContext): + app: Sphinx + + @override + def generate(self, env: BuildEnvironment) -> Any: + from .utils.ctxproxy import proxy + + return proxy(self.app) + + +@extra_context('env') +class SphinxBuildEnvExtraContext(GlobalExtraContext): @override def generate(self, env: BuildEnvironment) -> Any: from .utils.ctxproxy import proxy - return proxy(env.app) + return proxy(env) @extra_context('docutils') @@ -189,63 +275,5 @@ def generate(self, env: BuildEnvironment) -> Any: } -# ======================== -# Extra Context Generation -# ======================== - - -class ExtraContextGenerator: - node: pending_node - report: Report - - 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) - - def on_anytime(self, env: BuildEnvironment) -> None: - """Generate global extra context for requested names.""" - self._generate(GlobalExtraContext, lambda ctx: ctx.generate(env)) - - def on_parsing(self, directive: SphinxDirective | SphinxRole) -> None: - """Generate parsing phase extra context for requested names.""" - self._generate(ParsingPhaseExtraContext, lambda ctx: ctx.generate(directive)) - - def on_parsed(self, transform: SphinxTransform) -> None: - """Generate parsed phase extra context for requested names.""" - self._generate(ParsedPhaseExtraContext, lambda ctx: ctx.generate(transform)) - - def on_resolving(self, transform: SphinxTransform) -> None: - """Generate resolving phase extra context for requested names.""" - self._generate(ResolvingPhaseExtraContext, lambda ctx: ctx.generate(transform)) - - def _generate(self, cls: type, gen: Callable[[ExtraContext], Any]) -> None: - """Generate extra context of the given type for all requested names.""" - for name in self.node.template.extra: - ctx = REGISTRY.get(name) - if ctx is None: - self.report.text( - f'Extra context "{name}" is not registered. ' - f'Available: {REGISTRY.names}' - ) - continue - if not isinstance(ctx, cls): - self.report.text( - f'Extra context "{name}" has wrong type: ' - f'expected {cls.__name__}, got {type(ctx).__name__}' - ) - continue - try: - self.node.extra[name] = gen(ctx) - except Exception: - self.report.text(f'Failed to generate extra context "{name}":') - self.report.traceback() - - def setup(app: Sphinx): - pass + SphinxExtraContext.app = app From 5c2cf1f052943d2e23e8c643d30904fcd54d33b9 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sat, 4 Apr 2026 22:56:03 +0800 Subject: [PATCH 6/8] tests: Add smoke test for extra context feature Test custom extra context registration with @extra_context decorator, load() function in templates, and Template.extra field. Co-Authored-By: MiMo v2 Pro --- tests/roots/test-extra-context/conf.py | 41 ++++++++++++++++++++++++ tests/roots/test-extra-context/index.rst | 4 +++ tests/test_smoke.py | 10 ++++++ 3 files changed, 55 insertions(+) create mode 100644 tests/roots/test-extra-context/conf.py create mode 100644 tests/roots/test-extra-context/index.rst diff --git a/tests/roots/test-extra-context/conf.py b/tests/roots/test-extra-context/conf.py new file mode 100644 index 0000000..f72850c --- /dev/null +++ b/tests/roots/test-extra-context/conf.py @@ -0,0 +1,41 @@ +from sphinx.application import Sphinx +from sphinxnotes.render import ( + extra_context, + ParsingPhaseExtraContext, + GlobalExtraContext, + BaseContextDirective, + Template, +) + + +@extra_context('custom_parsing') +class CustomParsingExtraContext(ParsingPhaseExtraContext): + def generate(self, directive): + return {'custom_value': 'parsing_test'} + + +@extra_context('custom_global') +class CustomGlobalExtraContext(GlobalExtraContext): + def generate(self, env): + return {'custom_value': 'global_test'} + + +class CustomExtraContextDirective(BaseContextDirective): + def current_context(self): + return {} + + def current_template(self): + return Template( + """ +{% set _parsing = load('custom_parsing') %} +{% set _global = load('custom_global') %} +Parsing: {{ _parsing.custom_value }} +Global: {{ _global.custom_value }} +""", + extra=['custom_parsing', 'custom_global'], + ) + + +def setup(app: Sphinx): + app.setup_extension('sphinxnotes.render') + app.add_directive('custom-extra', CustomExtraContextDirective) diff --git a/tests/roots/test-extra-context/index.rst b/tests/roots/test-extra-context/index.rst new file mode 100644 index 0000000..e4a4241 --- /dev/null +++ b/tests/roots/test-extra-context/index.rst @@ -0,0 +1,4 @@ +Extra Context Test +================== + +.. custom-extra:: diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 5af277b..9fa23b2 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -26,3 +26,13 @@ def test_strict_data_define_directive_card(app, status, warning): # -- literalinclude:end:end-to-end-card -- + + +@pytest.mark.sphinx('html', testroot='extra-context') +def test_extra_context_custom_loader(app, status, warning): + app.build() + + html = (app.outdir / 'index.html').read_text(encoding='utf-8') + + assert 'Parsing: parsing_test' in html + assert 'Global: global_test' in html From 450417f9463179b1f7ca35d37830611efd805260 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sat, 4 Apr 2026 23:30:08 +0800 Subject: [PATCH 7/8] refactor: Rename load() to load_extra() in templates Co-Authored-By: MiMo v2 Pro --- docs/tmpl.rst | 16 ++++++++-------- src/sphinxnotes/render/extractx.py | 2 +- src/sphinxnotes/render/template.py | 6 +++--- tests/roots/test-extra-context/conf.py | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/tmpl.rst b/docs/tmpl.rst index 71dfd04..c25da6e 100644 --- a/docs/tmpl.rst +++ b/docs/tmpl.rst @@ -155,7 +155,7 @@ Extra Context Templates can access additional context through **extra context**. Extra context must be explicitly declared using the ``:extra:`` option and loaded in the -template using the ``load()`` function. +template using the ``load_extra()`` function. Built-in extra context ...................... @@ -194,7 +194,7 @@ arbitrary Python object behavior. :extra: doc Current document title is - "{{ load('doc').title }}". + "{{ load_extra('doc').title }}". Extending extra context ....................... @@ -239,8 +239,8 @@ Each :py:class:`~sphinxnotes.render.Template` has a render phase controlled by :on: parsing :extra: doc sphinx - {% set doc = load('doc') %} - {% set sphinx = load('sphinx') %} + {% set doc = load_extra('doc') %} + {% set sphinx = load_extra('sphinx') %} - The current document has {{ doc.sections | length }} @@ -263,8 +263,8 @@ Each :py:class:`~sphinxnotes.render.Template` has a render phase controlled by :on: parsed :extra: doc sphinx - {% set doc = load('doc') %} - {% set sphinx = load('sphinx') %} + {% set doc = load_extra('doc') %} + {% set sphinx = load_extra('sphinx') %} - The current document has {{ doc.sections | length }} @@ -288,8 +288,8 @@ Each :py:class:`~sphinxnotes.render.Template` has a render phase controlled by :on: resolving :extra: doc sphinx - {% set doc = load('doc') %} - {% set sphinx = load('sphinx') %} + {% set doc = load_extra('doc') %} + {% set sphinx = load_extra('sphinx') %} - The current document has {{ doc.sections | length }} diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index 7002b33..9791850 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -136,7 +136,7 @@ class DocExtraContext(ParsingPhaseExtraContext): def generate(self, directive): return proxy(HostWrapper(directive).doctree) - :param name: The context name, used in templates via ``load('name')``. + :param name: The context name, used in templates via ``load_extra('name')``. """ def decorator(cls): diff --git a/src/sphinxnotes/render/template.py b/src/sphinxnotes/render/template.py index 8dfe835..0b34169 100644 --- a/src/sphinxnotes/render/template.py +++ b/src/sphinxnotes/render/template.py @@ -50,8 +50,8 @@ def render( elif isinstance(data, dict): ctx = data.copy() - # Inject load() function for accessing extra context. - def load(name: str): + # Inject load_extra() function for accessing extra context. + def load_extra(name: str): if name not in extra: raise ValueError( f'Extra context "{name}" is not available. ' @@ -59,7 +59,7 @@ def load(name: str): ) return extra[name] - ctx['load'] = load + ctx['load_extra'] = load_extra text = self._render(ctx, debug=debug is not None) diff --git a/tests/roots/test-extra-context/conf.py b/tests/roots/test-extra-context/conf.py index f72850c..82b6e94 100644 --- a/tests/roots/test-extra-context/conf.py +++ b/tests/roots/test-extra-context/conf.py @@ -27,8 +27,8 @@ def current_context(self): def current_template(self): return Template( """ -{% set _parsing = load('custom_parsing') %} -{% set _global = load('custom_global') %} +{% set _parsing = load_extra('custom_parsing') %} +{% set _global = load_extra('custom_global') %} Parsing: {{ _parsing.custom_value }} Global: {{ _global.custom_value }} """, From ba64f2818c95a5a38c4fcf77a039aee63a1d90d4 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sun, 5 Apr 2026 15:34:36 +0800 Subject: [PATCH 8/8] chore: Some tweaks --- docs/api.rst | 10 +-- docs/tmpl.rst | 98 ++++++++++++++---------- src/sphinxnotes/render/extractx.py | 118 +++++++++++++---------------- src/sphinxnotes/render/pipeline.py | 16 ++-- src/sphinxnotes/render/render.py | 4 + 5 files changed, 127 insertions(+), 119 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index b4f07b6..2e7d12b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -83,22 +83,20 @@ See :doc:`tmpl` for built-in extra-context names such as ``doc`` and .. autofunction:: sphinxnotes.render.extra_context -.. autoclass:: sphinxnotes.render.ExtraContext - .. autoclass:: sphinxnotes.render.ParsingPhaseExtraContext - :members: generate + :members: phase, generate :undoc-members: .. autoclass:: sphinxnotes.render.ParsedPhaseExtraContext - :members: generate + :members: phase, generate :undoc-members: .. autoclass:: sphinxnotes.render.ResolvingPhaseExtraContext - :members: generate + :members: phase, generate :undoc-members: .. autoclass:: sphinxnotes.render.GlobalExtraContext - :members: generate + :members: phase, generate :undoc-members: Base Roles and Directives diff --git a/docs/tmpl.rst b/docs/tmpl.rst index c25da6e..0734eef 100644 --- a/docs/tmpl.rst +++ b/docs/tmpl.rst @@ -154,38 +154,47 @@ Extra Context ------------- Templates can access additional context through **extra context**. Extra context -must be explicitly declared using the ``:extra:`` option and loaded in the +must be explicitly declared using the :rst:dir:`templat:extra` option and loaded in the template using the ``load_extra()`` function. -Built-in extra context -...................... - -.. list-table:: - :header-rows: 1 - - * - Name - - Available in Pahses - - Description - * - ``sphinx`` - - all - - A proxy to the Sphinx application object. - * - ``docutils`` - - all - - A mapping that exposes registered docutils directives and roles. - * - ``markup`` - - :term:`parsing` and later - - Information about the current directive or role invocation, such as its - type, name, source text, and line number. - * - ``section`` - - :term:`parsing` and later - - A proxy to the current section node, when one exists. - * - ``doc`` - - :term:`parsing` and later - - A proxy to the current document node. - -These values are wrapped for safer template access. In practice this means -templates can read public, non-callable attributes, but should not rely on -arbitrary Python object behavior. +Built-in Extra Contexts +~~~~~~~~~~~~~~~~~~~~~~~ + +``sphinx`` +.......... + +:Phase: all + +A proxy to the :py:class:`sphinx.application.Sphinx` object. + +``env`` +....... + +:Phase: all + +A proxy to the :py:class:`sphinx.environment.BuildEnvironment` object. + +``markup`` +.......... + +:Phase: :term:`parsing` and later + +Information about the current directive or role invocation, such as its +type, name, source text, and line number. + +``section`` +............ + +:Phase: :term:`parsing` and later + +A proxy to the current :py:class:`docutils.nodes.section` node, when one exists. + +``doc`` +....... + +:Phase: :term:`parsing` and later + +A proxy to the current :py:class:`docutils.notes.document` node. .. example:: :style: grid @@ -193,7 +202,8 @@ arbitrary Python object behavior. .. data.render:: :extra: doc - Current document title is + + Title of current document is "{{ load_extra('doc').title }}". Extending extra context @@ -237,16 +247,16 @@ Each :py:class:`~sphinxnotes.render.Template` has a render phase controlled by .. data.render:: :on: parsing - :extra: doc sphinx + :extra: doc env {% set doc = load_extra('doc') %} - {% set sphinx = load_extra('sphinx') %} + {% set env = load_extra('env') %} - The current document has {{ doc.sections | length }} section(s). - The current project has - {{ sphinx.env.all_docs | length }} + {{ env.all_docs | length }} document(s). ``parsed`` @@ -261,16 +271,16 @@ Each :py:class:`~sphinxnotes.render.Template` has a render phase controlled by .. data.render:: :on: parsed - :extra: doc sphinx + :extra: doc env {% set doc = load_extra('doc') %} - {% set sphinx = load_extra('sphinx') %} + {% set env = load_extra('env') %} - The current document has {{ doc.sections | length }} section(s). - The current project has - {{ sphinx.env.all_docs | length }} + {{ env.all_docs | length }} document(s). ``resolving`` @@ -278,7 +288,7 @@ Each :py:class:`~sphinxnotes.render.Template` has a render phase controlled by Render late in the build, after references and other transforms are being resolved. - Choose this when the template depends on project-wide state or on document + Choose this when the template depends on pr structure that is only stable near the end of the pipeline. .. example:: @@ -286,16 +296,16 @@ Each :py:class:`~sphinxnotes.render.Template` has a render phase controlled by .. data.render:: :on: resolving - :extra: doc sphinx + :extra: doc env {% set doc = load_extra('doc') %} - {% set sphinx = load_extra('sphinx') %} + {% set env = load_extra('env') %} - The current document has {{ doc.sections | length }} section(s). - The current project has - {{ sphinx.env.all_docs | length }} + {{ env.all_docs | length }} document(s). Debugging @@ -343,3 +353,9 @@ This pattern is often the most convenient way to build small, declarative directives. For more control, subclass :py:class:`~sphinxnotes.render.BaseDataDefineDirective` directly and implement ``current_schema()`` and ``current_template()`` yourself. + .. data.render:: + :extra: doc + + + Title of current document is + "{{ load_extra('doc').title }}". diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index 9791850..5bf57c3 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -1,11 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, override +from typing import TYPE_CHECKING, ClassVar, override from abc import ABC, abstractmethod from sphinx.util.docutils import SphinxDirective, SphinxRole from sphinx.transforms import SphinxTransform -from .render import HostWrapper +from .render import HostWrapper, Phase from .ctxnodes import pending_node from .utils import find_current_section, Report, Reporter @@ -14,56 +14,60 @@ from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment - # ============================ # ExtraContext ABC definitions # ============================ -type ExtraContext = ( - ParsingPhaseExtraContext - | ParsingPhaseExtraContext - | ResolvingPhaseExtraContext - | GlobalExtraContext -) -"""Type alias of all available extra contexts.""" +class _ExtraContext(ABC): + """Base class of extra context.""" -class ParsingPhaseExtraContext(ABC): - """Extra context generated during the :py:data:`~Phase.Parsing` phase. + 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(ABC): +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(ABC): +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: ... -class GlobalExtraContext(ABC): +class GlobalExtraContext(_ExtraContext): """Extra context available in all phases. - The ``generate`` method receives the Sphinx build environment. """ + phase = None + @abstractmethod def generate(self, env: BuildEnvironment) -> Any: ... @@ -74,42 +78,34 @@ def generate(self, env: BuildEnvironment) -> Any: ... class _ExtraContextRegistry: - ctxs: dict[str, ExtraContext] + ctxs: dict[str, _ExtraContext] def __init__(self) -> None: self.ctxs = {} - def is_extra_context(self, ctx: Any) -> bool: - return isinstance( - ctx, - ( - ParsingPhaseExtraContext, - ParsingPhaseExtraContext, - ResolvingPhaseExtraContext, - GlobalExtraContext, - ), - ) - - 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') - if not self.is_extra_context(ctx): - raise TypeError( - f'Invalid extra context instance "{name}", Expecting {ExtraContext}' - ) 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] - def get_names_by_phase(self, cls: type) -> set[str]: - return {name for name, ctx in self.ctxs.items() if isinstance(ctx, cls)} - 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() @@ -133,8 +129,8 @@ def extra_context(name: str): @extra_context('doc') class DocExtraContext(ParsingPhaseExtraContext): - def generate(self, directive): - return proxy(HostWrapper(directive).doctree) + def generate(self, ctx): + return proxy(HostWrapper(ctx).doctree) :param name: The context name, used in templates via ``load_extra('name')``. """ @@ -156,6 +152,8 @@ class ExtraContextGenerator: todo: set[str] report: Report + env: ClassVar[BuildEnvironment] + def __init__(self, node: pending_node) -> None: self.node = node self.report = Report( @@ -167,13 +165,19 @@ def __init__(self, node: pending_node) -> None: 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) - avail = _REGISTRY.get_names() self.todo = requested & avail # Report errors for non-existent contexts - if nonexist := requested - avail: - self.report.text(f'Extra contexts {nonexist} are not registered.') + 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 pahse {node.template.phase}.' + ) def on_anytime(self, env: BuildEnvironment) -> None: self._generate(GlobalExtraContext, lambda ctx: ctx.generate(env)) @@ -187,9 +191,9 @@ def on_parsed(self, transform: SphinxTransform) -> None: def on_resolving(self, transform: SphinxTransform) -> None: self._generate(ResolvingPhaseExtraContext, lambda ctx: ctx.generate(transform)) - def _generate(self, cls: type, gen: Callable[..., Any]) -> None: + def _generate(self, cls: type[_ExtraContext], gen: Callable[..., Any]) -> None: # Get all context names available for this phase - avail = _REGISTRY.get_names_by_phase(cls) + avail = _REGISTRY.get_names_at_phase(cls.phase) # Find which ones are requested and not yet generated todo = avail & self.todo @@ -243,14 +247,12 @@ def generate(self, directive: SphinxDirective | SphinxRole) -> Any: @extra_context('sphinx') -class SphinxExtraContext(GlobalExtraContext): - app: Sphinx - +class SphinxAppExtraContext(GlobalExtraContext): @override def generate(self, env: BuildEnvironment) -> Any: from .utils.ctxproxy import proxy - return proxy(self.app) + return proxy(env.app) @extra_context('env') @@ -262,18 +264,4 @@ def generate(self, env: BuildEnvironment) -> Any: return proxy(env) -@extra_context('docutils') -class DocutilsExtraContext(GlobalExtraContext): - @override - def generate(self, env: BuildEnvironment) -> Any: - from docutils.parsers.rst.directives import _directives - from docutils.parsers.rst.roles import _roles - - return { - 'directives': _directives, - 'roles': _roles, - } - - -def setup(app: Sphinx): - SphinxExtraContext.app = app +def setup(app: Sphinx): ... diff --git a/src/sphinxnotes/render/pipeline.py b/src/sphinxnotes/render/pipeline.py index 6081557..db5d131 100644 --- a/src/sphinxnotes/render/pipeline.py +++ b/src/sphinxnotes/render/pipeline.py @@ -91,7 +91,7 @@ def queue_context( return pending @final - def render_queue(self, app: Sphinx) -> list[pending_node]: + def render_queue(self) -> list[pending_node]: """ Try rendering all pending nodes in queue. @@ -121,10 +121,12 @@ def render_queue(self, app: Sphinx) -> list[pending_node]: ns.append(pending) continue + host = cast(Host, self) + # Generate global extra context for later use. - ExtraContextGenerator(pending).on_anytime(app.env) + ExtraContextGenerator(pending).on_anytime(host.env) - host = cast(Host, self) + # Perform render. pending.render(host) if pending.parent is None: @@ -205,7 +207,7 @@ def run(self) -> list[nodes.Node]: self.queue_context(self.current_context(), self.current_template()) ns = [] - for x in self.render_queue(self.env.app): + for x in self.render_queue(): if not x.rendered: ns.append(x) continue @@ -233,7 +235,7 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: pending.inline = True ns, msgs = [], [] - for n in self.render_queue(self.env.app): + for n in self.render_queue(): if not n.rendered: ns.append(n) continue @@ -258,7 +260,7 @@ def apply(self, **kwargs): for pending in self.document.findall(pending_node): self.queue_pending_node(pending) - for n in self.render_queue(self.app): + for n in self.render_queue(): ... @@ -275,7 +277,7 @@ def process_pending_node(self, n: pending_node) -> bool: def apply(self, **kwargs): for pending in self.document.findall(pending_node): self.queue_pending_node(pending) - ns = self.render_queue(self.app) + ns = self.render_queue() # NOTE: Should no node left. assert len(ns) == 0 diff --git a/src/sphinxnotes/render/render.py b/src/sphinxnotes/render/render.py index 7ad14f5..57d696c 100644 --- a/src/sphinxnotes/render/render.py +++ b/src/sphinxnotes/render/render.py @@ -25,6 +25,10 @@ class Phase(Enum): def default(cls) -> Phase: return cls.Parsing + def __ge__(self, other: Phase) -> bool: + _ORDER = {Phase.Parsing: 1, Phase.Parsed: 2, Phase.Resolving: 3} + return _ORDER[self] >= _ORDER[other] + @dataclass class Template: