Skip to content

Commit 24e7696

Browse files
committed
feat: Initial implemention
1 parent fbeb450 commit 24e7696

8 files changed

Lines changed: 1058 additions & 9 deletions

File tree

src/sphinxnotes/data/__init__.py

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
"""
2+
sphinxnotes.dataview
3+
~~~~~~~~~~~~~~~~~~~~
4+
5+
:copyright: Copyright 2025 by the Shengyu Zhang.
6+
:license: BSD, see LICENSE for details.
7+
"""
8+
9+
from __future__ import annotations
10+
from typing import Any
11+
12+
from docutils import nodes
13+
from docutils.parsers.rst import directives
14+
15+
from sphinx.util import logging
16+
from sphinx.util.docutils import SphinxDirective, SphinxRole
17+
from sphinx.application import Sphinx
18+
from sphinx.transforms import SphinxTransform
19+
20+
from . import meta
21+
from .freestyle import FreeStyleDirective, FreeStyleOptionSpec
22+
from .data import Data, Field, Schema, Raw
23+
from .render import Template, Phase, render, JinjaEnv
24+
from .utils import find_current_document, find_current_section, TempData
25+
from .context_proxy import proxy
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
class Node(nodes.Element): ...
31+
32+
33+
class pending_node(Node, nodes.meta): ...
34+
35+
36+
class rendered_node(Node, nodes.container): ...
37+
38+
39+
class TemplateStore(TempData[Template]): ...
40+
41+
42+
class RawDataStore(TempData[Raw]): ...
43+
44+
45+
class SchemaStore(TempData[Schema]): ...
46+
47+
48+
class TemplateDirective(SphinxDirective):
49+
option_spec = {
50+
'on': Phase.option_spec,
51+
'debug': directives.flag,
52+
}
53+
has_content = True
54+
55+
def run(self) -> list[nodes.Node]:
56+
self.assert_has_content()
57+
58+
tmpl = Template(
59+
text='\n'.join(self.content),
60+
phase=self.options.get('on', Phase.default()),
61+
debug='debug' in self.options,
62+
)
63+
TemplateStore.set(self.state.document, tmpl)
64+
65+
return []
66+
67+
68+
class DataSchemaDirective(FreeStyleDirective):
69+
optional_arguments = 1
70+
option_spec = FreeStyleOptionSpec()
71+
has_content = True
72+
73+
def run(self) -> list[nodes.Node]:
74+
if self.arguments:
75+
name = Field.from_str(self.arguments[0])
76+
else:
77+
name = None
78+
79+
attrs = {}
80+
for k, v in self.options.items():
81+
attrs[k] = Field.from_str(v)
82+
83+
if self.content:
84+
content = Field.from_str(self.content[0])
85+
else:
86+
content = None
87+
88+
schema = Schema(name, attrs, content)
89+
SchemaStore.set(self.state.document, schema)
90+
91+
return []
92+
93+
94+
def markup_context(v: SphinxDirective | SphinxRole | nodes.Element) -> dict[str, Any]:
95+
ctx = {}
96+
97+
if isinstance(v, nodes.Element):
98+
ctx['_markup'] = {}
99+
else:
100+
isdir = isinstance(v, SphinxDirective)
101+
ctx['_markup'] = {
102+
'type': 'directive' if isdir else 'role',
103+
'name': v.name,
104+
'lineno': v.lineno,
105+
'rawtext': v.block_text if isdir else v.rawtext,
106+
}
107+
return ctx
108+
109+
110+
def doctree_context(v: SphinxDirective | SphinxRole | nodes.Element) -> dict[str, Any]:
111+
ctx = {}
112+
if isinstance(v, nodes.Element):
113+
ctx['_doc'] = proxy(find_current_document(v))
114+
ctx['_section'] = proxy(find_current_section(v))
115+
else:
116+
isdir = isinstance(v, SphinxDirective)
117+
state = v.state if isdir else v.inliner
118+
ctx['_doc'] = proxy(state.document)
119+
ctx['_section'] = proxy(find_current_section(state.parent))
120+
return ctx
121+
122+
123+
def sphinx_context(v: SphinxDirective | SphinxRole | SphinxTransform) -> dict[str, Any]:
124+
ctx = {}
125+
ctx['_env'] = proxy(v.env)
126+
ctx['_config'] = proxy(v.config)
127+
return ctx
128+
129+
130+
class DataDefineDirective(FreeStyleDirective):
131+
optional_arguments = 1
132+
has_content = True
133+
134+
def run(self) -> list[nodes.Node]:
135+
tmpl = TemplateStore.get(self.state.document)
136+
if tmpl is None:
137+
return []
138+
139+
schema = SchemaStore.get(self.state.document)
140+
rawdata = self._raw_data()
141+
142+
if tmpl.phase != Phase.Parsing:
143+
n = pending_node()
144+
RawDataStore.set(n, rawdata)
145+
TemplateStore.set(n, tmpl)
146+
if schema:
147+
SchemaStore.set(n, schema)
148+
return [n]
149+
150+
if schema:
151+
try:
152+
data = schema.parse(rawdata)
153+
except ValueError as e:
154+
raise self.error(str(e))
155+
else:
156+
data = Data.from_raw(rawdata)
157+
158+
extra_ctx = {
159+
**sphinx_context(self),
160+
**doctree_context(self),
161+
**markup_context(self),
162+
}
163+
n = render(self.parse_text_to_nodes, tmpl, data, extra_ctx)
164+
165+
return [n]
166+
167+
def _raw_data(self) -> Raw:
168+
return Raw(
169+
self.arguments[0] if self.arguments else None,
170+
self.options.copy(),
171+
'\n'.join(self.content),
172+
)
173+
174+
175+
class ParsedHookDirective(SphinxDirective):
176+
def run(self) -> list[nodes.Node]:
177+
for pending in self.state.document.findall(pending_node):
178+
tmpl = TemplateStore.get(pending)
179+
schema = SchemaStore.get(pending)
180+
rawdata = RawDataStore.get(pending)
181+
182+
assert tmpl
183+
assert rawdata
184+
185+
if schema:
186+
try:
187+
data = schema.parse(rawdata)
188+
except ValueError as e:
189+
raise self.error(str(e))
190+
else:
191+
data = Data.from_raw(rawdata)
192+
193+
extra_ctx = {
194+
**sphinx_context(self),
195+
**doctree_context(pending),
196+
**markup_context(pending),
197+
}
198+
n = render(self.parse_text_to_nodes, tmpl, data, extra_ctx)
199+
pending.replace_self(n)
200+
201+
return [] # nothing to return
202+
203+
204+
def on_source_read(app, docname, content):
205+
# NOTE: content is a single element list, representing the content of the
206+
# source file.
207+
#
208+
# .. seealso:: https://www.sphinx-doc.org/en/master/extdev/event_callbacks.html#event-source-read
209+
content[-1] = content[-1] + '\n\n.. data:parsed-hook::'
210+
211+
212+
class ResolvingHookTransform(SphinxTransform):
213+
default_priority = 210 # 在主要处理阶段运行
214+
215+
def apply(self, **kwargs):
216+
for pending in self.document.findall(pending_node):
217+
tmpl = TemplateStore.get(pending)
218+
schema = SchemaStore.get(pending)
219+
rawdata = RawDataStore.get(pending)
220+
221+
assert tmpl
222+
assert rawdata
223+
224+
if schema:
225+
try:
226+
data = schema.parse(rawdata)
227+
except ValueError:
228+
continue # FIXME
229+
else:
230+
data = Data.from_raw(rawdata)
231+
232+
n = render(self.parse_text_to_nodes, tmpl, data, {})
233+
pending.replace_self(n)
234+
235+
236+
def setup(app: Sphinx):
237+
meta.pre_setup(app)
238+
239+
app.add_directive('data:tmpl', TemplateDirective, False)
240+
app.add_directive('data:schema', DataSchemaDirective, False)
241+
# app.add_directive('data:use-tmpl', DataTemplateDirective, False)
242+
app.add_directive('data:def', DataDefineDirective, False)
243+
app.add_directive('data:parsed-hook', ParsedHookDirective, False)
244+
245+
app.connect('source-read', on_source_read)
246+
247+
app.add_transform(ResolvingHookTransform)
248+
249+
JinjaEnv.setup(app)
250+
251+
return meta.post_setup(app)
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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+
8+
from sphinx.util import logging
9+
from sphinx.config import Config as SphinxConfig
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
def proxy_property(func: Callable[[Any], Any]) -> property:
15+
@wraps(func)
16+
def wrapped(self: Proxy) -> Any:
17+
return self._normalize(func(self))
18+
19+
return property(wrapped)
20+
21+
22+
@dataclass(frozen=True)
23+
class Proxy:
24+
_obj: Any
25+
26+
def __getattr__(self, name: str) -> Any:
27+
# Internal attr is not accessable.
28+
if name.startswith('_'):
29+
raise AttributeError(name)
30+
31+
v = getattr(self._obj, name)
32+
if callable(v):
33+
# Deny callable attr for safety.
34+
raise AttributeError(name)
35+
36+
return self._wrap(v)
37+
38+
@staticmethod
39+
def _wrap(v: Any) -> Any:
40+
cls = SPECIFIC_TYPE_REGISTRY.get(type(v))
41+
if cls:
42+
return cls(v)
43+
for types, cls in TYPE_REGISTRY.items():
44+
if isinstance(v, types):
45+
return cls(v)
46+
return v
47+
48+
@staticmethod
49+
def _normalize(val: Any) -> Any:
50+
"""
51+
对显式 property 的返回值做模板安全化处理:
52+
- 标量原样返回;
53+
- 已是 ContextProxy 原样返回;
54+
- 注册类型自动 wrap;
55+
- 容器转换为不可变快照并递归处理;
56+
- 其他对象默认转 str。
57+
"""
58+
# 标量
59+
if val is None or isinstance(val, (str, int, float, bool)):
60+
return val
61+
62+
# 已包装过
63+
if isinstance(val, Proxy):
64+
return val
65+
66+
# 注册类型自动 wrap
67+
wrapped_val = Proxy._wrap(val)
68+
if wrapped_val is not val:
69+
return wrapped_val
70+
71+
# 容器
72+
if isinstance(val, (set, frozenset)):
73+
return frozenset(Proxy._normalize(x) for x in val)
74+
if isinstance(val, (list, tuple)):
75+
return tuple(Proxy._normalize(x) for x in val)
76+
if isinstance(val, dict):
77+
copied = {k: Proxy._normalize(v) for k, v in val.items()}
78+
return MappingProxyType(copied)
79+
80+
# 其他对象:安全起见转成字符串
81+
return str(val)
82+
83+
84+
@dataclass(frozen=True)
85+
class Node(Proxy):
86+
_obj: nodes.Element
87+
88+
@proxy_property
89+
def attrs(self) -> dict[str, str]:
90+
"""Shortcut to :attr:`nodes.Element.attributes`."""
91+
return self._obj.attributes
92+
93+
def __str__(self) -> str:
94+
return self._obj.astext()
95+
96+
97+
@dataclass(frozen=True)
98+
class NodeWithTitle(Node):
99+
@proxy_property
100+
def title(self) -> Node | None:
101+
return self._obj.next_node(nodes.Titular)
102+
103+
104+
@dataclass(frozen=True)
105+
class Section(NodeWithTitle):
106+
_obj: nodes.section
107+
108+
@proxy_property
109+
def sections(self) -> tuple['Section', ...]:
110+
sect_nodes = self._obj[0].findall(
111+
nodes.section, descend=False, ascend=False, siblings=True
112+
)
113+
return list(sect_nodes)
114+
115+
116+
@dataclass(frozen=True)
117+
class Document(NodeWithTitle):
118+
_obj: nodes.document
119+
120+
def _top_section(self) -> Section:
121+
section = self._obj.next_node(nodes.section)
122+
assert section
123+
return Section(section)
124+
125+
@proxy_property
126+
def sections(self) -> tuple[Section, ...]:
127+
return self._top_section().sections
128+
129+
130+
@dataclass(frozen=True)
131+
class ConfigProxy(Proxy):
132+
_obj: SphinxConfig
133+
134+
135+
TYPE_REGISTRY: dict[type | tuple[type, ...], type[Proxy]] = {
136+
nodes.Node: Node,
137+
}
138+
139+
SPECIFIC_TYPE_REGISTRY: dict[type, type[Proxy]] = {
140+
nodes.document: Document,
141+
nodes.section: Section,
142+
SphinxConfig: ConfigProxy,
143+
}
144+
145+
146+
def proxy(v: Any) -> Proxy | None:
147+
return Proxy._wrap(v) if v is not None else None

0 commit comments

Comments
 (0)