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,184 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING, override
3
+ from abc import ABC, abstractmethod
4
+
5
+ from sphinx.util.docutils import SphinxDirective
6
+ from docutils.parsers.rst.directives import _directives
7
+ from docutils.parsers.rst.roles import _roles
8
+
9
+ from .render import HostWrapper
10
+ from .ctxnodes import pending_node
11
+ from .utils import find_current_section, Report, Reporter
12
+ from .utils.ctxproxy import proxy
13
+
14
+ if TYPE_CHECKING:
15
+ from typing import Any, Callable, ClassVar
16
+ from sphinx.application import Sphinx
17
+ from .render import ParseHost, TransformHost
18
+
19
+
20
+ class GlobalExtraContxt(ABC):
21
+ @abstractmethod
22
+ def generate(self) -> Any: ...
23
+
24
+
25
+ class ParsePhaseExtraContext(ABC):
26
+ @abstractmethod
27
+ def generate(self, host: ParseHost) -> Any: ...
28
+
29
+
30
+ class TransformPhaseExtraContext(ABC):
31
+ @abstractmethod
32
+ def generate(self, host: TransformHost) -> Any: ...
33
+
34
+
35
+ # =======================
36
+ # Extra context registion
37
+ # =======================
38
+
39
+
40
+ class ExtraContextRegistry:
41
+ names: set[str]
42
+ parsing: dict[str, ParsePhaseExtraContext]
43
+ parsed: dict[str, TransformPhaseExtraContext]
44
+ post_transform: dict[str, TransformPhaseExtraContext]
45
+ global_: dict[str, GlobalExtraContxt]
46
+
47
+ def __init__(self) -> None:
48
+ self.names = set()
49
+ self.parsing = {}
50
+ self.parsed = {}
51
+ self.post_transform = {}
52
+ self.global_ = {}
53
+
54
+ self.add_global_context('sphinx', _SphinxExtraContext())
55
+ self.add_global_context('docutils', _DocutilsExtraContext())
56
+ self.add_parsing_phase_context('markup', _MarkupExtraContext())
57
+ self.add_parsing_phase_context('section', _SectionExtraContext())
58
+ self.add_parsing_phase_context('doc', _DocExtraContext())
59
+
60
+ def _name_dedup(self, name: str) -> None:
61
+ # TODO: allow dup
62
+ if name in self.names:
63
+ raise ValueError(f'Context generator {name} already exists')
64
+ self.names.add(name)
65
+
66
+ def add_parsing_phase_context(
67
+ self, name: str, ctxgen: ParsePhaseExtraContext
68
+ ) -> None:
69
+ self._name_dedup(name)
70
+ self.parsing['_' + name] = ctxgen
71
+
72
+ def add_parsed_phase_context(
73
+ self, name: str, ctxgen: TransformPhaseExtraContext
74
+ ) -> None:
75
+ self._name_dedup(name)
76
+ self.parsed['_' + name] = ctxgen
77
+
78
+ def add_post_transform_phase_context(
79
+ self, name: str, ctxgen: TransformPhaseExtraContext
80
+ ) -> None:
81
+ self._name_dedup(name)
82
+ self.post_transform['_' + name] = ctxgen
83
+
84
+ def add_global_context(self, name: str, ctxgen: GlobalExtraContxt):
85
+ self._name_dedup(name)
86
+ self.global_['_' + name] = ctxgen
87
+
88
+
89
+ # ===================================
90
+ # Bulitin extra context implementions
91
+ # ===================================
92
+
93
+
94
+ class _MarkupExtraContext(ParsePhaseExtraContext):
95
+ @override
96
+ def generate(self, host: ParseHost) -> Any:
97
+ isdir = isinstance(host, SphinxDirective)
98
+ return {
99
+ 'type': 'directive' if isdir else 'role',
100
+ 'name': host.name,
101
+ 'lineno': host.lineno,
102
+ 'rawtext': host.block_text if isdir else host.rawtext,
103
+ }
104
+
105
+
106
+ class _DocExtraContext(ParsePhaseExtraContext):
107
+ @override
108
+ def generate(self, host: ParseHost) -> Any:
109
+ return proxy(HostWrapper(host).doctree)
110
+
111
+
112
+ class _SectionExtraContext(ParsePhaseExtraContext):
113
+ @override
114
+ def generate(self, host: ParseHost) -> Any:
115
+ parent = HostWrapper(host).parent
116
+ return proxy(find_current_section(parent))
117
+
118
+
119
+ class _SphinxExtraContext(GlobalExtraContxt):
120
+ app: ClassVar[Sphinx]
121
+
122
+ @override
123
+ def generate(self) -> Any:
124
+ return proxy(self.app)
125
+
126
+
127
+ class _DocutilsExtraContext(GlobalExtraContxt):
128
+ @override
129
+ def generate(self) -> Any:
130
+ # FIXME: use unexported api
131
+ return {
132
+ 'directives': _directives,
133
+ 'roles': _roles,
134
+ }
135
+
136
+
137
+ # ========================
138
+ # Extra Context Management
139
+ # ========================
140
+
141
+
142
+ class ExtraContextGenerator:
143
+ node: pending_node
144
+ report: Report
145
+
146
+ registry: ClassVar[ExtraContextRegistry] = ExtraContextRegistry()
147
+
148
+ def __init__(self, node: pending_node) -> None:
149
+ self.node = node
150
+ self.report = Report(
151
+ 'Extra Context Generation Report',
152
+ 'ERROR',
153
+ source=node.source,
154
+ line=node.line,
155
+ )
156
+ Reporter(node).append(self.report)
157
+
158
+ def on_anytime(self) -> None:
159
+ for name, ctxgen in self.registry.global_.items():
160
+ self._safegen(name, lambda: ctxgen.generate())
161
+
162
+ def on_parsing(self, host: ParseHost) -> None:
163
+ for name, ctxgen in self.registry.parsing.items():
164
+ self._safegen(name, lambda: ctxgen.generate(host))
165
+
166
+ def on_parsed(self, host: TransformHost) -> None:
167
+ for name, ctxgen in self.registry.parsed.items():
168
+ self._safegen(name, lambda: ctxgen.generate(host))
169
+
170
+ def on_post_transform(self, host: TransformHost) -> None:
171
+ for name, ctxgen in self.registry.post_transform.items():
172
+ self._safegen(name, lambda: ctxgen.generate(host))
173
+
174
+ def _safegen(self, name: str, gen: Callable[[], Any]):
175
+ try:
176
+ # ctxgen.generate can be user-defined code, exception of any kind are possible.
177
+ self.node.extra[name] = gen()
178
+ except Exception:
179
+ self.report.text(f'Failed to generate extra context "{name}":')
180
+ self.report.traceback()
181
+
182
+
183
+ def setup(app: Sphinx):
184
+ _SphinxExtraContext.app = app
@@ -0,0 +1,122 @@
1
+ """
2
+ sphinxnotes.markup
3
+ ~~~~~~~~~~~~~~~~~~
4
+
5
+ Rendering markup text to doctree nodes.
6
+
7
+ :copyright: Copyright 2025 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 typing import TYPE_CHECKING
14
+
15
+ from docutils import nodes
16
+ from docutils.parsers.rst.states import Struct
17
+ from docutils.utils import new_document
18
+ from sphinx import version_info
19
+ from sphinx.util.docutils import SphinxDirective, SphinxRole
20
+ from sphinx.transforms import SphinxTransform
21
+ from sphinx.environment.collectors.asset import ImageCollector
22
+
23
+ from .render import Host
24
+
25
+ if TYPE_CHECKING:
26
+ from docutils.nodes import Node, system_message
27
+
28
+
29
+ @dataclass
30
+ class MarkupRenderer:
31
+ host: Host
32
+
33
+ def render(
34
+ self, text: str, inline: bool = False
35
+ ) -> tuple[list[Node], list[system_message]]:
36
+ if inline:
37
+ return self._render_inline(text)
38
+ else:
39
+ return self._render(text), []
40
+
41
+ def _render(self, text: str) -> list[Node]:
42
+ if isinstance(self.host, SphinxDirective):
43
+ return self.host.parse_text_to_nodes(text)
44
+ elif isinstance(self.host, SphinxTransform):
45
+ # TODO: dont create parser for every time
46
+ if version_info[0] >= 9:
47
+ parser = self.host.app.registry.create_source_parser(
48
+ 'rst', env=self.host.env, config=self.host.config
49
+ )
50
+ else:
51
+ parser = self.host.app.registry.create_source_parser(
52
+ self.host.app, 'rst'
53
+ )
54
+ settings = self.host.document.settings
55
+ doc = new_document('<generated text>', settings=settings)
56
+ parser.parse(text, doc)
57
+
58
+ # NOTE: Nodes produced by standalone source parser should be fixed
59
+ # before returning, cause they missed the processing by certain
60
+ # Sphinx transforms.
61
+ self._fix_document(doc)
62
+
63
+ return doc.children
64
+ else:
65
+ assert False
66
+
67
+ def _render_inline(self, text: str) -> tuple[list[Node], list[system_message]]:
68
+ if isinstance(self.host, SphinxDirective):
69
+ return self.host.parse_inline(text)
70
+ if isinstance(self.host, SphinxRole):
71
+ inliner = self.host.inliner
72
+ memo = Struct(
73
+ document=inliner.document,
74
+ reporter=inliner.reporter,
75
+ language=inliner.language,
76
+ )
77
+
78
+ return inliner.parse(text, self.host.lineno, memo, inliner.parent)
79
+ elif isinstance(self.host, SphinxTransform):
80
+ # Fallback to normal non-inline render then extract inline
81
+ # elements by self.
82
+ # FIXME: error seems be ignored?
83
+ ns = self._render(text)
84
+ if ns and isinstance(ns[0], nodes.paragraph):
85
+ ns = ns[0].children
86
+ return ns, []
87
+ else:
88
+ assert False
89
+
90
+ def _fix_document(self, document: nodes.document) -> None:
91
+ assert isinstance(self.host, SphinxTransform)
92
+
93
+ """For documents generated by a separate source parser, some preprocessing
94
+ may be missing. For example:
95
+
96
+ - the lack of an ImageCollector result in an incorrect "node['uri']" and
97
+ a missing "node['candidates']" :class:`nodes.images
98
+
99
+ .. note::
100
+
101
+ Since this depends on Sphinx's internal implementation, there may be
102
+ many cases that have not been considered. We can only do our best
103
+ and fix each error we encounter as possible.
104
+ """
105
+
106
+ ImageCollector().process_doc(self.host.app, document)
107
+
108
+ def _fix_image_candidates(self, node: nodes.image) -> None:
109
+ """This is another way for fixing images node instead of calling
110
+ ImageCollector().process_doc. Keep it for now.
111
+ """
112
+ assert isinstance(self.host, SphinxTransform)
113
+
114
+ # NOTE:
115
+ # :meth:`sphinx.environment.collectors.ImageCollector.process_doc` add
116
+ # a 'candidates' key to nodes.image, and subclasses of
117
+ # :class:`sphinx.transforms.post_transforms.BaseImageConverter` require
118
+ # the key.
119
+ node['candidates'] = {}
120
+
121
+ # Update `node['uri']` to a relative path from srcdir.
122
+ node['uri'], _ = self.host.env.relfn2path(node['uri'])
@@ -0,0 +1,35 @@
1
+ # This file is generated from sphinx-notes/cookiecutter.
2
+ # DO NOT EDIT!!!
3
+
4
+ ################################################################################
5
+ # Project meta infos.
6
+ ################################################################################
7
+
8
+ from __future__ import annotations
9
+ from importlib import metadata
10
+
11
+ __project__ = 'sphinxnotes.render'
12
+ __author__ = 'Shengyu Zhang'
13
+ __desc__ = 'A framework to define, constrain, and render data in Sphinx documentation'
14
+
15
+ try:
16
+ __version__ = metadata.version('sphinxnotes.render')
17
+ except metadata.PackageNotFoundError:
18
+ __version__ = 'unknown'
19
+
20
+
21
+ ################################################################################
22
+ # Sphinx extension utils.
23
+ ################################################################################
24
+
25
+
26
+ def pre_setup(app):
27
+ app.require_sphinx('7.0')
28
+
29
+
30
+ def post_setup(app):
31
+ return {
32
+ 'version': __version__,
33
+ 'parallel_read_safe': True,
34
+ 'parallel_write_safe': True,
35
+ }
@@ -0,0 +1,300 @@
1
+ """
2
+ sphinxnotes.render.pipeline
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+
5
+ :copyright: Copyright 2026 by the Shengyu Zhang.
6
+ :license: BSD, see LICENSE for details.
7
+
8
+ This module defines pipeline for rendering data to nodes.
9
+
10
+ The Pipline
11
+ ===========
12
+
13
+ 1. Define context: BaseDataSource generates a :class:`pending_node`, which contains:
14
+
15
+ - Context
16
+ - Template for rendering data to markup text
17
+ - Possible extra contexts
18
+
19
+ See also :class:`BaseDataSource`.
20
+
21
+ 2. Render data: the ``pending_node`` nodes will be rendered
22
+ (by calling :meth:`pending_node.render`) at some point, depending on
23
+ :attr:`pending_node.template.phase`.
24
+
25
+ The one who calls ``pending_node.render`` is called ``Host``.
26
+ The ``Host`` host is responsible for rendering the markup text into docutils
27
+ nodes (See :class:`MarkupRenderer`).
28
+
29
+ Phases:
30
+
31
+ :``Phase.Parsing``:
32
+ Called by BaseDataSource ('s subclasses)
33
+
34
+ :``Phase.Parsed``:
35
+ Called by :class:`ParsedHookTransform`.
36
+
37
+ :``Phase.Resolving``:
38
+ Called by :class:`ResolvingHookTransform`.
39
+
40
+ How context be rendered ``list[nodes.Node]``
41
+ ============================================
42
+
43
+ .. seealso:: :meth:`.ctxnodes.pending_node.render`.
44
+
45
+ """
46
+
47
+ from __future__ import annotations
48
+ from typing import TYPE_CHECKING, override, final, cast
49
+ from abc import abstractmethod, ABC
50
+
51
+ from docutils import nodes
52
+ from sphinx.util import logging
53
+ from sphinx.util.docutils import SphinxDirective, SphinxRole
54
+ from sphinx.transforms import SphinxTransform
55
+ from sphinx.transforms.post_transforms import SphinxPostTransform, ReferencesResolver
56
+
57
+ from .render import HostWrapper, Phase, Template, Host, ParseHost, TransformHost
58
+ from .ctx import PendingContext, ResolvedContext
59
+ from .ctxnodes import pending_node
60
+ from .extractx import ExtraContextGenerator
61
+
62
+
63
+ if TYPE_CHECKING:
64
+ from sphinx.application import Sphinx
65
+
66
+ logger = logging.getLogger(__name__)
67
+
68
+
69
+ class Pipeline(ABC):
70
+ """
71
+ The core class defines the pipleing of rendering :class:`pending_node`s.
72
+
73
+ Subclass is responsible to:
74
+
75
+ - call ``queue_xxx`` to add pendin nodes into queue.
76
+ - override :meth:`process_pending_node` to control when a pending node gets
77
+ rendered. In this method subclass can also call ``queue_xxx`` to add more
78
+ pending nodes.
79
+ - call :meth:`render_queue` to process all queued nodes and
80
+ returns any that couldn't be rendered in the current phase.
81
+
82
+ See Also:
83
+
84
+ - :class:`BaseDataSource`: Context source implementation and hook for Phase.Parsing
85
+ - :class:`ParsedHookTransform`: Built-in hook for Phase.Parsed
86
+ - :class:`ResolvingHookTransform`: Built-in hook for Phase.Resolving
87
+ """
88
+
89
+ #: Queue of pending node to be rendered.
90
+ _q: list[pending_node] | None = None
91
+
92
+ """Methods to be overrided."""
93
+
94
+ def process_pending_node(self, n: pending_node) -> bool:
95
+ """
96
+ You can add hooks to pending node here.
97
+
98
+ Return ``true`` if you want to render the pending node *now*,
99
+ otherwise it will be inserted to doctree directly andwaiting to later
100
+ rendering
101
+ """
102
+ ...
103
+
104
+ """Helper method for subclasses."""
105
+
106
+ @final
107
+ def queue_pending_node(self, n: pending_node) -> None:
108
+ if not self._q:
109
+ self._q = []
110
+ self._q.append(n)
111
+
112
+ @final
113
+ def queue_context(
114
+ self, ctx: PendingContext | ResolvedContext, tmpl: Template
115
+ ) -> pending_node:
116
+ pending = pending_node(ctx, tmpl)
117
+ self.queue_pending_node(pending)
118
+ return pending
119
+
120
+ @final
121
+ def render_queue(self) -> list[pending_node]:
122
+ """
123
+ Try rendering all pending nodes in queue.
124
+
125
+ If the timing(Phase) is ok, :class:`pending_node` will be rendered
126
+ (pending.rendered = True); otherwise, the pending node is unchanged.
127
+
128
+ If the pending node is already inserted to document, it will not be return.
129
+ And the corrsponding rendered node will replace it too.
130
+ """
131
+
132
+ logger.debug(
133
+ f'{type(self)} is running its render queue, '
134
+ f'{len(self._q or [])} node(s) to render'
135
+ )
136
+ ns = []
137
+ while self._q:
138
+ pending = self._q.pop()
139
+
140
+ ok = self.process_pending_node(pending)
141
+ logger.debug(
142
+ f'{type(self)} is trying to render '
143
+ f'{pending.source}:{pending.line}, ok? {ok}'
144
+ )
145
+
146
+ if not ok:
147
+ ns.append(pending)
148
+ continue
149
+
150
+ # Generate global extra context for later use.
151
+ ExtraContextGenerator(pending).on_anytime()
152
+
153
+ host = cast(Host, self)
154
+ pending.render(host)
155
+
156
+ if pending.parent is None:
157
+ ns.append(pending)
158
+ continue
159
+
160
+ if pending.inline:
161
+ host_ = HostWrapper(host)
162
+ pending.unwrap_and_replace_self_inline((host_.doctree, pending.parent))
163
+ else:
164
+ pending.unwrap_and_replace_self()
165
+
166
+ logger.debug(
167
+ f'{type(self)} runs out of its render queue, '
168
+ f'{len(self._q or [])} node(s) hanging'
169
+ )
170
+
171
+ return ns
172
+
173
+
174
+ class BaseContextSource(Pipeline):
175
+ """
176
+ Abstract base class for generateing context, as the source of the rendering
177
+ pipeline.
178
+
179
+ This class also responsible to render context in Phase.Parsing. So the final
180
+ implementations MUST be subclass of :class:`SphinxDirective` or
181
+ :class:`SphinxRole`, which provide the execution context and interface for
182
+ processing reStructuredText markup.
183
+ """
184
+
185
+ """Methods to be implemented."""
186
+
187
+ @abstractmethod
188
+ def current_context(self) -> PendingContext | ResolvedContext:
189
+ """Return the context to be rendered."""
190
+ ...
191
+
192
+ @abstractmethod
193
+ def current_template(self) -> Template:
194
+ """
195
+ Return the template for rendering the context.
196
+
197
+ This method should be implemented to provide the Jinja2 template
198
+ that will render the context into markup text. The template determines
199
+ the phase at which rendering occurs.
200
+
201
+ Returns:
202
+ The template to use for rendering.
203
+ """
204
+ ...
205
+
206
+ """Methods override from parent."""
207
+
208
+ @override
209
+ def process_pending_node(self, n: pending_node) -> bool:
210
+ host = cast(ParseHost, self)
211
+
212
+ # Set source and line.
213
+ host.set_source_info(n)
214
+ # Generate and save parsing phase extra context for later use.
215
+ ExtraContextGenerator(n).on_parsing(host)
216
+
217
+ return n.template.phase == Phase.Parsing
218
+
219
+
220
+ class BaseContextDirective(BaseContextSource, SphinxDirective):
221
+ @override
222
+ def run(self) -> list[nodes.Node]:
223
+ self.queue_context(self.current_context(), self.current_template())
224
+
225
+ ns = []
226
+ for x in self.render_queue():
227
+ if not x.rendered:
228
+ ns.append(x)
229
+ continue
230
+ ns += x.unwrap()
231
+
232
+ return ns
233
+
234
+
235
+ class BaseContextRole(BaseContextSource, SphinxRole):
236
+ @override
237
+ def process_pending_node(self, n: pending_node) -> bool:
238
+ n.inline = True
239
+ return super().process_pending_node(n)
240
+
241
+ @override
242
+ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]:
243
+ pending = self.queue_context(self.current_context(), self.current_template())
244
+ pending.inline = True
245
+
246
+ ns, msgs = [], []
247
+ for n in self.render_queue():
248
+ if not n.rendered:
249
+ ns.append(n)
250
+ continue
251
+ ns_, msgs_ = n.unwrap_inline(self.inliner)
252
+ ns += ns_
253
+ msgs += msgs_
254
+
255
+ return ns, msgs
256
+
257
+
258
+ class ParsedHookTransform(SphinxTransform, Pipeline):
259
+ # Before almost all others.
260
+ default_priority = 100
261
+
262
+ @override
263
+ def process_pending_node(self, n: pending_node) -> bool:
264
+ ExtraContextGenerator(n).on_parsed(cast(TransformHost, self))
265
+ return n.template.phase == Phase.Parsed
266
+
267
+ @override
268
+ def apply(self, **kwargs):
269
+ for pending in self.document.findall(pending_node):
270
+ self.queue_pending_node(pending)
271
+
272
+ for n in self.render_queue():
273
+ ...
274
+
275
+
276
+ class ResolvingHookTransform(SphinxPostTransform, Pipeline):
277
+ # After resolving pending_xref
278
+ default_priority = (ReferencesResolver.default_priority or 10) + 5
279
+
280
+ @override
281
+ def process_pending_node(self, n: pending_node) -> bool:
282
+ ExtraContextGenerator(n).on_post_transform(cast(TransformHost, self))
283
+ return n.template.phase == Phase.Resolving
284
+
285
+ @override
286
+ def apply(self, **kwargs):
287
+ for pending in self.document.findall(pending_node):
288
+ self.queue_pending_node(pending)
289
+ ns = self.render_queue()
290
+
291
+ # NOTE: Should no node left.
292
+ assert len(ns) == 0
293
+
294
+
295
+ def setup(app: Sphinx) -> None:
296
+ # Hook for Phase.Parsed.
297
+ app.add_transform(ParsedHookTransform)
298
+
299
+ # Hook for Phase.Resolving.
300
+ app.add_post_transform(ResolvingHookTransform)
File without changes
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+
5
+ from docutils import nodes
6
+ from sphinx.transforms import SphinxTransform
7
+ from sphinx.util.docutils import SphinxDirective, SphinxRole
8
+
9
+
10
+ class Phase(Enum):
11
+ Parsing = 'parsing'
12
+ Parsed = 'parsed'
13
+ Resolving = 'resolving'
14
+
15
+ @classmethod
16
+ def default(cls) -> Phase:
17
+ return cls.Parsing
18
+
19
+
20
+ @dataclass
21
+ class Template:
22
+ #: Jinja template for rendering the context.
23
+ text: str
24
+ #: The render phase.
25
+ phase: Phase = Phase.default()
26
+ #: Enable debug output (shown as :class:`nodes.system_message` in document.)
27
+ debug: bool = False
28
+
29
+
30
+ # Possible render host of :meth:`pending_node.render`.
31
+ type Host = ParseHost | TransformHost
32
+ # Host of source parse phase (Phase.Parsing, Phase.Parsed).
33
+ type ParseHost = SphinxDirective | SphinxRole
34
+ # Host of source parse phase (Phase.Parsing, Phase.Parsed).
35
+ type TransformHost = SphinxTransform
36
+
37
+
38
+ @dataclass
39
+ class HostWrapper:
40
+ v: Host
41
+
42
+ @property
43
+ def doctree(self) -> nodes.document:
44
+ if isinstance(self.v, SphinxDirective):
45
+ return self.v.state.document
46
+ elif isinstance(self.v, SphinxRole):
47
+ return self.v.inliner.document
48
+ elif isinstance(self.v, SphinxTransform):
49
+ return self.v.document
50
+ else:
51
+ raise NotImplementedError
52
+
53
+ @property
54
+ def parent(self) -> nodes.Element | None:
55
+ if isinstance(self.v, SphinxDirective):
56
+ return self.v.state.parent
57
+ elif isinstance(self.v, SphinxRole):
58
+ return self.v.inliner.parent
59
+ else:
60
+ return None