sphinxnotes.render 1.0a42__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ """
2
+ sphinxnotes.render.sources
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+
5
+ :copyright: Copyright 2026 by the Shengyu Zhang.
6
+ :license: BSD, see LICENSE for details.
7
+
8
+ This module provides helpful BaseContextSource subclasses.
9
+ """
10
+
11
+ from __future__ import annotations
12
+ from typing import TYPE_CHECKING, override
13
+ from abc import abstractmethod
14
+ from dataclasses import dataclass
15
+
16
+ from docutils.parsers.rst import directives
17
+
18
+ from .data import Field, RawData, Schema
19
+ from .ctx import PendingContext, ResolvedContext
20
+ from .render import Template
21
+ from .pipeline import BaseContextSource, BaseContextDirective, BaseContextRole
22
+
23
+ if TYPE_CHECKING:
24
+ pass
25
+
26
+
27
+ @dataclass
28
+ class UnparsedData(PendingContext):
29
+ """A implementation of PendingContext, contains raw data and its schema."""
30
+
31
+ raw: RawData
32
+ schema: Schema
33
+
34
+ @override
35
+ def resolve(self) -> ResolvedContext:
36
+ return self.schema.parse(self.raw)
37
+
38
+ @override
39
+ def __hash__(self) -> int:
40
+ return hash((self.raw, self.schema))
41
+
42
+
43
+ class BaseRawDataSource(BaseContextSource):
44
+ """
45
+ A BaseContextRenderer subclass, which itself is a definition of raw data.
46
+ """
47
+
48
+ """Methods to be implemented."""
49
+
50
+ @abstractmethod
51
+ def current_raw_data(self) -> RawData: ...
52
+
53
+ @abstractmethod
54
+ def current_schema(self) -> Schema: ...
55
+
56
+ """Methods to be overrided."""
57
+
58
+ @override
59
+ def current_context(self) -> PendingContext | ResolvedContext:
60
+ return UnparsedData(self.current_raw_data(), self.current_schema())
61
+
62
+
63
+ class BaseDataDefineDirective(BaseRawDataSource, BaseContextDirective):
64
+ @override
65
+ def current_raw_data(self) -> RawData:
66
+ return RawData(
67
+ ' '.join(self.arguments) if self.arguments else None,
68
+ self.options.copy(),
69
+ '\n'.join(self.content) if self.has_content else None,
70
+ )
71
+
72
+
73
+ class BaseDataDefineRole(BaseRawDataSource, BaseContextRole):
74
+ @override
75
+ def current_raw_data(self) -> RawData:
76
+ return RawData(self.name, self.options.copy(), self.text)
77
+
78
+
79
+ class StrictDataDefineDirective(BaseDataDefineDirective):
80
+ final_argument_whitespace = True
81
+
82
+ schema: Schema
83
+ template: Template
84
+
85
+ @override
86
+ def current_template(self) -> Template:
87
+ return self.template
88
+
89
+ @override
90
+ def current_schema(self) -> Schema:
91
+ return self.schema
92
+
93
+ @classmethod
94
+ def derive(
95
+ cls, name: str, schema: Schema, tmpl: Template
96
+ ) -> type[StrictDataDefineDirective]:
97
+ if not schema.name:
98
+ required_arguments = 0
99
+ optional_arguments = 0
100
+ elif schema.name.required:
101
+ required_arguments = 1
102
+ optional_arguments = 0
103
+ else:
104
+ required_arguments = 0
105
+ optional_arguments = 1
106
+
107
+ assert not isinstance(schema.attrs, Field)
108
+ option_spec = {}
109
+ for name, field in schema.attrs.items():
110
+ if field.required:
111
+ option_spec[name] = directives.unchanged_required
112
+ else:
113
+ option_spec[name] = directives.unchanged
114
+
115
+ has_content = schema.content is not None
116
+
117
+ # Generate directive class
118
+ return type(
119
+ 'Strict%sDataDefineDirective' % name.title(),
120
+ (cls,),
121
+ {
122
+ 'schema': schema,
123
+ 'template': tmpl,
124
+ 'has_content': has_content,
125
+ 'required_arguments': required_arguments,
126
+ 'optional_arguments': optional_arguments,
127
+ 'option_spec': option_spec,
128
+ },
129
+ )
@@ -0,0 +1,146 @@
1
+ """
2
+ sphinxnotes.template
3
+ ~~~~~~~~~~~~~~~~~~~~
4
+
5
+ Rendering Jinja2 template to markup text.
6
+
7
+ :copyright: Copyright 2026 by the Shengyu Zhang.
8
+ :license: BSD, see LICENSE for details.
9
+ """
10
+
11
+ from __future__ import annotations
12
+ from dataclasses import dataclass
13
+ from pprint import pformat
14
+ from typing import TYPE_CHECKING, override
15
+
16
+ from jinja2.sandbox import SandboxedEnvironment
17
+ from jinja2 import StrictUndefined, DebugUndefined
18
+
19
+ from .data import ParsedData
20
+ from .utils import Report
21
+
22
+ if TYPE_CHECKING:
23
+ from typing import Any, Iterable
24
+ from sphinx.application import Sphinx
25
+ from sphinx.environment import BuildEnvironment
26
+ from sphinx.builders import Builder
27
+
28
+
29
+ @dataclass
30
+ class TemplateRenderer:
31
+ text: str
32
+
33
+ def render(
34
+ self,
35
+ data: ParsedData | dict[str, Any],
36
+ extra: dict[str, Any] = {},
37
+ debug: Report | None = None,
38
+ ) -> str:
39
+ if debug:
40
+ debug.text('Starting Jinja template rendering...')
41
+
42
+ debug.text('Data:')
43
+ debug.code(pformat(data), lang='python')
44
+ debug.text('Extra context (just key):')
45
+ debug.code(pformat(list(extra.keys())), lang='python')
46
+
47
+ # Convert data to context dict.
48
+ if isinstance(data, ParsedData):
49
+ ctx = data.asdict()
50
+ elif isinstance(data, dict):
51
+ ctx = data.copy()
52
+
53
+ # Merge extra context and main context.
54
+ conflicts = set()
55
+ for name, e in extra.items():
56
+ if name not in ctx:
57
+ ctx[name] = e
58
+ else:
59
+ conflicts.add(name)
60
+
61
+ text = self._render(ctx, debug=debug is not None)
62
+
63
+ return text
64
+
65
+ def _render(self, ctx: dict[str, Any], debug: bool = False) -> str:
66
+ extensions = [
67
+ 'jinja2.ext.loopcontrols', # enable {% break %}, {% continue %}
68
+ 'jinja2.ext.do', # enable {% do ... %}
69
+ ]
70
+ if debug:
71
+ extensions.append('jinja2.ext.debug')
72
+
73
+ env = _JinjaEnv(
74
+ undefined=DebugUndefined if debug else StrictUndefined,
75
+ extensions=extensions,
76
+ )
77
+ # TODO: cache jinja env
78
+
79
+ return env.from_string(self.text).render(ctx)
80
+
81
+ def _report_self(self, reporter: Report) -> None:
82
+ reporter.text('Template:')
83
+ reporter.code(self.text, lang='jinja')
84
+
85
+
86
+ class _JinjaEnv(SandboxedEnvironment):
87
+ _builder: Builder
88
+ # List of user defined filter factories.
89
+ _filter_factories = {}
90
+
91
+ @classmethod
92
+ def _on_builder_inited(cls, app: Sphinx):
93
+ cls._builder = app.builder
94
+
95
+ @classmethod
96
+ def _on_build_finished(cls, app: Sphinx, exception): ...
97
+
98
+ @classmethod
99
+ def add_filter(cls, name: str, ff):
100
+ cls._filter_factories[name] = ff
101
+
102
+ def __init__(self, *args, **kwargs):
103
+ super().__init__(*args, **kwargs)
104
+ for name, factory in self._filter_factories.items():
105
+ self.filters[name] = factory(self._builder.env)
106
+
107
+ @override
108
+ def is_safe_attribute(self, obj, attr, value=None):
109
+ """
110
+ The sandboxed environment will call this method to check if the
111
+ attribute of an object is safe to access. Per default all attributes
112
+ starting with an underscore are considered private as well as the
113
+ special attributes of internal python objects as returned by the
114
+ is_internal_attribute() function.
115
+
116
+ .. seealso:: :class:`..utils.ctxproxy.Proxy`
117
+ """
118
+ return super().is_safe_attribute(obj, attr, value)
119
+
120
+
121
+ def _roles_filter(env: BuildEnvironment):
122
+ """
123
+ Fetch artwork picture by ID and install theme to Sphinx's source directory,
124
+ return the relative URI of current doc root.
125
+ """
126
+
127
+ def _filter(value: Iterable[str], role: str) -> Iterable[str]:
128
+ """
129
+ A heplfer filter for converting list of string to list of role.
130
+
131
+ For example::
132
+
133
+ {{ ["foo", "bar"] | roles("doc") }}
134
+
135
+ Produces ``[":doc:`foo`", ":doc:`bar`"]``.
136
+ """
137
+ return map(lambda x: ':%s:`%s`' % (role, x), value)
138
+
139
+ return _filter
140
+
141
+
142
+ def setup(app: Sphinx):
143
+ app.connect('builder-inited', _JinjaEnv._on_builder_inited)
144
+ app.connect('build-finished', _JinjaEnv._on_build_finished)
145
+
146
+ _JinjaEnv.add_filter('roles', _roles_filter)
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import TYPE_CHECKING, TypeVar, cast
4
+ import pickle
5
+ import traceback
6
+
7
+ from docutils import nodes
8
+ from docutils.frontend import get_default_settings
9
+ from docutils.parsers.rst import Parser
10
+ from docutils.parsers.rst.states import Struct, Inliner as RstInliner
11
+ from docutils.utils import new_document
12
+ from sphinx.util import logging
13
+
14
+ if TYPE_CHECKING:
15
+ from typing import Literal, Iterable, Callable
16
+ from sphinx.util.docutils import SphinxRole
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def parse_text_to_nodes(text: str) -> list[nodes.Node]:
22
+ """
23
+ Utility for parsing standard reStructuredText (without Sphinx stuffs) to nodes.
24
+ Used when there is not a SphinxDirective/SphinxRole available.
25
+ """
26
+ # TODO: markdown support
27
+ document = new_document('<string>', settings=get_default_settings(Parser)) # type: ignore
28
+ Parser().parse(text, document)
29
+ return document.children
30
+
31
+
32
+ def role_parse_text_to_nodes(self: SphinxRole, text: str) -> list[nodes.Node]:
33
+ """
34
+ Utility for parsing reStructuredText (without Sphinx stuffs) to nodes in
35
+ SphinxRole Context.
36
+ """
37
+ memo = Struct(
38
+ document=self.inliner.document,
39
+ reporter=self.inliner.reporter,
40
+ language=self.inliner.language,
41
+ )
42
+ ns, msgs = self.inliner.parse(text, self.lineno, memo, self.inliner.parent)
43
+ return ns + msgs
44
+
45
+
46
+ _Node = TypeVar('_Node', bound=nodes.Node)
47
+
48
+
49
+ def find_parent(node: nodes.Node | None, typ: type[_Node]) -> _Node | None:
50
+ if node is None or isinstance(node, typ):
51
+ return node
52
+ return find_parent(node.parent, typ)
53
+
54
+
55
+ def find_current_section(node: nodes.Node | None) -> nodes.section | None:
56
+ return find_parent(node, nodes.section)
57
+
58
+
59
+ def find_current_document(node: nodes.Node | None) -> nodes.document | None:
60
+ return find_parent(node, nodes.document)
61
+
62
+
63
+ def find_first_child(node: nodes.Element, cls: type[_Node]) -> _Node | None:
64
+ if (index := node.first_child_matching_class(cls)) is None:
65
+ return None
66
+ return cast(_Node, node[index])
67
+
68
+
69
+ def find_titular_node_upward(node: nodes.Element | None) -> nodes.Element | None:
70
+ if node is None:
71
+ return None
72
+ if isinstance(node, (nodes.section, nodes.sidebar)):
73
+ if title := find_first_child(node, nodes.title):
74
+ return title
75
+ if isinstance(node, nodes.definition_list_item):
76
+ if term := find_first_child(node, nodes.term):
77
+ return term
78
+ if isinstance(node, nodes.field):
79
+ if field := find_first_child(node, nodes.field_name):
80
+ return field
81
+ if isinstance(node, nodes.list_item):
82
+ if para := find_first_child(node, nodes.paragraph):
83
+ return para
84
+ return find_titular_node_upward(node.parent)
85
+
86
+
87
+ def find_nearest_block_element(node: nodes.Node | None) -> nodes.Element | None:
88
+ """
89
+ Finds the nearest ancestor that is suitable for block-level placement.
90
+ Typically a Body element (paragraph, table, list) or Structural element (section).
91
+ """
92
+ while node:
93
+ if isinstance(node, (nodes.Body, nodes.Structural, nodes.document)):
94
+ return node
95
+ node = node.parent
96
+ return None
97
+
98
+
99
+ class Report(nodes.system_message):
100
+ type Type = Literal['DEBUG', 'INFO', 'WARNING', 'ERROR']
101
+
102
+ title: str
103
+
104
+ def __init__(
105
+ self, title: str, typ: Type = 'DEBUG', *children, **attributes
106
+ ) -> None:
107
+ super().__init__(title + ':', type=typ, level=2, *children, **attributes)
108
+ self.title = title
109
+
110
+ def empty(self) -> bool:
111
+ # title is the only children
112
+ return len(self.children) <= 1
113
+
114
+ def node(self, node: nodes.Node) -> None:
115
+ self += node
116
+ self.log(f'report: {node.astext()}')
117
+
118
+ def log(self, msg: str) -> None:
119
+ if self['type'] in 'ERROR':
120
+ logger.error(msg)
121
+ elif self['type'] in 'WARNING':
122
+ logger.warning(msg)
123
+
124
+ def text(self, text: str) -> None:
125
+ self.node(nodes.paragraph(text, text))
126
+
127
+ def code(self, code: str, lang: str | None = None) -> None:
128
+ blk = nodes.literal_block(code, code)
129
+ if lang:
130
+ blk['language'] = lang
131
+ self.node(blk)
132
+
133
+ def list(self, lines: Iterable[str]) -> None:
134
+ bullet_list = nodes.bullet_list(bullet='*')
135
+
136
+ for line in lines:
137
+ list_item = nodes.list_item()
138
+ para = nodes.paragraph()
139
+ para += nodes.Text(line)
140
+ list_item += para
141
+ bullet_list += list_item
142
+
143
+ self.node(bullet_list)
144
+
145
+ def traceback(self) -> None:
146
+ # https://pygments.org/docs/lexers/#pygments.lexers.python.PythonTracebackLexer
147
+ self.code(traceback.format_exc(), lang='pytb')
148
+
149
+ def exception(self, e: Exception) -> None:
150
+ # https://pygments.org/docs/lexers/#pygments.lexers.python.PythonTracebackLexer
151
+ self.code(str(e), lang='pytb')
152
+
153
+ def is_error(self) -> bool:
154
+ return self['type'] == 'ERROR'
155
+
156
+ type Inliner = RstInliner | tuple[nodes.document, nodes.Element]
157
+
158
+ def problematic(self, inliner: Inliner) -> nodes.problematic:
159
+ """Create a crossed referenced inline problematic nodes."""
160
+
161
+ if isinstance(inliner, RstInliner):
162
+ prb = inliner.problematic('', '', self)
163
+ else:
164
+ # See also :meth:`docutils.parsers.rst.Inliner.problematic`.
165
+ msgid = inliner[0].set_id(self, inliner[1])
166
+ prb = nodes.problematic('', '', refid=msgid)
167
+ prbid = inliner[0].set_id(prb)
168
+ self.add_backref(prbid)
169
+
170
+ prb += nodes.Text(' ')
171
+ prb += nodes.superscript(self.title, self.title)
172
+
173
+ return prb
174
+
175
+
176
+ @dataclass
177
+ class Reporter:
178
+ """A helper class for storing :class:`Report` to nodes."""
179
+
180
+ node: nodes.Element
181
+
182
+ @property
183
+ def reports(self) -> list[Report]:
184
+ """Use ``node += Report('xxx')`` to append a report."""
185
+ return [x for x in self.node if isinstance(x, Report)]
186
+
187
+ def append(self, report: Report) -> None:
188
+ self.node += report
189
+
190
+ def clear(self, pred: Callable[[Report], bool] | None = None) -> list[Report]:
191
+ """Clear report children from node if pred returns True."""
192
+ msgs = []
193
+ for report in self.reports:
194
+ if not pred or pred(report):
195
+ msgs.append(report)
196
+ self.node.remove(report)
197
+ return msgs
198
+
199
+ def clear_empty(self) -> list[Report]:
200
+ return self.clear(lambda x: x.empty())
201
+
202
+
203
+ class Unpicklable:
204
+ """
205
+ Make objects unpickable to prevent them from being stored in the
206
+ on-disk doctree.
207
+ """
208
+
209
+ def __reduce_ex__(self, protocol):
210
+ # Prevent pickling explicitly
211
+ raise pickle.PicklingError(f'{type(self)} is unpicklable')
@@ -0,0 +1,144 @@
1
+ from dataclasses import dataclass
2
+ from functools import wraps
3
+ from typing import Any, Callable
4
+ from types import MappingProxyType
5
+
6
+ from docutils import nodes
7
+ from sphinx.util import logging
8
+ from sphinx.config import Config as SphinxConfig
9
+
10
+ from ..utils import find_first_child
11
+
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def proxy_property(func: Callable[[Any], Any]) -> property:
17
+ @wraps(func)
18
+ def wrapped(self: Proxy) -> Any:
19
+ return self._normalize(func(self))
20
+
21
+ return property(wrapped)
22
+
23
+
24
+ # FIXME: Unpicklable?
25
+ @dataclass(frozen=True)
26
+ class Proxy:
27
+ """
28
+ Proxy complex objects into context for convenient and secure access within
29
+ Jinja templates.
30
+
31
+ Porxy might point to very complex, unpredictable objects, therefore
32
+ disallowing pickling is necessary.
33
+ """
34
+
35
+ _obj: Any
36
+
37
+ def __getattr__(self, name: str) -> Any:
38
+ # Internal attr is not accessable.
39
+ if name.startswith('_'):
40
+ raise AttributeError(name)
41
+
42
+ v = getattr(self._obj, name)
43
+ if callable(v):
44
+ # Deny callable attr for safety.
45
+ raise AttributeError(name)
46
+
47
+ return self._wrap(v)
48
+
49
+ @staticmethod
50
+ def _wrap(v: Any) -> Any:
51
+ cls = SPECIFIC_TYPE_REGISTRY.get(type(v))
52
+ if cls:
53
+ return cls(v)
54
+ for types, cls in TYPE_REGISTRY.items():
55
+ if isinstance(v, types):
56
+ return cls(v)
57
+ return v
58
+
59
+ @staticmethod
60
+ def _normalize(val: Any) -> Any:
61
+ if val is None or isinstance(val, (str, int, float, bool)):
62
+ return val
63
+
64
+ if isinstance(val, Proxy):
65
+ return val
66
+
67
+ wrapped_val = Proxy._wrap(val)
68
+ if wrapped_val is not val:
69
+ return wrapped_val
70
+
71
+ if isinstance(val, (set, frozenset)):
72
+ return frozenset(Proxy._normalize(x) for x in val)
73
+ if isinstance(val, (list, tuple)):
74
+ return tuple(Proxy._normalize(x) for x in val)
75
+ if isinstance(val, dict):
76
+ copied = {k: Proxy._normalize(v) for k, v in val.items()}
77
+ return MappingProxyType(copied)
78
+ return str(val)
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class Node(Proxy):
83
+ _obj: nodes.Element
84
+
85
+ @proxy_property
86
+ def attrs(self) -> dict[str, str]:
87
+ """Shortcut to :attr:`nodes.Element.attributes`."""
88
+ return self._obj.attributes
89
+
90
+ def __str__(self) -> str:
91
+ return self._obj.astext()
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class NodeWithTitle(Node):
96
+ @proxy_property
97
+ def title(self) -> Node | None:
98
+ return find_first_child(self._obj, nodes.Titular) # type: ignore
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class Section(NodeWithTitle):
103
+ _obj: nodes.section
104
+
105
+ @proxy_property
106
+ def sections(self) -> tuple['Section', ...]:
107
+ sect_nodes = self._obj[0].findall(
108
+ nodes.section, descend=False, ascend=False, siblings=True
109
+ )
110
+ return list(sect_nodes)
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class Document(NodeWithTitle):
115
+ _obj: nodes.document
116
+
117
+ def _top_section(self) -> Section:
118
+ section = self._obj.next_node(nodes.section)
119
+ assert section
120
+ return Section(section)
121
+
122
+ @proxy_property
123
+ def sections(self) -> tuple[Section, ...]:
124
+ return self._top_section().sections
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class Config(Proxy):
129
+ _obj: SphinxConfig
130
+
131
+
132
+ TYPE_REGISTRY: dict[type | tuple[type, ...], type[Proxy]] = {
133
+ nodes.Node: Node,
134
+ }
135
+
136
+ SPECIFIC_TYPE_REGISTRY: dict[type, type[Proxy]] = {
137
+ nodes.document: Document,
138
+ nodes.section: Section,
139
+ SphinxConfig: Config,
140
+ }
141
+
142
+
143
+ def proxy(v: Any) -> Proxy:
144
+ return Proxy._wrap(v)