From 72befab74aed610b7dd3c9f2700c05317bf44c26 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Mon, 9 Sep 2024 14:58:40 +0800 Subject: [PATCH 1/8] refactor: Split a sphinxnotes.jinja extension --- src/sphinxnotes/jinja/__init__.py | 9 +++++++++ src/sphinxnotes/jinja/context.py | 9 +++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/sphinxnotes/jinja/__init__.py create mode 100644 src/sphinxnotes/jinja/context.py diff --git a/src/sphinxnotes/jinja/__init__.py b/src/sphinxnotes/jinja/__init__.py new file mode 100644 index 0000000..734bf32 --- /dev/null +++ b/src/sphinxnotes/jinja/__init__.py @@ -0,0 +1,9 @@ +""" +sphinxnotes.jinja +~~~~~~~~~~~~~~~~~ + +Sphinx extension entrypoint of sphinxnotes-jinja. + +:copyright: Copyright 2024 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" diff --git a/src/sphinxnotes/jinja/context.py b/src/sphinxnotes/jinja/context.py new file mode 100644 index 0000000..57b0bf6 --- /dev/null +++ b/src/sphinxnotes/jinja/context.py @@ -0,0 +1,9 @@ +""" +sphinxnotes.jinja.rstctx +~~~~~~~~~~~~~~~~~~~~~~~~ + +Sphinx extension entrypoint of sphinxnotes-jinja. + +:copyright: Copyright 2024 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" From 2fbd69b94af8b0bc3e5e50b7b2d8feff46796fe3 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Fri, 13 Sep 2024 00:20:39 +0800 Subject: [PATCH 2/8] update --- src/sphinxnotes/any/objects.py | 27 --- src/sphinxnotes/jinja/context.py | 277 ++++++++++++++++++++++++++++++- 2 files changed, 274 insertions(+), 30 deletions(-) diff --git a/src/sphinxnotes/any/objects.py b/src/sphinxnotes/any/objects.py index b499921..e2224ba 100644 --- a/src/sphinxnotes/any/objects.py +++ b/src/sphinxnotes/any/objects.py @@ -57,33 +57,6 @@ def as_str(self) -> str: return str(self._v) -class Form(ABC): - @abstractmethod - def extract(self, raw: str) -> Value: - """Extract :class:`Value` from field's raw value.""" - raise NotImplementedError - - -class Single(Form): - def __init__(self, strip=False): - self.strip = strip - - def extract(self, raw: str) -> Value: - return Value(raw.strip() if self.strip else raw) - - -class List(Form): - def __init__(self, sep: str, strip=False, max=-1): - self.strip = strip - self.sep = sep - self.max = max - - def extract(self, raw: str) -> Value: - strv = raw.split(self.sep, maxsplit=self.max) - if self.strip: - strv = [x.strip() for x in strv] - return Value(strv) - @dataclasses.dataclass class Category(object): diff --git a/src/sphinxnotes/jinja/context.py b/src/sphinxnotes/jinja/context.py index 57b0bf6..9c85c90 100644 --- a/src/sphinxnotes/jinja/context.py +++ b/src/sphinxnotes/jinja/context.py @@ -1,9 +1,280 @@ """ -sphinxnotes.jinja.rstctx -~~~~~~~~~~~~~~~~~~~~~~~~ +sphinxnotes.jinja.context +~~~~~~~~~~~~~~~~~~~~~~~~~ -Sphinx extension entrypoint of sphinxnotes-jinja. +Build :cls:`jinja2.runtime.Context` from reStructuredText. + +context: + +- env: Sphinx env +- doc: Current documentation + doc.docname + doc.section.title +- node: next, prev +- self $.name :copyright: Copyright 2024 Shengyu Zhang :license: BSD, see LICENSE for details. """ + +from __future__ import annotations +from typing import Type +from abc import ABC, abstractmethod + +from docutils import nodes +from docutils.nodes import Node, Element, fully_normalize_name +from docutils.statemachine import StringList +from docutils.parsers.rst import directives +from sphinx import addnodes +from sphinx.util.docutils import SphinxDirective, SphinxRole +from sphinx.util.nodes import make_id, nested_parse_with_titles +from sphinx.util import logging + +logger = logging.getLogger(__name__) + +class Value(object): + """Immutable optional string value of :cls:`Field`.""" + + type T = None | str | list[str] + _v: T + + def __init__(self, v: T): + self._v = v # TODO: type checking + + @property + def value(self) -> T: + return self._v + + def as_list(self) -> list[str]: + if isinstance(self._v, str): + return [self._v] + elif isinstance(self._v, list): + return self._v + else: + return [] + + def as_str(self) -> str: + return str(self._v) + +class Form(ABC): + @abstractmethod + def extract(self, raw: str) -> Value: + """Extract :class:`Value` from field's raw value.""" + raise NotImplementedError + + +class Single(Form): + def __init__(self, strip=False): + self.strip = strip + + def extract(self, raw: str) -> Value: + return Value(raw.strip() if self.strip else raw) + + +class List(Form): + def __init__(self, sep: str, strip=False, max=-1): + self.strip = strip + self.sep = sep + self.max = max + + def extract(self, raw: str) -> Value: + strv = raw.split(self.sep, maxsplit=self.max) + if self.strip: + strv = [x.strip() for x in strv] + return Value(strv) + + +class Field(object): + """ + Describes value constraint of field of Object. + + The value of field can be single or mulitple string. + + :param form: The form of value. + :param required: Whether the field is required. + If ture, :py:exc:`ObjectError` will be raised when building documentation + if the value is no given. + """ + + form: Form = Forms.PLAIN + +class RstContext(object): + pass + +class DocContext(object): + pass + +class EnvContext(object): + pass + +class ContextDirective(SphinxDirective): + """ + Directive to describe anything. Not used directly, + but dynamically subclassed to describe specific object. + + The class is modified from sphinx.directives.ObjectDescription + """ + + schema: Schema + + # Member of parent + has_content: bool = True + required_arguments: int = 0 + optional_arguments: int = 0 + final_argument_whitespace: bool = True + option_spec: dict[str, callable] = {} + + @classmethod + def derive(cls, schema: Schema) -> Type['ContextDirective']: + """Generate an AnyDirective child class for describing object.""" + has_content = schema.content is not None + + if not schema.name: + required_arguments = 0 + optional_arguments = 0 + elif schema.name.required: + required_arguments = 1 + optional_arguments = 0 + else: + required_arguments = 0 + optional_arguments = 1 + + option_spec = {} + for name, field in schema.attrs.items(): + if field.required: + option_spec[name] = directives.unchanged_required + else: + option_spec[name] = directives.unchanged + + # Generate directive class + return type( + 'Any%sDirective' % schema.objtype.title(), + (ContextDirective,), + { + 'schema': schema, + 'has_content': has_content, + 'required_arguments': required_arguments, + 'optional_arguments': optional_arguments, + 'option_spec': option_spec, + }, + ) + + def _build_object(self) -> Object: + """Build object information for template rendering.""" + return self.schema.object( + name=self.arguments[0] if self.arguments else None, + attrs=self.options, + # Convert docutils.statemachine.ViewList.data -> str + content='\n'.join(list(self.content.data)), + ) + + def _setup_nodes( + self, obj: Object, sectnode: Element, ahrnode: Element | None, contnode: Element + ) -> None: + """ + Attach necessary informations to nodes and note them. + + The necessary information contains: domain info, basic attributes for nodes + (ids, names, classes...), name of anchor, description content and so on. + + :param sectnode: Section node, used as container of the whole object description + :param ahrnode: Anchor node, used to mark the location of object description + :param contnode: Content node, which contains the description content + """ + domainname, objtype = self.name.split(':', 1) + domain = self.env.get_domain(domainname) + + # Attach domain related info to section node + sectnode['domain'] = domain.name + # 'desctype' is a backwards compatible attribute + sectnode['objtype'] = sectnode['desctype'] = objtype + sectnode['classes'].append(domain.name) + + # Setup anchor + if ahrnode is not None: + _, objid = self.schema.identifier_of(obj) + ahrid = make_id(self.env, self.state.document, prefix=objtype, term=objid) + ahrnode['ids'].append(ahrid) + # Add object name to node's names attribute. + # 'names' is space-separated list containing normalized reference + # names of an element. + name = self.schema.name_of(obj) + if isinstance(name, str): + ahrnode['names'].append(fully_normalize_name(name)) + elif isinstance(name, list): + ahrnode['names'].extend([fully_normalize_name(x) for x in name]) + self.state.document.note_explicit_target(ahrnode) + # Note object by docu fields + domain.note_object( + self.env.docname, ahrid, self.schema, obj + ) # FIXME: Cast to AnyDomain + + # Parse description + nested_parse_with_titles( + self.state, StringList(self.schema.render_description(obj)), contnode + ) + + def _run_section(self, obj: Object) -> list[Node]: + # Get the title of the "section" where the directive is located + sectnode = self.state.parent + titlenode = sectnode.next_node(nodes.title) + if not titlenode or titlenode.parent != sectnode: + # Title should be direct child of section + msg = 'Failed to get title of current section' + logger.warning(msg, location=sectnode) + sm = nodes.system_message( + msg, type='WARNING', level=2, backrefs=[], source='' + ) + sectnode += sm + title = '' + else: + title = titlenode.astext() + # Replace the first name "_" with section title + name = title + obj.name[1:] + # Object is immutable, so create a new one + obj = self.schema.object(name=name, attrs=obj.attrs, content=obj.content) + # NOTE: In _setup_nodes, the anchor node(ahrnode) will be noted by + # `note_explicit_target` for ahrnode, while `sectnode` is already noted + # by `note_implicit_target`. + # Multiple `note_xxx_target` calls to same node causes undefined behavior, + # so we use `titlenode` as anchor node + # + # See https://github.com/sphinx-notes/any/issues/18 + self._setup_nodes(obj, sectnode, titlenode, sectnode) + # Add all content to existed section, so return nothing + return [] + + def _run_objdesc(self, obj: Object) -> list[Node]: + descnode = addnodes.desc() + + # Generate signature node + title = self.schema.title_of(obj) + if title is None: + # Use non-generated object ID as replacement of title + idfield, objid = self.schema.identifier_of(obj) + title = objid if idfield is not None else None + if title is not None: + signode = addnodes.desc_signature(title, '') + signode += addnodes.desc_name(title, title) + descnode.append(signode) + else: + signode = None + + # Generate content node + contnode = addnodes.desc_content() + descnode.append(contnode) + self._setup_nodes(obj, descnode, signode, contnode) + return [descnode] + + def run(self) -> list[Node]: + obj = self._build_object() + if self.schema.title_of(obj) == '_': + # If no argument is given, or the first argument is '_', + # use the section title as object name and anchor, + # append content nodes to section node + return self._run_section(obj) + else: + # Else, create Sphinx ObjectDescription(sphinx.addnodes.dsec_*) + return self._run_objdesc(obj) + +class ContextRole(SphinxRole): From 06476d4f1bc7f31b0f6fe2a03405a6d34e827dc9 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sat, 21 Sep 2024 23:43:51 +0800 Subject: [PATCH 3/8] update --- docs/conf.py | 1 + docs/index.rst | 89 +--------- src/sphinxnotes/any/objects.py | 27 +++ src/sphinxnotes/jinja/__init__.py | 12 ++ src/sphinxnotes/jinja/context.py | 282 +++++++----------------------- src/sphinxnotes/jinja/env.py | 186 ++++++++++++++++++++ src/sphinxnotes/jinja/render.py | 22 +++ src/sphinxnotes/jinja/template.py | 24 +++ 8 files changed, 338 insertions(+), 305 deletions(-) create mode 100644 src/sphinxnotes/jinja/env.py create mode 100644 src/sphinxnotes/jinja/render.py create mode 100644 src/sphinxnotes/jinja/template.py diff --git a/docs/conf.py b/docs/conf.py index 3ec77e2..9323895 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -111,6 +111,7 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('../src/sphinxnotes')) extensions.append('any') +extensions.append('jinja') # # DOG FOOD CONFIGURATION START diff --git a/docs/index.rst b/docs/index.rst index 39637f3..7e50ee6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,92 +5,13 @@ sphinxnotes-any =============== -.. |docs| image:: https://img.shields.io/github/deployments/sphinx-notes/any/github-pages - :target: https://sphinx.silverrainz.me/any - :alt: Documentation Status +Role: -.. |license| image:: https://img.shields.io/github/license/sphinx-notes/any - :target: https://github.com/sphinx-notes/any/blob/master/LICENSE - :alt: Open Source License +:test:`Hey!` -.. |pypi| image:: https://img.shields.io/pypi/v/sphinxnotes-any.svg - :target: https://pypi.python.org/pypi/sphinxnotes-any - :alt: PyPI Package -.. |download| image:: https://img.shields.io/pypi/dm/sphinxnotes-any - :target: https://pypi.python.org/pypi/sphinxnotes-any - :alt: PyPI Package Downloads +Directive: -|docs| |license| |pypi| |download| - -Introduction -============ +.. test:: Hey! -.. INTRODUCTION START - -The extension provides a domain which allows user creates directive and roles -to descibe, reference and index arbitrary object in documentation. -It is a bit like :py:meth:`sphinx.application.Sphinx.add_object_type`, -but more powerful. - -.. INTRODUCTION END - -Getting Started -=============== - -.. note:: - - We assume you already have a Sphinx documentation, - if not, see `Getting Started with Sphinx`_. - -First, downloading extension from PyPI: - -.. code-block:: console - - $ pip install sphinxnotes-any - -Then, add the extension name to ``extensions`` configuration item in your -:parsed_literal:`conf.py_`: - -.. code-block:: python - - extensions = [ - # … - 'sphinxnotes.any', - # … - ] - -.. _Getting Started with Sphinx: https://www.sphinx-doc.org/en/master/usage/quickstart.html -.. _conf.py: https://www.sphinx-doc.org/en/master/usage/configuration.html - -.. ADDITIONAL CONTENT START - -See :doc:`usage` and :doc:`conf` for more details. - -.. ADDITIONAL CONTENT END - -Contents -======== - -.. toctree:: - :caption: Contents - - usage - conf - tips - changelog - -The Sphinx Notes Project -======================== - -The project is developed by `Shengyu Zhang`__, -as part of **The Sphinx Notes Project**. - -.. toctree:: - :caption: The Sphinx Notes Project - - Home - Blog - PyPI - -__ https://github.com/SilverRainZ + I am here. diff --git a/src/sphinxnotes/any/objects.py b/src/sphinxnotes/any/objects.py index e2224ba..b499921 100644 --- a/src/sphinxnotes/any/objects.py +++ b/src/sphinxnotes/any/objects.py @@ -57,6 +57,33 @@ def as_str(self) -> str: return str(self._v) +class Form(ABC): + @abstractmethod + def extract(self, raw: str) -> Value: + """Extract :class:`Value` from field's raw value.""" + raise NotImplementedError + + +class Single(Form): + def __init__(self, strip=False): + self.strip = strip + + def extract(self, raw: str) -> Value: + return Value(raw.strip() if self.strip else raw) + + +class List(Form): + def __init__(self, sep: str, strip=False, max=-1): + self.strip = strip + self.sep = sep + self.max = max + + def extract(self, raw: str) -> Value: + strv = raw.split(self.sep, maxsplit=self.max) + if self.strip: + strv = [x.strip() for x in strv] + return Value(strv) + @dataclasses.dataclass class Category(object): diff --git a/src/sphinxnotes/jinja/__init__.py b/src/sphinxnotes/jinja/__init__.py index 734bf32..f79bdb0 100644 --- a/src/sphinxnotes/jinja/__init__.py +++ b/src/sphinxnotes/jinja/__init__.py @@ -7,3 +7,15 @@ :copyright: Copyright 2024 Shengyu Zhang :license: BSD, see LICENSE for details. """ + +from importlib.metadata import version +from sphinx.application import Sphinx +from .context import ContextRole, ContextDirective + +def setup(app: Sphinx): + """Sphinx extension entrypoint.""" + + app.add_role('test', ContextRole.derive('test')) + app.add_directive('test', ContextDirective.derive('test', required_arguments=1, has_content=True)) + + return {'version': version('sphinxnotes.any')} diff --git a/src/sphinxnotes/jinja/context.py b/src/sphinxnotes/jinja/context.py index 9c85c90..9d32a59 100644 --- a/src/sphinxnotes/jinja/context.py +++ b/src/sphinxnotes/jinja/context.py @@ -18,85 +18,17 @@ """ from __future__ import annotations -from typing import Type -from abc import ABC, abstractmethod +from typing import Type, Callable, Any -from docutils import nodes -from docutils.nodes import Node, Element, fully_normalize_name -from docutils.statemachine import StringList -from docutils.parsers.rst import directives -from sphinx import addnodes +from docutils.nodes import Node, system_message, inline +from docutils.parsers.rst import directives, states from sphinx.util.docutils import SphinxDirective, SphinxRole -from sphinx.util.nodes import make_id, nested_parse_with_titles from sphinx.util import logging -logger = logging.getLogger(__name__) - -class Value(object): - """Immutable optional string value of :cls:`Field`.""" - - type T = None | str | list[str] - _v: T - - def __init__(self, v: T): - self._v = v # TODO: type checking - - @property - def value(self) -> T: - return self._v - - def as_list(self) -> list[str]: - if isinstance(self._v, str): - return [self._v] - elif isinstance(self._v, list): - return self._v - else: - return [] - - def as_str(self) -> str: - return str(self._v) - -class Form(ABC): - @abstractmethod - def extract(self, raw: str) -> Value: - """Extract :class:`Value` from field's raw value.""" - raise NotImplementedError - - -class Single(Form): - def __init__(self, strip=False): - self.strip = strip - - def extract(self, raw: str) -> Value: - return Value(raw.strip() if self.strip else raw) +import .template +logger = logging.getLogger(__name__) -class List(Form): - def __init__(self, sep: str, strip=False, max=-1): - self.strip = strip - self.sep = sep - self.max = max - - def extract(self, raw: str) -> Value: - strv = raw.split(self.sep, maxsplit=self.max) - if self.strip: - strv = [x.strip() for x in strv] - return Value(strv) - - -class Field(object): - """ - Describes value constraint of field of Object. - - The value of field can be single or mulitple string. - - :param form: The form of value. - :param required: Whether the field is required. - If ture, :py:exc:`ObjectError` will be raised when building documentation - if the value is no given. - """ - - form: Form = Forms.PLAIN class RstContext(object): pass @@ -107,174 +39,82 @@ class DocContext(object): class EnvContext(object): pass -class ContextDirective(SphinxDirective): - """ - Directive to describe anything. Not used directly, - but dynamically subclassed to describe specific object. - - The class is modified from sphinx.directives.ObjectDescription - """ - - schema: Schema - # Member of parent - has_content: bool = True - required_arguments: int = 0 - optional_arguments: int = 0 - final_argument_whitespace: bool = True - option_spec: dict[str, callable] = {} +class ContextDirective(SphinxDirective): + arguments_variable_name: str + content_variable_name: str @classmethod - def derive(cls, schema: Schema) -> Type['ContextDirective']: - """Generate an AnyDirective child class for describing object.""" - has_content = schema.content is not None - - if not schema.name: - required_arguments = 0 - optional_arguments = 0 - elif schema.name.required: - required_arguments = 1 - optional_arguments = 0 - else: - required_arguments = 0 - optional_arguments = 1 + def derive(cls, + directive_name: str, + required_arguments: int = 0, + optional_arguments: int = 0, + final_argument_whitespace: bool = False, + option_spec: list[str] | dict[str, Callable[[str], Any]] = {}, + has_content: bool = False, + arguments_variable_name: str = 'args', + content_variable_name: str = 'content') -> Type['ContextDirective']: + # Generate directive class - option_spec = {} - for name, field in schema.attrs.items(): - if field.required: - option_spec[name] = directives.unchanged_required - else: - option_spec[name] = directives.unchanged + # If no conversion function provided in option_spec, fallback to directive.unchanged. + if isinstance(option_spec, list): + option_spec = {k: directives.unchanged for k in option_spec} - # Generate directive class return type( - 'Any%sDirective' % schema.objtype.title(), + directive_name.title() + 'ContextDirective', (ContextDirective,), { - 'schema': schema, - 'has_content': has_content, + # Member of docutils.parsers.rst.Directive. 'required_arguments': required_arguments, 'optional_arguments': optional_arguments, + 'final_argument_whitespace': final_argument_whitespace, 'option_spec': option_spec, - }, - ) + 'has_content': has_content, - def _build_object(self) -> Object: - """Build object information for template rendering.""" - return self.schema.object( - name=self.arguments[0] if self.arguments else None, - attrs=self.options, - # Convert docutils.statemachine.ViewList.data -> str - content='\n'.join(list(self.content.data)), + 'arguments_variable_name': arguments_variable_name, + 'content_variable_name': content_variable_name, + }, ) - def _setup_nodes( - self, obj: Object, sectnode: Element, ahrnode: Element | None, contnode: Element - ) -> None: - """ - Attach necessary informations to nodes and note them. - - The necessary information contains: domain info, basic attributes for nodes - (ids, names, classes...), name of anchor, description content and so on. + def run(self) -> list[Node]: + ctx = {} + if self.required_arguments + self.optional_arguments != 0: + ctx[self.arguments_variable_name] = self.arguments + if self.has_content: + ctx[self.content_variable_name] = self.content + for key in self.option_spec or {}: + ctx[key] = self.options.get(key) + + text = template.render(type(self), ctx) - :param sectnode: Section node, used as container of the whole object description - :param ahrnode: Anchor node, used to mark the location of object description - :param contnode: Content node, which contains the description content - """ - domainname, objtype = self.name.split(':', 1) - domain = self.env.get_domain(domainname) + return self.parse_text_to_nodes(text, allow_section_headings=True) - # Attach domain related info to section node - sectnode['domain'] = domain.name - # 'desctype' is a backwards compatible attribute - sectnode['objtype'] = sectnode['desctype'] = objtype - sectnode['classes'].append(domain.name) - # Setup anchor - if ahrnode is not None: - _, objid = self.schema.identifier_of(obj) - ahrid = make_id(self.env, self.state.document, prefix=objtype, term=objid) - ahrnode['ids'].append(ahrid) - # Add object name to node's names attribute. - # 'names' is space-separated list containing normalized reference - # names of an element. - name = self.schema.name_of(obj) - if isinstance(name, str): - ahrnode['names'].append(fully_normalize_name(name)) - elif isinstance(name, list): - ahrnode['names'].extend([fully_normalize_name(x) for x in name]) - self.state.document.note_explicit_target(ahrnode) - # Note object by docu fields - domain.note_object( - self.env.docname, ahrid, self.schema, obj - ) # FIXME: Cast to AnyDomain +class ContextRole(SphinxRole): + text_variable_name: str - # Parse description - nested_parse_with_titles( - self.state, StringList(self.schema.render_description(obj)), contnode + @classmethod + def derive(cls, role_name: str, text_variable_name: str = 'text') -> Type['ContextRole']: + # Generate sphinx role class + return type( + role_name.title() + 'ContextDirective', + (ContextRole,), + { + 'text_variable_name': text_variable_name, + }, ) - def _run_section(self, obj: Object) -> list[Node]: - # Get the title of the "section" where the directive is located - sectnode = self.state.parent - titlenode = sectnode.next_node(nodes.title) - if not titlenode or titlenode.parent != sectnode: - # Title should be direct child of section - msg = 'Failed to get title of current section' - logger.warning(msg, location=sectnode) - sm = nodes.system_message( - msg, type='WARNING', level=2, backrefs=[], source='' - ) - sectnode += sm - title = '' - else: - title = titlenode.astext() - # Replace the first name "_" with section title - name = title + obj.name[1:] - # Object is immutable, so create a new one - obj = self.schema.object(name=name, attrs=obj.attrs, content=obj.content) - # NOTE: In _setup_nodes, the anchor node(ahrnode) will be noted by - # `note_explicit_target` for ahrnode, while `sectnode` is already noted - # by `note_implicit_target`. - # Multiple `note_xxx_target` calls to same node causes undefined behavior, - # so we use `titlenode` as anchor node - # - # See https://github.com/sphinx-notes/any/issues/18 - self._setup_nodes(obj, sectnode, titlenode, sectnode) - # Add all content to existed section, so return nothing - return [] - - def _run_objdesc(self, obj: Object) -> list[Node]: - descnode = addnodes.desc() + def run(self) -> tuple[list[Node], list[system_message]]: + ctx = { + self.text_variable_name: self.text, + } - # Generate signature node - title = self.schema.title_of(obj) - if title is None: - # Use non-generated object ID as replacement of title - idfield, objid = self.schema.identifier_of(obj) - title = objid if idfield is not None else None - if title is not None: - signode = addnodes.desc_signature(title, '') - signode += addnodes.desc_name(title, title) - descnode.append(signode) - else: - signode = None + text = template.render(type(self), ctx) - # Generate content node - contnode = addnodes.desc_content() - descnode.append(contnode) - self._setup_nodes(obj, descnode, signode, contnode) - return [descnode] + parent = inline(self.rawtext, '', **self.options) + memo = states.Struct( + document=inliner.document, # type: ignore[attr-defined] + reporter=inliner.reporter, # type: ignore[attr-defined] + language=inliner.language) # type: ignore[attr-defined] + return self.inliner.parse(text, self.lineno, memo, parent) # type: ignore[attr-defined] - def run(self) -> list[Node]: - obj = self._build_object() - if self.schema.title_of(obj) == '_': - # If no argument is given, or the first argument is '_', - # use the section title as object name and anchor, - # append content nodes to section node - return self._run_section(obj) - else: - # Else, create Sphinx ObjectDescription(sphinx.addnodes.dsec_*) - return self._run_objdesc(obj) - -class ContextRole(SphinxRole): diff --git a/src/sphinxnotes/jinja/env.py b/src/sphinxnotes/jinja/env.py new file mode 100644 index 0000000..b5113af --- /dev/null +++ b/src/sphinxnotes/jinja/env.py @@ -0,0 +1,186 @@ +""" +sphinxnotes.any.template +~~~~~~~~~~~~~~~~~~~~~~~~ + +Jinja template extensions for sphinxnotes.any. + +:copyright: Copyright 2021 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" + +from __future__ import annotations +import os +from os import path +import posixpath +import shutil + +from sphinx.util import logging +from sphinx.util.osutil import ensuredir, relative_uri +from sphinx.application import Sphinx +from sphinx.builders import Builder + +import jinja2 +from wand.image import Image + +logger = logging.getLogger(__name__) + + +class TemplateEnv(jinja2.Environment): + _builder: Builder + # Exclusive outdir for template filters + _outdir: str + # Exclusive srcdir for template filters + # Actually it is a softlink link to _outdir. + _srcdir: str + # Same to _srcdir, but relative to Sphinx's srcdir. + _reldir: str + + @classmethod + def setup(cls, app: Sphinx): + """You must call this method before instantiating""" + app.connect('builder-inited', cls._on_builder_inited) + app.connect('build-finished', cls._on_build_finished) + + @classmethod + def _on_builder_inited(cls, app: Sphinx): + cls._builder = app.builder + + # Template filters (like thumbnail_filter) may produces and new files, + # they will be referenced in documents. While usually directive + # (like ..image::) can only access file inside sphinx's srcdir(source/). + # + # So we create a dir in sphinx's outdir(_build/), and link it from srcdir, + # then files can be referenced, then we won't messup the srcdir + # (usually it is trakced by git), and our files can be cleaned up by + # removing outdir. + # + # NOTE: we use builder name as suffix to avoid conflicts between multiple + # builders. + ANYDIR = '.any' + reldir = ANYDIR + '_' + app.builder.name + cls._outdir = path.join(app.outdir, reldir) + cls._srcdir = path.join(app.srcdir, reldir) + cls._reldir = path.join('/', reldir) # abspath relatived to srcdir + + ensuredir(cls._outdir) + # Link srcdir -> outdir when needed. + if not path.exists(cls._srcdir): + os.symlink(cls._outdir, cls._srcdir) + elif not path.islink(cls._srcdir): + os.remove(cls._srcdir) + os.symlink(cls._outdir, cls._srcdir) + + logger.debug(f'[any] srcdir: {cls._srcdir}') + logger.debug(f'[any] outdir: {cls._outdir}') + + @classmethod + def _on_build_finished(cls, app: Sphinx, exception): + # NOTE: no need to clean up the symlink, it will cause unnecessary + # rebuild because file is mssiing from Sphinx's srcdir. Logs like: + # + # [build target] changed 'docname' missing dependency 'xxx.jpg' + # + # os.unlink(cls._srcdir) + pass + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.filters['thumbnail'] = self.thumbnail_filter + self.filters['install'] = self.install_filter + # self.filters['watermark'] = self._watermark_filter + + def thumbnail_filter(self, imgfn: str) -> str: + srcfn, outfn, relfn = self._get_src_out_rel(imgfn) + if not self._is_outdated(outfn, srcfn): + return relfn # no need to make thumbnail + try: + with Image(filename=srcfn) as img: + # Remove any associated profiles + img.thumbnail() + # If larger than 640x480, fit within box, preserving aspect ratio + img.transform(resize='640x480>') + img.save(filename=outfn) + except Exception as e: + logger.warning('failed to create thumbnail for %s: %s', imgfn, e) + return relfn + + def install_filter(self, fn: str) -> str: + """ + Install file to sphinx outdir, return the relative uri of current docname. + """ + srcfn, outfn, relfn = self._get_src_out_rel(fn) + if not self._is_outdated(outfn, srcfn): + return relfn # no need to install file + try: + shutil.copy(srcfn, outfn) + except Exception as e: + logger.warning('failed to install %s: %s', fn, e) + return relfn + + def watermark_filter(self, imgfn: str) -> str: + # TODO + # infn, outfn, relfn = self._get_in_out_rel(imgfn) + # with Image(filename=infn) as img: + # with Image(width=100, height=100,background='#0008', pseudo='caption:@SilverRainZ') as watermark: + # # img.watermark(watermark) + # watermark.save(filename=outfn) + # return relfn + pass + + def _relative_uri(self, *args): + """Return a relative URL from current docname to ``*args``.""" + docname = self._builder.env.docname + base = self._builder.get_target_uri(docname) + return relative_uri(base, posixpath.join(*args)) + + def _get_src_out_rel(self, fn: str) -> tuple[str, str, str]: + """Return three paths (srcfn, outfn, relfn). + :srcfn: abs path of fn, must inside sphinx's srcdir + :outfn: abs path to motified file, must inside self._srcdir + :relfn: path to outfn relatived to sphinx's srcdir + """ + isabs = path.isabs(fn) + if isabs: + fn = fn[1:] # skip os.sep so that it can be join + else: + docname = self._builder.env.docname + a, b = self._builder.env.relfn2path(fn, docname) + fn = a + + srcfn = path.join(self._builder.srcdir, fn) + if srcfn.startswith(self._srcdir): + # fn is outputted by other filters + outfn = srcfn + relfn = path.join('/', fn) + else: + outfn = path.join(self._srcdir, fn) # fn is specified by user + relfn = path.join(self._reldir, fn) + ensuredir(path.dirname(outfn)) # make sure output dir exists + logger.debug('[any] srcfn: %s, outfn: %s, relfn: %s', srcfn, outfn, relfn) + return (srcfn, outfn, relfn) + + def _is_outdated(self, target: str, src: str) -> bool: + """ + Return whether the target file is older than src file. + The given filenames must be absoulte paths. + """ + + assert path.isabs(target) + assert path.isabs(src) + + # If target file not found, regard as outdated + if not path.exists(target): + logger.debug(f'[any] {target} is outdated: not found') + return True + + # Compare mtime + try: + targetmtime = path.getmtime(target) + srcmtime = path.getmtime(src) + outdated = srcmtime > targetmtime + if outdated: + logger.debug(f'[any] {target} is outdated: {srcmtime} > {targetmtime}') + except Exception as e: + outdated = True + logger.debug(f'[any] {target} is outdated: {e}') + return outdated diff --git a/src/sphinxnotes/jinja/render.py b/src/sphinxnotes/jinja/render.py new file mode 100644 index 0000000..42f1468 --- /dev/null +++ b/src/sphinxnotes/jinja/render.py @@ -0,0 +1,22 @@ +# def run_objdesc(self, obj: Object) -> list[Node]: +# descnode = addnodes.desc() +# +# # Generate signature node +# title = self.schema.title_of(obj) +# if title is None: +# # Use non-generated object ID as replacement of title +# idfield, objid = self.schema.identifier_of(obj) +# title = objid if idfield is not None else None +# if title is not None: +# signode = addnodes.desc_signature(title, '') +# signode += addnodes.desc_name(title, title) +# descnode.append(signode) +# else: +# signode = None +# +# # Generate content node +# contnode = addnodes.desc_content() +# descnode.append(contnode) +# self._setup_nodes(obj, descnode, signode, contnode) +# return [descnode] + diff --git a/src/sphinxnotes/jinja/template.py b/src/sphinxnotes/jinja/template.py new file mode 100644 index 0000000..ed66c60 --- /dev/null +++ b/src/sphinxnotes/jinja/template.py @@ -0,0 +1,24 @@ + +from typing import Any, Type +from textwrap import dedent + +from sphinx.util.docutils import SphinxDirective, SphinxRole + +import jinja2 + +class Environment(jinja2.Environment): + pass + +def render(cls: Type[SphinxRole] | Type[SphinxDirective], ctx: dict[str, Any]): + env = Environment() + if cls is SphinxDirective: + template = dedent(""" + {{ args[0] }} + ============================================== + + {{ content }} + """) + else: + template = "<{{ text }}>" + + return env.from_string(template).render(ctx) From cae26b548410d423bc112e3726a4938af1e35179 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sun, 22 Sep 2024 00:15:44 +0800 Subject: [PATCH 4/8] update --- docs/Makefile | 2 +- docs/conf.py | 2 +- docs/index.rst | 4 ++-- src/sphinxnotes/jinja/__init__.py | 6 +++--- src/sphinxnotes/jinja/context.py | 14 +++++++------- src/sphinxnotes/jinja/template.py | 9 ++++++--- 6 files changed, 20 insertions(+), 17 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index 57d350d..4adac5d 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -20,4 +20,4 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -v diff --git a/docs/conf.py b/docs/conf.py index 9323895..c87bda5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -90,7 +90,7 @@ autoclass_content = 'init' autodoc_typehints = 'description' -extensions.append('sphinx.ext.intersphinx') +# extensions.append('sphinx.ext.intersphinx') intersphinx_mapping = { 'python': ('https://docs.python.org/3', None), 'sphinx': ('https://www.sphinx-doc.org/en/master', None), diff --git a/docs/index.rst b/docs/index.rst index 7e50ee6..d4a0968 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,11 +7,11 @@ sphinxnotes-any Role: -:test:`Hey!` +:rrr:`Hey!` Directive: -.. test:: Hey! +.. ddd:: Hey! I am here. diff --git a/src/sphinxnotes/jinja/__init__.py b/src/sphinxnotes/jinja/__init__.py index f79bdb0..83d182e 100644 --- a/src/sphinxnotes/jinja/__init__.py +++ b/src/sphinxnotes/jinja/__init__.py @@ -15,7 +15,7 @@ def setup(app: Sphinx): """Sphinx extension entrypoint.""" - app.add_role('test', ContextRole.derive('test')) - app.add_directive('test', ContextDirective.derive('test', required_arguments=1, has_content=True)) + app.add_role('rrr', ContextRole.derive('rrr')()) + app.add_directive('ddd', ContextDirective.derive('ddd', required_arguments=1, has_content=True)) - return {'version': version('sphinxnotes.any')} + return {} diff --git a/src/sphinxnotes/jinja/context.py b/src/sphinxnotes/jinja/context.py index 9d32a59..5e03324 100644 --- a/src/sphinxnotes/jinja/context.py +++ b/src/sphinxnotes/jinja/context.py @@ -25,7 +25,7 @@ from sphinx.util.docutils import SphinxDirective, SphinxRole from sphinx.util import logging -import .template +from . import template logger = logging.getLogger(__name__) @@ -85,7 +85,7 @@ def run(self) -> list[Node]: for key in self.option_spec or {}: ctx[key] = self.options.get(key) - text = template.render(type(self), ctx) + text = template.render(self, ctx) return self.parse_text_to_nodes(text, allow_section_headings=True) @@ -97,7 +97,7 @@ class ContextRole(SphinxRole): def derive(cls, role_name: str, text_variable_name: str = 'text') -> Type['ContextRole']: # Generate sphinx role class return type( - role_name.title() + 'ContextDirective', + role_name.title() + 'ContextRole', (ContextRole,), { 'text_variable_name': text_variable_name, @@ -109,12 +109,12 @@ def run(self) -> tuple[list[Node], list[system_message]]: self.text_variable_name: self.text, } - text = template.render(type(self), ctx) + text = template.render(self, ctx) parent = inline(self.rawtext, '', **self.options) memo = states.Struct( - document=inliner.document, # type: ignore[attr-defined] - reporter=inliner.reporter, # type: ignore[attr-defined] - language=inliner.language) # type: ignore[attr-defined] + document=self.inliner.document, # type: ignore[attr-defined] + reporter=self.inliner.reporter, # type: ignore[attr-defined] + language=self.inliner.language) # type: ignore[attr-defined] return self.inliner.parse(text, self.lineno, memo, parent) # type: ignore[attr-defined] diff --git a/src/sphinxnotes/jinja/template.py b/src/sphinxnotes/jinja/template.py index ed66c60..8ba503f 100644 --- a/src/sphinxnotes/jinja/template.py +++ b/src/sphinxnotes/jinja/template.py @@ -9,16 +9,19 @@ class Environment(jinja2.Environment): pass -def render(cls: Type[SphinxRole] | Type[SphinxDirective], ctx: dict[str, Any]): +def render(obj: SphinxDirective | SphinxRole, ctx: dict[str, Any]): env = Environment() - if cls is SphinxDirective: + print('type', obj) + if isinstance(obj, SphinxDirective): + print('>>>>>>>>>>>>>> is dir') template = dedent(""" {{ args[0] }} ============================================== {{ content }} """) - else: + if isinstance(obj, SphinxRole): + print('>>>>>>>>>>>>>> is role') template = "<{{ text }}>" return env.from_string(template).render(ctx) From d9826d5532d22fbd68b7d7e361692739fd0cb564 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Wed, 2 Oct 2024 22:12:01 +0800 Subject: [PATCH 5/8] update --- src/sphinxnotes/jinja/context.py | 124 ++++++++++++++++++++++++------ src/sphinxnotes/jinja/template.py | 23 ++++-- 2 files changed, 119 insertions(+), 28 deletions(-) diff --git a/src/sphinxnotes/jinja/context.py b/src/sphinxnotes/jinja/context.py index 5e03324..09f816e 100644 --- a/src/sphinxnotes/jinja/context.py +++ b/src/sphinxnotes/jinja/context.py @@ -19,7 +19,9 @@ from __future__ import annotations from typing import Type, Callable, Any +from abc import ABC, abstractmethod +from docutils import nodes from docutils.nodes import Node, system_message, inline from docutils.parsers.rst import directives, states from sphinx.util.docutils import SphinxDirective, SphinxRole @@ -29,21 +31,86 @@ logger = logging.getLogger(__name__) +class ContextGenerator(ABC): -class RstContext(object): - pass + @abstractmethod + def gen(self) -> dict[str, Any]: + raise NotImplementedError() -class DocContext(object): - pass -class EnvContext(object): - pass +class NodeAdapter(object): + node: nodes.Node + def __init__(self, n: nodes.Node): + self.node = n -class ContextDirective(SphinxDirective): - arguments_variable_name: str - content_variable_name: str + @property + def doc(self) -> NodeAdapter | None: + """Return the current doctree root node.""" + if not self.node.document: + return None + return NodeAdapter(self.node.document) + + @property + def section(self) -> NodeAdapter | None: + """Return the current section.""" + sect = self.node.next_node(nodes.section, include_self=False, descend=False, + siblings=False, ascend=False) + if not sect: + return None + return NodeAdapter(sect) + + @property + def dom(self) -> str: + return self.node.pformat() + + @property + def lines(self) -> list[str]: + return self.node.astext().split('\n') + + @property + def text(self) -> str: + return self.node.astext() + + @property + def title(self) -> NodeAdapter | None: + if not isinstance(self.node, (nodes.document, nodes.section)): + return None + title = self.node.first_child_matching_class(nodes.title) + if not title: + return None + return NodeAdapter(self.node[title]) + + + +class TopLevelVarNames(object): + env = 'env' + doc = 'doc' + self = 'self' + super = 'super' + git = 'git' + +class DirectiveContextVariableNames(object): + arguments = 'args' + options = 'opts' + content = 'content' + + name = 'name' + rawtext = 'rawtext' + source = 'source' + lineno = 'lineno' + + +class RoleContextVariableNames(object): + text = 'text' + + name = 'name' + rawtext = 'rawtext' + source = 'source' + lineno = 'lineno' + +class ContextDirective(SphinxDirective, ContextGenerator): @classmethod def derive(cls, directive_name: str, @@ -51,9 +118,7 @@ def derive(cls, optional_arguments: int = 0, final_argument_whitespace: bool = False, option_spec: list[str] | dict[str, Callable[[str], Any]] = {}, - has_content: bool = False, - arguments_variable_name: str = 'args', - content_variable_name: str = 'content') -> Type['ContextDirective']: + has_content: bool = False) -> Type['ContextDirective']: # Generate directive class # If no conversion function provided in option_spec, fallback to directive.unchanged. @@ -70,27 +135,34 @@ def derive(cls, 'final_argument_whitespace': final_argument_whitespace, 'option_spec': option_spec, 'has_content': has_content, - - 'arguments_variable_name': arguments_variable_name, - 'content_variable_name': content_variable_name, }, ) - def run(self) -> list[Node]: - ctx = {} + def gen(self) -> dict[str, Any]: + ctx = { + } if self.required_arguments + self.optional_arguments != 0: - ctx[self.arguments_variable_name] = self.arguments + ctx['args'] = self.arguments if self.has_content: - ctx[self.content_variable_name] = self.content + ctx['content'] = self.content + opts = ctx.setdefault('opts', {}) for key in self.option_spec or {}: - ctx[key] = self.options.get(key) + opts[key] = self.options.get(key) + return ctx + + def run(self) -> list[Node]: + ctx = { + 'rst': self.gen(), + 'env': self.env, + 'doc': NodeAdapter(self.state.document), + } text = template.render(self, ctx) return self.parse_text_to_nodes(text, allow_section_headings=True) -class ContextRole(SphinxRole): +class ContextRole(SphinxRole, ContextGenerator): text_variable_name: str @classmethod @@ -104,9 +176,17 @@ def derive(cls, role_name: str, text_variable_name: str = 'text') -> Type['Conte }, ) + def gen(self) -> dict[str, Any]: + ctx = { + 'text': self.text, + } + return ctx + def run(self) -> tuple[list[Node], list[system_message]]: ctx = { - self.text_variable_name: self.text, + 'rst': self.gen(), + 'env': self.env, + 'doc': NodeAdapter(self.inliner.document), } text = template.render(self, ctx) diff --git a/src/sphinxnotes/jinja/template.py b/src/sphinxnotes/jinja/template.py index 8ba503f..fcfb234 100644 --- a/src/sphinxnotes/jinja/template.py +++ b/src/sphinxnotes/jinja/template.py @@ -2,6 +2,7 @@ from typing import Any, Type from textwrap import dedent +from docutils import nodes from sphinx.util.docutils import SphinxDirective, SphinxRole import jinja2 @@ -11,17 +12,27 @@ class Environment(jinja2.Environment): def render(obj: SphinxDirective | SphinxRole, ctx: dict[str, Any]): env = Environment() + # env.filters['node_astext'] = node_astext print('type', obj) if isinstance(obj, SphinxDirective): print('>>>>>>>>>>>>>> is dir') - template = dedent(""" - {{ args[0] }} - ============================================== + template = """ +txt:: + {% for line in doc.lines %} + {{ line}} + {%- endfor %} - {{ content }} - """) +dom:: + + {% for line in doc.dom.split('\n') -%} + {{ line }} + {% endfor %} + +title:: + {{ doc.title.text }} +""" if isinstance(obj, SphinxRole): print('>>>>>>>>>>>>>> is role') - template = "<{{ text }}>" + template = "<<{{ rst.text }}>>" return env.from_string(template).render(ctx) From 6aec4bd4dadd3ff90447da2945dd6887620f8b13 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Thu, 3 Oct 2024 22:39:09 +0800 Subject: [PATCH 6/8] update --- docs/index.rst | 3 +++ src/sphinxnotes/jinja/context.py | 32 +++++++++++++++++++------------ src/sphinxnotes/jinja/template.py | 2 +- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index d4a0968..b5e7e5a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,3 +15,6 @@ Directive: .. ddd:: Hey! I am here. + +.. autoclass:: jinja.context.NodeAdapter + :members: diff --git a/src/sphinxnotes/jinja/context.py b/src/sphinxnotes/jinja/context.py index 09f816e..54359a0 100644 --- a/src/sphinxnotes/jinja/context.py +++ b/src/sphinxnotes/jinja/context.py @@ -76,9 +76,11 @@ def text(self) -> str: @property def title(self) -> NodeAdapter | None: if not isinstance(self.node, (nodes.document, nodes.section)): + print('node:', type(self.node), 'not doc sect') return None title = self.node.first_child_matching_class(nodes.title) if not title: + print('no title') return None return NodeAdapter(self.node[title]) @@ -91,24 +93,20 @@ class TopLevelVarNames(object): super = 'super' git = 'git' -class DirectiveContextVariableNames(object): - arguments = 'args' - options = 'opts' - content = 'content' - +class _MarkupVars(object): name = 'name' rawtext = 'rawtext' source = 'source' lineno = 'lineno' +class _DirectiveVars(_MarkupVars): + arguments = 'args' + options = 'opts' + content = 'content' -class RoleContextVariableNames(object): - text = 'text' - name = 'name' - rawtext = 'rawtext' - source = 'source' - lineno = 'lineno' +class RoleVars(_MarkupVars): + content = 'content' class ContextDirective(SphinxDirective, ContextGenerator): @classmethod @@ -139,7 +137,12 @@ def derive(cls, ) def gen(self) -> dict[str, Any]: + source, lineno = self.get_source_info() ctx = { + 'name': self.name, + 'rawtext': self.block_text, + 'source': source, + 'lineno': lineno, } if self.required_arguments + self.optional_arguments != 0: ctx['args'] = self.arguments @@ -177,8 +180,13 @@ def derive(cls, role_name: str, text_variable_name: str = 'text') -> Type['Conte ) def gen(self) -> dict[str, Any]: + source, lineno = self.get_source_info() ctx = { - 'text': self.text, + 'content': self.text, + 'name': self.name, + 'rawtext': self.rawtext, + 'source': source, + 'lineno': lineno, } return ctx diff --git a/src/sphinxnotes/jinja/template.py b/src/sphinxnotes/jinja/template.py index fcfb234..f34cb95 100644 --- a/src/sphinxnotes/jinja/template.py +++ b/src/sphinxnotes/jinja/template.py @@ -29,7 +29,7 @@ def render(obj: SphinxDirective | SphinxRole, ctx: dict[str, Any]): {% endfor %} title:: - {{ doc.title.text }} + {{ doc.section.title.text }} """ if isinstance(obj, SphinxRole): print('>>>>>>>>>>>>>> is role') From 03494b4db5a705939e2db3a79804387cd0373b21 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sat, 5 Oct 2024 21:01:05 +0800 Subject: [PATCH 7/8] update --- docs/index.rst | 16 +++ src/sphinxnotes/jinja/context.py | 216 +++++++++------------------- src/sphinxnotes/jinja/directives.py | 44 ++++++ src/sphinxnotes/jinja/roles.py | 28 ++++ src/sphinxnotes/jinja/template.py | 24 +++- 5 files changed, 174 insertions(+), 154 deletions(-) create mode 100644 src/sphinxnotes/jinja/directives.py create mode 100644 src/sphinxnotes/jinja/roles.py diff --git a/docs/index.rst b/docs/index.rst index b5e7e5a..42a2f27 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,3 +18,19 @@ Directive: .. autoclass:: jinja.context.NodeAdapter :members: + +.. template:: git + :extra: env app json:xxx.json + + {% for r in revisions %} + :{{ r.date | strftime }}: + {% if r.modification %} + - 修改了 {{ r.modification | roles("doc") | join("、") }} + {% endif %} + {% if r.addition %} + - 新增了 {{ r.addition | roles("doc") | join("、") }} + {% endif %} + {% if r.deletion %} + - 删除了 {{ r.deletion | join("、") }} + {% endif %} + {% endfor %} diff --git a/src/sphinxnotes/jinja/context.py b/src/sphinxnotes/jinja/context.py index 54359a0..65a8029 100644 --- a/src/sphinxnotes/jinja/context.py +++ b/src/sphinxnotes/jinja/context.py @@ -18,191 +18,103 @@ """ from __future__ import annotations -from typing import Type, Callable, Any +from typing import Any, TYPE_CHECKING from abc import ABC, abstractmethod from docutils import nodes -from docutils.nodes import Node, system_message, inline -from docutils.parsers.rst import directives, states from sphinx.util.docutils import SphinxDirective, SphinxRole from sphinx.util import logging -from . import template +if TYPE_CHECKING: + from sphinx.application import Sphinx + logger = logging.getLogger(__name__) class ContextGenerator(ABC): - @abstractmethod def gen(self) -> dict[str, Any]: raise NotImplementedError() +_registry: dict[str, ContextGenerator] = {} + +class SphinxContext(ContextGenerator): + _app: Sphinx -class NodeAdapter(object): - node: nodes.Node + def __init__(self, app: Sphinx): + self._app = app - def __init__(self, n: nodes.Node): - self.node = n + def gen(self) -> dict[str, Any]: + return { + 'app': self._app, + 'env': self._app.env, + 'cfg': self._app.config, + 'builder': self._app.builder, + } - @property - def doc(self) -> NodeAdapter | None: - """Return the current doctree root node.""" - if not self.node.document: - return None - return NodeAdapter(self.node.document) +class DocContext(ContextGenerator): + _node: nodes.Node + def __init__(self, node: nodes.Node): + self._node = node - @property - def section(self) -> NodeAdapter | None: - """Return the current section.""" - sect = self.node.next_node(nodes.section, include_self=False, descend=False, + def gen(self) -> dict[str, Any]: + return { + 'root': self._node.document, + 'section': self._node.next_node(nodes.section,include_self=False, descend=False, siblings=False, ascend=False) - if not sect: - return None - return NodeAdapter(sect) - - @property - def dom(self) -> str: - return self.node.pformat() - - @property - def lines(self) -> list[str]: - return self.node.astext().split('\n') - - @property - def text(self) -> str: - return self.node.astext() - - @property - def title(self) -> NodeAdapter | None: - if not isinstance(self.node, (nodes.document, nodes.section)): - print('node:', type(self.node), 'not doc sect') - return None - title = self.node.first_child_matching_class(nodes.title) - if not title: - print('no title') - return None - return NodeAdapter(self.node[title]) - - - -class TopLevelVarNames(object): - env = 'env' - doc = 'doc' - self = 'self' - super = 'super' - git = 'git' - -class _MarkupVars(object): - name = 'name' - rawtext = 'rawtext' - source = 'source' - lineno = 'lineno' - -class _DirectiveVars(_MarkupVars): - arguments = 'args' - options = 'opts' - content = 'content' - - -class RoleVars(_MarkupVars): - content = 'content' - -class ContextDirective(SphinxDirective, ContextGenerator): - @classmethod - def derive(cls, - directive_name: str, - required_arguments: int = 0, - optional_arguments: int = 0, - final_argument_whitespace: bool = False, - option_spec: list[str] | dict[str, Callable[[str], Any]] = {}, - has_content: bool = False) -> Type['ContextDirective']: - # Generate directive class - - # If no conversion function provided in option_spec, fallback to directive.unchanged. - if isinstance(option_spec, list): - option_spec = {k: directives.unchanged for k in option_spec} - - return type( - directive_name.title() + 'ContextDirective', - (ContextDirective,), - { - # Member of docutils.parsers.rst.Directive. - 'required_arguments': required_arguments, - 'optional_arguments': optional_arguments, - 'final_argument_whitespace': final_argument_whitespace, - 'option_spec': option_spec, - 'has_content': has_content, - }, - ) + } + +class SourceContext(ContextGenerator): + _markup: SphinxDirective | SphinxRole + + def __init__(self, markup: SphinxDirective | SphinxRole): + self._markup = markup def gen(self) -> dict[str, Any]: - source, lineno = self.get_source_info() - ctx = { - 'name': self.name, - 'rawtext': self.block_text, + if isinstance(self._markup, SphinxDirective): + rawtext = self._markup.block_text + elif isinstance(self._markup, SphinxRole): + rawtext = self._markup.rawtext + else: + raise ValueError() + + source, lineno = self._markup.get_source_info() + return { + 'name': self._markup.name, + 'rawtext': rawtext, 'source': source, 'lineno': lineno, } - if self.required_arguments + self.optional_arguments != 0: - ctx['args'] = self.arguments - if self.has_content: - ctx['content'] = self.content - opts = ctx.setdefault('opts', {}) - for key in self.option_spec or {}: - opts[key] = self.options.get(key) - return ctx - - - def run(self) -> list[Node]: - ctx = { - 'rst': self.gen(), - 'env': self.env, - 'doc': NodeAdapter(self.state.document), - } - text = template.render(self, ctx) - - return self.parse_text_to_nodes(text, allow_section_headings=True) -class ContextRole(SphinxRole, ContextGenerator): - text_variable_name: str +class DirectiveContext(ContextGenerator): + _dir: SphinxDirective - @classmethod - def derive(cls, role_name: str, text_variable_name: str = 'text') -> Type['ContextRole']: - # Generate sphinx role class - return type( - role_name.title() + 'ContextRole', - (ContextRole,), - { - 'text_variable_name': text_variable_name, - }, - ) + def __init__(self, dir: SphinxDirective): + self._dir = dir def gen(self) -> dict[str, Any]: - source, lineno = self.get_source_info() - ctx = { - 'content': self.text, - 'name': self.name, - 'rawtext': self.rawtext, - 'source': source, - 'lineno': lineno, + ctx: dict[str,Any] = { + 'opts': {}, } + if self._dir.required_arguments + self._dir.optional_arguments != 0: + ctx['args'] = self._dir.arguments + if self._dir.has_content: + ctx['content'] = self._dir.content # TODO: StringList + for key in self._dir.option_spec or {}: + ctx['opts'][key] = self._dir.options.get(key) return ctx - def run(self) -> tuple[list[Node], list[system_message]]: - ctx = { - 'rst': self.gen(), - 'env': self.env, - 'doc': NodeAdapter(self.inliner.document), - } - text = template.render(self, ctx) +class RoleContext(ContextGenerator): + _role: SphinxRole + + def __init__(self, role: SphinxRole): + self._role = role - parent = inline(self.rawtext, '', **self.options) - memo = states.Struct( - document=self.inliner.document, # type: ignore[attr-defined] - reporter=self.inliner.reporter, # type: ignore[attr-defined] - language=self.inliner.language) # type: ignore[attr-defined] - return self.inliner.parse(text, self.lineno, memo, parent) # type: ignore[attr-defined] + def gen(self) -> dict[str, Any]: + return { + 'content': self._role.text, + } diff --git a/src/sphinxnotes/jinja/directives.py b/src/sphinxnotes/jinja/directives.py new file mode 100644 index 0000000..dd5e120 --- /dev/null +++ b/src/sphinxnotes/jinja/directives.py @@ -0,0 +1,44 @@ +from typing import Type, Callable, Any + +from docutils.nodes import Node +from docutils.parsers.rst import directives +from sphinx.util.docutils import SphinxDirective + +from . import template + +class ContextDirective(SphinxDirective): + @classmethod + def derive(cls, + directive_name: str, + required_arguments: int = 0, + optional_arguments: int = 0, + final_argument_whitespace: bool = False, + option_spec: list[str] | dict[str, Callable[[str], Any]] = {}, + has_content: bool = False) -> Type['ContextDirective']: + """Generate directive class.""" + + # If no conversion function provided in option_spec, fallback to directive.unchanged. + if isinstance(option_spec, list): + option_spec = {k: directives.unchanged for k in option_spec} + + return type( + directive_name.title() + 'ContextDirective', + (ContextDirective,), + { + # Member of docutils.parsers.rst.Directive. + 'required_arguments': required_arguments, + 'optional_arguments': optional_arguments, + 'final_argument_whitespace': final_argument_whitespace, + 'option_spec': option_spec, + 'has_content': has_content, + }, + ) + + + def run(self) -> list[Node]: + ctx = {} + text = template.render(self, ctx) + + return self.parse_text_to_nodes(text, allow_section_headings=True) + + diff --git a/src/sphinxnotes/jinja/roles.py b/src/sphinxnotes/jinja/roles.py new file mode 100644 index 0000000..0b15df9 --- /dev/null +++ b/src/sphinxnotes/jinja/roles.py @@ -0,0 +1,28 @@ +from typing import Type + +from docutils.nodes import Node, system_message, inline +from docutils.parsers.rst import states +from sphinx.util.docutils import SphinxRole + +from . import template + +class ContextRole(SphinxRole): + @classmethod + def derive(cls, role_name: str) -> Type['ContextRole']: + # Generate sphinx role class + return type( + role_name.title() + 'ContextRole', + (ContextRole,), + {}, + ) + + def run(self) -> tuple[list[Node], list[system_message]]: + ctx = {} + text = template.render(self, ctx) + + parent = inline(self.rawtext, '', **self.options) + memo = states.Struct( + document=self.inliner.document, # type: ignore[attr-defined] + reporter=self.inliner.reporter, # type: ignore[attr-defined] + language=self.inliner.language) # type: ignore[attr-defined] + return self.inliner.parse(text, self.lineno, memo, parent) # type: ignore[attr-defined] diff --git a/src/sphinxnotes/jinja/template.py b/src/sphinxnotes/jinja/template.py index f34cb95..0035dfd 100644 --- a/src/sphinxnotes/jinja/template.py +++ b/src/sphinxnotes/jinja/template.py @@ -3,6 +3,7 @@ from textwrap import dedent from docutils import nodes +from docutils.parsers.rst import directives from sphinx.util.docutils import SphinxDirective, SphinxRole import jinja2 @@ -13,9 +14,7 @@ class Environment(jinja2.Environment): def render(obj: SphinxDirective | SphinxRole, ctx: dict[str, Any]): env = Environment() # env.filters['node_astext'] = node_astext - print('type', obj) if isinstance(obj, SphinxDirective): - print('>>>>>>>>>>>>>> is dir') template = """ txt:: {% for line in doc.lines %} @@ -36,3 +35,24 @@ def render(obj: SphinxDirective | SphinxRole, ctx: dict[str, Any]): template = "<<{{ rst.text }}>>" return env.from_string(template).render(ctx) + +class TemplateDirective(SphinxDirective): + required_arguments = 0 + optional_arguments = 1 + final_argument_whitespace = False + option_spec = { + 'extra': directives.unchanged, + } + has_content = True + + def run(self) -> list[nodes.Node]: + ctx = {} + for ctxname in self.arguments: + ctx = { + ctxname: context.load(ctxname) + } + + text = template.render(self, ctx) + + return self.parse_text_to_nodes(text, allow_section_headings=True) + From 101340e93e83828210d26cfc80edd28997ee3fec Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Wed, 9 Oct 2024 22:26:46 +0800 Subject: [PATCH 8/8] update --- src/sphinxnotes/jinja/context.py | 30 ++++++++++++++++++++++------- src/sphinxnotes/jinja/directives.py | 17 ++++++++++++++++ src/sphinxnotes/jinja/template.py | 4 ++-- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/sphinxnotes/jinja/context.py b/src/sphinxnotes/jinja/context.py index 65a8029..1e2dc7a 100644 --- a/src/sphinxnotes/jinja/context.py +++ b/src/sphinxnotes/jinja/context.py @@ -27,6 +27,7 @@ if TYPE_CHECKING: from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment logger = logging.getLogger(__name__) @@ -39,17 +40,17 @@ def gen(self) -> dict[str, Any]: _registry: dict[str, ContextGenerator] = {} class SphinxContext(ContextGenerator): - _app: Sphinx + _env: BuildEnvironment - def __init__(self, app: Sphinx): - self._app = app + def __init__(self, env: BuildEnvironment): + self._env = env def gen(self) -> dict[str, Any]: return { - 'app': self._app, - 'env': self._app.env, - 'cfg': self._app.config, - 'builder': self._app.builder, + 'app': self._env.app, + 'env': self._env, + 'cfg': self._env.config, + 'builder': self._env.app.builder, } class DocContext(ContextGenerator): @@ -118,3 +119,18 @@ def gen(self) -> dict[str, Any]: 'content': self._role.text, } +def _load_single_ctx(env: BuildEnvironment, ctxname: str) -> dict[str, Any]: + if ctxname == 'sphinx': + return SphinxContext(env).gen() + elif ctxname == 'doc': + return DocContext(env).gen() + + +def load_and_fuse( + buildenv: BuildEnvironment, + fuse_ctxs: list[str], + separate_ctxs: list[str], + allow_duplicate: bool = False) -> dict[str, Any]: + + + diff --git a/src/sphinxnotes/jinja/directives.py b/src/sphinxnotes/jinja/directives.py index dd5e120..70106fc 100644 --- a/src/sphinxnotes/jinja/directives.py +++ b/src/sphinxnotes/jinja/directives.py @@ -42,3 +42,20 @@ def run(self) -> list[Node]: return self.parse_text_to_nodes(text, allow_section_headings=True) +class TemplateDirective(SphinxDirective): + required_arguments = 0 + optional_arguments = 10 + final_argument_whitespace = False + option_spec = {} + has_content = True + + def run(self) -> list[nodes.Node]: + ctx = {} + for ctxname in self.arguments: + ctx = { + ctxname: context.load(ctxname) + } + + text = template.render(self, ctx) + + return self.parse_text_to_nodes(text, allow_section_headings=True) diff --git a/src/sphinxnotes/jinja/template.py b/src/sphinxnotes/jinja/template.py index 0035dfd..f0e9fdc 100644 --- a/src/sphinxnotes/jinja/template.py +++ b/src/sphinxnotes/jinja/template.py @@ -38,10 +38,10 @@ def render(obj: SphinxDirective | SphinxRole, ctx: dict[str, Any]): class TemplateDirective(SphinxDirective): required_arguments = 0 - optional_arguments = 1 + optional_arguments = 10 final_argument_whitespace = False option_spec = { - 'extra': directives.unchanged, + 'ctxs': directives.unchanged, } has_content = True