From 8a8be35f83ac2353cadcbf013a0de777e91ebab6 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Wed, 17 Jun 2026 13:53:59 +0800 Subject: [PATCH] refactor: Clean up registry APIs - Expose ExtraContextRegistry with add() method, remove internal get/get_names - Add JinjaRegistry with add_filter() and add_extension() methods - Update Registry class with extra_context and jinja_env properties - Move default Jinja extensions to setup() for proper initialization - Update documentation and tests Co-authored-by: MiMoCode Co-authored-by: DeepSeek --- docs/api.rst | 20 ++++-- src/sphinxnotes/render/__init__.py | 19 +++++- src/sphinxnotes/render/ctxnodes.py | 39 ++++++----- src/sphinxnotes/render/data.py | 6 +- src/sphinxnotes/render/extractx.py | 41 +++++++----- src/sphinxnotes/render/jinja.py | 67 ++++++++++++++----- src/sphinxnotes/render/markup.py | 10 ++- tests/roots/test-extra-context-params/conf.py | 1 + tests/test_e2e.py | 1 - tests/test_extractx.py | 10 +-- 10 files changed, 146 insertions(+), 68 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 2c2b8dd..38fdecf 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -29,7 +29,7 @@ Base Directive Classes .. autoclass:: sphinxnotes.render.BaseContextDirective :show-inheritance: - :members: process_pending_node, queue_pending_node, current_raw_data, current_context, current_template + :members: process_pending_node, queue_pending_node, current_context, current_template .. autoclass:: sphinxnotes.render.BaseDataDefineDirective :show-inheritance: @@ -104,11 +104,21 @@ See :doc:`tmpl` for built-in extra-context names such as ``doc`` and :members: :undoc-members: +.. autoclass:: sphinxnotes.render.ExtraContextRegistry + :members: + :inherited-members: + :undoc-members: + :class-doc-from: both + Filters ======= .. autodecorator:: sphinxnotes.render.filter +.. autoclass:: sphinxnotes.render.JinjaRegistry + :members: + :undoc-members: + Data, Field and Schema ====================== @@ -131,11 +141,9 @@ Data, Field and Schema :members: name, attrs, content :undoc-members: -.. autoclass:: sphinxnotes.render.data.Registry +.. autoclass:: sphinxnotes.render.DataRegistry :members: - .. autotype:: sphinxnotes.render.data.ByOptionStore - Registry ======== @@ -148,3 +156,7 @@ or add new extra context) by adding new items to .. autoclass:: sphinxnotes.render.Registry .. autoproperty:: data + + .. autoproperty:: extra_context + + .. autoproperty:: jinja diff --git a/src/sphinxnotes/render/__init__.py b/src/sphinxnotes/render/__init__.py index 9ddb9c1..d26d892 100644 --- a/src/sphinxnotes/render/__init__.py +++ b/src/sphinxnotes/render/__init__.py @@ -11,7 +11,7 @@ from . import meta from .data import ( - Registry as DataRegistry, + DataRegistry, REGISTRY as DATA_REGISTRY, PlainValue, Value, @@ -28,6 +28,8 @@ extra_context, ExtraContext, ExtraContextRequest, + ExtraContextRegistry, + REGISTRY as EXTRA_CONTEXT_REGISTRY, ) from .pipeline import BaseContextRole, BaseContextDirective from .sources import ( @@ -36,7 +38,7 @@ BaseDataDefineDirective, StrictDataDefineDirective, ) -from .jinja import filter +from .jinja import filter, JinjaRegistry, REGISTRY as JINJA_REGISTRY if TYPE_CHECKING: from sphinx.application import Sphinx @@ -45,6 +47,7 @@ """Python API for other Sphinx extensions.""" __all__ = [ 'Registry', + 'DataRegistry', 'PlainValue', 'Value', 'ValueWrapper', @@ -58,6 +61,8 @@ 'ResolvedContext', 'ExtraContext', 'ExtraContextRequest', + 'ExtraContextRegistry', + 'EXTRA_CONTEXT_REGISTRY', 'extra_context', 'pending_node', 'BaseContextRole', @@ -67,6 +72,8 @@ 'BaseDataDefineDirective', 'StrictDataDefineDirective', 'filter', + 'JinjaRegistry', + 'JINJA_REGISTRY', ] @@ -77,6 +84,14 @@ class Registry: def data(self) -> DataRegistry: return DATA_REGISTRY + @property + def extra_context(self) -> ExtraContextRegistry: + return EXTRA_CONTEXT_REGISTRY + + @property + def jinja(self) -> JinjaRegistry: + return JINJA_REGISTRY + REGISTRY = Registry() diff --git a/src/sphinxnotes/render/ctxnodes.py b/src/sphinxnotes/render/ctxnodes.py index a3739f0..c879eb1 100644 --- a/src/sphinxnotes/render/ctxnodes.py +++ b/src/sphinxnotes/render/ctxnodes.py @@ -29,7 +29,7 @@ class pending_node(nodes.Element): """A docutils node to be rendered.""" - # The context to be rendered by Jinja template. + #: The context to be rendered by Jinja template. ctx: UnresolvedContext | ResolvedContext #: Jinja template for rendering the context. template: Template @@ -37,9 +37,22 @@ class pending_node(nodes.Element): inline: bool #: Whether the rendering pipeline is finished (failed is also finished). rendered: bool - #: Stored pickling error for later-phase unresolved context. + + # Stored pickling error for later-phase unresolved context. _ctx_pickle_error: Exception | None + # Types for hook functions. + type UnresolvedContextHook = Callable[[pending_node, UnresolvedContext], None] + type ResolvedContextHook = Callable[[pending_node, ResolvedContext], None] + type MarkupTextHook = Callable[[pending_node, str], str] + type RenderedNodesHook = Callable[[pending_node, list[nodes.Node]], None] + + # Hooks for processing render intermediate products. + _unresolved_context_hooks: list[UnresolvedContextHook] + _resolved_context_hooks: list[ResolvedContextHook] + _markup_text_hooks: list[MarkupTextHook] + _rendered_nodes_hooks: list[RenderedNodesHook] + def __init__( self, ctx: UnresolvedContext | ResolvedContext, @@ -50,16 +63,18 @@ def __init__( **attributes, ) -> None: super().__init__(rawsource, *children, **attributes) + self.ctx = ctx + self.template = tmpl + self.inline = inline + self.rendered = False + + # Test whehter ctx pickle-able. self._ctx_pickle_error = None if isinstance(ctx, UnresolvedContext) and tmpl.phase != Phase.Parsing: try: pickle.dumps(ctx) except Exception as exc: self._ctx_pickle_error = exc - self.ctx = ctx - self.template = tmpl - self.inline = inline - self.rendered = False # Init hook lists. self._unresolved_context_hooks = [] @@ -239,18 +254,6 @@ def unwrap_and_replace_self_inline(self, inliner: Report.Inliner) -> None: # Replace self with inline nodes. self.replace_self(ns) - """Hooks for processing render intermediate products.""" - - type UnresolvedContextHook = Callable[[pending_node, UnresolvedContext], None] - type ResolvedContextHook = Callable[[pending_node, ResolvedContext], None] - type MarkupTextHook = Callable[[pending_node, str], str] - type RenderedNodesHook = Callable[[pending_node, list[nodes.Node]], None] - - _unresolved_context_hooks: list[UnresolvedContextHook] - _resolved_context_hooks: list[ResolvedContextHook] - _markup_text_hooks: list[MarkupTextHook] - _rendered_nodes_hooks: list[RenderedNodesHook] - def hook_unresolved_context(self, hook: UnresolvedContextHook) -> None: self._unresolved_context_hooks.append(hook) diff --git a/src/sphinxnotes/render/data.py b/src/sphinxnotes/render/data.py index 0897fec..17f0d30 100644 --- a/src/sphinxnotes/render/data.py +++ b/src/sphinxnotes/render/data.py @@ -106,7 +106,7 @@ def _str_conv(v: str) -> str: return vv if isinstance(vv, str) else v -class Registry: +class DataRegistry: """Stores supported element types and element forms (containers).""" etypes: dict[str, type] @@ -227,7 +227,7 @@ def add_by_option( in the DSL :param etype: The value type for this option :param default: The default value for this option - :param store: How to store multiple values + :param store: How to store multiple values, can be ``'assign'`` or ``'append'`` :param aliases: Alternative names for this option .. seealso:: :ref:`add-custom-by-options` @@ -239,7 +239,7 @@ def add_by_option( self.byopts[alias] = opt -REGISTRY = Registry() +REGISTRY = DataRegistry() # ====================== # Data, Field and Schema diff --git a/src/sphinxnotes/render/extractx.py b/src/sphinxnotes/render/extractx.py index febaaa1..d2f2785 100644 --- a/src/sphinxnotes/render/extractx.py +++ b/src/sphinxnotes/render/extractx.py @@ -41,27 +41,34 @@ def generate(self, req: ExtraContextRequest, *args, **kwargs) -> Any: ... # ========================== -class _ExtraContextRegistry: - ctxs: dict[str, ExtraContext] +class ExtraContextRegistry: + """Registry for extra contexts.""" + + _ctxs: dict[str, ExtraContext] def __init__(self) -> None: - self.ctxs = {} + self._ctxs = {} - 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 add(self, name: str, ctx: ExtraContext) -> None: + """Register an extra context. - def get(self, name: str) -> ExtraContext | None: - if name not in self.ctxs: - return None - return self.ctxs[name] + :param name: The context name, used in templates via ``load_extra('name')`` + :param ctx: An :py:class:`ExtraContext` instance + + .. note:: Using the :py:deco:`extra_context` decorator is recommended for most cases. + """ + if name in self._ctxs: + raise ValueError(f'Extra context "{name}" already registered') + self._ctxs[name] = ctx - def get_names(self) -> set[str]: - return set(self.ctxs.keys()) +REGISTRY = ExtraContextRegistry() +"""The global registry for extra contexts. -_REGISTRY = _ExtraContextRegistry() +This is the underlying registry used by the :py:func:`extra_context` decorator. +Using the decorator is recommended for most cases, but you can also register +extra contexts directly via this registry. +""" def extra_context(name: str): @@ -71,19 +78,19 @@ def extra_context(name: str): """ def decorator(cls): - _REGISTRY.register(name, cls()) + REGISTRY.add(name, cls()) return cls return decorator def extra_context_names() -> set[str]: - return _REGISTRY.get_names() + return set(REGISTRY._ctxs.keys()) def extra_context_loader(request: ExtraContextRequest): def load_extra(name: str, *args, **kwargs) -> Any: - ctx = _REGISTRY.get(name) + ctx = REGISTRY._ctxs.get(name) if ctx is None: raise ValueError( f'Extra context "{name}" is not registered. ' diff --git a/src/sphinxnotes/render/jinja.py b/src/sphinxnotes/render/jinja.py index f5991cd..f3aec4d 100644 --- a/src/sphinxnotes/render/jinja.py +++ b/src/sphinxnotes/render/jinja.py @@ -16,7 +16,6 @@ from jinja2 import StrictUndefined, DebugUndefined from .data import ParsedData -from .utils import Report if TYPE_CHECKING: from typing import Any @@ -25,6 +24,52 @@ from .ctx import ResolvedContext +class JinjaRegistry: + """Registry for customizing the Jinja2 environment. + + Provides methods to add custom filters and extensions to the Jinja2 + rendering environment used by this extension. + """ + + _filters: dict[str, Callable[[BuildEnvironment], Callable]] + _extensions: list[str] + + def __init__(self) -> None: + self._filters = {} + self._extensions = [] + + def add_filter( + self, name: str, factory: Callable[[BuildEnvironment], Callable] + ) -> None: + """Register a filter factory. + + :param name: The filter name, used in Jinja templates as ``{{ value|name }}`` + :param factory: A callable that takes a :py:class:`~sphinx.environment.BuildEnvironment` + and returns a filter callable + + .. note:: Using the :py:deco:`filter` decorator is recommended for most cases. + + """ + if name in self._filters: + raise ValueError(f'Jinja filter "{name}" already registered') + self._filters[name] = factory + + def add_extension(self, extension: str) -> None: + """Add a Jinja2 extension. + + See `Jinja2 Extensions `_ + for available builtin extensions. + + :param extension: The extension module path, e.g. ``'jinja2.ext.i18n'`` + """ + if extension not in self._extensions: + self._extensions.append(extension) + + +REGISTRY = JinjaRegistry() +"""The global registry for Jinja2 filter factories.""" + + @dataclass class TemplateRenderer: text: str @@ -48,10 +93,7 @@ def render( return self._render(ctx, debug=debug) def _render(self, ctx: dict[str, Any], debug: bool = False) -> str: - extensions = [ - 'jinja2.ext.loopcontrols', # enable {% break %}, {% continue %} - 'jinja2.ext.do', # enable {% do ... %} - ] + extensions = list(REGISTRY._extensions) if debug: extensions.append('jinja2.ext.debug') @@ -63,27 +105,19 @@ def _render(self, ctx: dict[str, Any], debug: bool = False) -> str: return env.from_string(self.text).render(ctx) - def _report_self(self, reporter: Report) -> None: - reporter.code(self.text, lang='jinja', caption='Template:') - class _JinjaEnv(SandboxedEnvironment): _env: ClassVar[BuildEnvironment] - _filter_factories: ClassVar[dict[str, Callable[[BuildEnvironment], Callable]]] = {} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - for name, factory in self._filter_factories.items(): + for name, factory in REGISTRY._filters.items(): self.filters[name] = factory(self._env) @classmethod def on_builder_inited(cls, app: Sphinx): cls._env = app.env - @classmethod - def add_filter(cls, name: str, factory: Callable[[BuildEnvironment], Callable]): - cls._filter_factories[name] = factory - @override def is_safe_attribute(self, obj, attr, value=None): """ @@ -111,7 +145,7 @@ def _filter(value): """ def decorator(ff): - _JinjaEnv.add_filter(name, ff) + REGISTRY.add_filter(name, ff) return ff return decorator @@ -119,3 +153,6 @@ def decorator(ff): def setup(app: Sphinx): app.connect('builder-inited', _JinjaEnv.on_builder_inited) + + REGISTRY.add_extension('jinja2.ext.loopcontrols') + REGISTRY.add_extension('jinja2.ext.do') diff --git a/src/sphinxnotes/render/markup.py b/src/sphinxnotes/render/markup.py index b2a4608..f3de058 100644 --- a/src/sphinxnotes/render/markup.py +++ b/src/sphinxnotes/render/markup.py @@ -55,7 +55,10 @@ def _render(self, text: str) -> list[Node]: parser = self.host.app.registry.create_source_parser( self.host.app, 'rst' ) - doc = new_document(self.host.env.docname, settings=self._get_settings(parser, self.host.document)) + doc = new_document( + self.host.env.docname, + settings=self._get_settings(parser, self.host.document), + ) parser.parse(text, doc) # NOTE: Nodes produced by standalone source parser should be fixed @@ -128,8 +131,9 @@ def _get_settings(self, parser: SphinxParser, doctree: nodes.document) -> Values if version_info[0] >= 9: try: from sphinx.util.docutils import _get_settings - settings = _get_settings(parser, - defaults=self.host.env.settings, read_config_files=True + + settings = _get_settings( + parser, defaults=self.host.env.settings, read_config_files=True ) except Exception as e: logger.warning( diff --git a/tests/roots/test-extra-context-params/conf.py b/tests/roots/test-extra-context-params/conf.py index 1b20dc0..3380b80 100644 --- a/tests/roots/test-extra-context-params/conf.py +++ b/tests/roots/test-extra-context-params/conf.py @@ -19,4 +19,5 @@ def generate(self, req: ExtraContextRequest, *args, **kwargs): keep_warnings = True + def setup(app): ... diff --git a/tests/test_e2e.py b/tests/test_e2e.py index f40ec26..6ae9724 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -68,7 +68,6 @@ def test_extra_context_params(app, status, warning): assert '['index']' in html - @pytest.mark.sphinx('html', testroot='extra-context-rebuild') def test_extra_context_rebuild(app, status, warning): app.build() diff --git a/tests/test_extractx.py b/tests/test_extractx.py index fcc2cf9..c01e3dd 100644 --- a/tests/test_extractx.py +++ b/tests/test_extractx.py @@ -3,7 +3,7 @@ from sphinxnotes.render.extractx import ( ExtraContext, ExtraContextRequest, - _REGISTRY, + REGISTRY, extra_context_loader, ) from sphinxnotes.render.template import Template @@ -22,7 +22,7 @@ def generate(self, req, *args, **kwargs): def test_extra_context_loader_does_not_cache_values(): name = 'test_no_cache' ctx = CountingExtraContext() - _REGISTRY.register(name, ctx) + REGISTRY.add(name, ctx) try: node = pending_node({}, Template('')) @@ -33,7 +33,7 @@ def test_extra_context_loader_does_not_cache_values(): assert load_extra(name) == 1 assert load_extra(name) == 2 finally: - _REGISTRY.ctxs.pop(name, None) + REGISTRY._ctxs.pop(name, None) class ParamExtraContext(ExtraContext): @@ -43,7 +43,7 @@ def generate(self, req, *args, **kwargs): def test_extra_context_loader_passes_parameters(): name = 'test_params' - _REGISTRY.register(name, ParamExtraContext()) + REGISTRY.add(name, ParamExtraContext()) try: node = pending_node({}, Template('')) @@ -54,4 +54,4 @@ def test_extra_context_loader_passes_parameters(): result = load_extra(name, 10, limit=20) assert result == {'args': (10,), 'kwargs': {'limit': 20}} finally: - _REGISTRY.ctxs.pop(name, None) + REGISTRY._ctxs.pop(name, None)