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.
- sphinxnotes/render/__init__.py +93 -0
- sphinxnotes/render/ctx.py +76 -0
- sphinxnotes/render/ctxnodes.py +254 -0
- sphinxnotes/render/data.py +546 -0
- sphinxnotes/render/extractx.py +184 -0
- sphinxnotes/render/markup.py +122 -0
- sphinxnotes/render/meta.py +35 -0
- sphinxnotes/render/pipeline.py +300 -0
- sphinxnotes/render/py.typed +0 -0
- sphinxnotes/render/render.py +60 -0
- sphinxnotes/render/sources.py +129 -0
- sphinxnotes/render/template.py +146 -0
- sphinxnotes/render/utils/__init__.py +211 -0
- sphinxnotes/render/utils/ctxproxy.py +144 -0
- sphinxnotes/render/utils/freestyle.py +93 -0
- sphinxnotes_render-1.0a42.dist-info/METADATA +78 -0
- sphinxnotes_render-1.0a42.dist-info/RECORD +20 -0
- sphinxnotes_render-1.0a42.dist-info/WHEEL +5 -0
- sphinxnotes_render-1.0a42.dist-info/licenses/LICENSE +29 -0
- sphinxnotes_render-1.0a42.dist-info/top_level.txt +1 -0
|
@@ -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)
|