sphinx-examples-as-code 0.1.0__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.
- sphinx_examples_as_code/__init__.py +711 -0
- sphinx_examples_as_code/_version.py +24 -0
- sphinx_examples_as_code-0.1.0.dist-info/METADATA +106 -0
- sphinx_examples_as_code-0.1.0.dist-info/RECORD +6 -0
- sphinx_examples_as_code-0.1.0.dist-info/WHEEL +5 -0
- sphinx_examples_as_code-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"""Generate downloadable Python/Jupyter files from docstring "Examples" sections.
|
|
2
|
+
|
|
3
|
+
See the README for configuration options and the full conversion rules.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import re
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
from urllib.parse import urljoin
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
from docutils import nodes
|
|
20
|
+
from sphinx import addnodes
|
|
21
|
+
from sphinx.errors import ConfigError
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from sphinx.application import Sphinx
|
|
25
|
+
from sphinx.config import Config
|
|
26
|
+
|
|
27
|
+
# Node types marking the start of another section - bound the end of an
|
|
28
|
+
# Examples span. desc/index are included so a class's Examples section
|
|
29
|
+
# doesn't swallow its members, which numpydoc renders as flat siblings
|
|
30
|
+
# inside the same desc_content.
|
|
31
|
+
_BOUNDARY_TYPES = (
|
|
32
|
+
nodes.rubric,
|
|
33
|
+
nodes.title,
|
|
34
|
+
nodes.section,
|
|
35
|
+
addnodes.desc,
|
|
36
|
+
addnodes.index,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Node types with no textual content worth keeping under any circumstance.
|
|
40
|
+
_IGNORED_TYPES = (nodes.image, nodes.figure, nodes.comment, nodes.raw)
|
|
41
|
+
|
|
42
|
+
# sphinx-design containers, matched by CSS class rather than node type so
|
|
43
|
+
# this doesn't need to import sphinx_design. Dropped entirely: a dropdown's
|
|
44
|
+
# content isn't part of the visible example, and a tab-set here only holds
|
|
45
|
+
# figures (already ignored) plus tab-label cruft.
|
|
46
|
+
_SKIP_SUBTREE_CLASSES = ('sd-dropdown', 'sd-tab-set')
|
|
47
|
+
|
|
48
|
+
_CONTAINER_TYPES = (
|
|
49
|
+
nodes.bullet_list,
|
|
50
|
+
nodes.enumerated_list,
|
|
51
|
+
nodes.definition_list,
|
|
52
|
+
nodes.definition_list_item,
|
|
53
|
+
nodes.list_item,
|
|
54
|
+
nodes.definition,
|
|
55
|
+
nodes.term,
|
|
56
|
+
nodes.classifier,
|
|
57
|
+
nodes.block_quote,
|
|
58
|
+
nodes.container,
|
|
59
|
+
nodes.compound,
|
|
60
|
+
addnodes.versionmodified,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Fixed-label admonitions (.. note::, .. warning::, .. seealso::, ...) - as
|
|
64
|
+
# opposed to the generic .. admonition:: Custom Title, handled separately.
|
|
65
|
+
_ADMONITION_LABELS = {
|
|
66
|
+
nodes.attention: 'ATTENTION',
|
|
67
|
+
nodes.caution: 'CAUTION',
|
|
68
|
+
nodes.danger: 'DANGER',
|
|
69
|
+
nodes.error: 'ERROR',
|
|
70
|
+
nodes.hint: 'HINT',
|
|
71
|
+
nodes.important: 'IMPORTANT',
|
|
72
|
+
nodes.note: 'NOTE',
|
|
73
|
+
addnodes.seealso: 'SEE ALSO',
|
|
74
|
+
nodes.tip: 'TIP',
|
|
75
|
+
nodes.warning: 'WARNING',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_PYTHON_LANGUAGES = ('python', 'py', 'python3')
|
|
79
|
+
|
|
80
|
+
# A chunk of generated lines tagged with how it should be spaced relative to
|
|
81
|
+
# its neighbors when segments are joined (see ``_join_segments``):
|
|
82
|
+
# 'code' real Python source
|
|
83
|
+
# 'text' a plain comment (prose, a paragraph, ...)
|
|
84
|
+
# 'directive' a comment block that must be visually set off with a blank
|
|
85
|
+
# line both before and after it (the title header; a
|
|
86
|
+
# ``# NOTE:``-style admonition block)
|
|
87
|
+
Segment = tuple[str, list[str]]
|
|
88
|
+
|
|
89
|
+
# Replacements for non-ASCII chars
|
|
90
|
+
ASCII_REPLACEMENTS = str.maketrans(
|
|
91
|
+
{
|
|
92
|
+
'\u2018': "'", # LEFT SINGLE QUOTATION MARK
|
|
93
|
+
'\u2019': "'", # RIGHT SINGLE QUOTATION MARK
|
|
94
|
+
'\u201c': '"', # LEFT DOUBLE QUOTATION MARK
|
|
95
|
+
'\u201d': '"', # RIGHT DOUBLE QUOTATION MARK
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _has_class(node: nodes.Node, css_class: str) -> bool:
|
|
101
|
+
getter = getattr(node, 'get', None)
|
|
102
|
+
return bool(getter) and css_class in getter('classes', [])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_examples_heading(node: nodes.Node) -> bool:
|
|
106
|
+
"""Check whether ``node`` is a heading (rubric or title) named "Examples"."""
|
|
107
|
+
return (
|
|
108
|
+
isinstance(node, (nodes.rubric, nodes.title))
|
|
109
|
+
and node.astext().strip().lower() == 'examples'
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_see_also_heading(node: nodes.Node) -> bool:
|
|
114
|
+
r"""Check whether ``node`` is a heading (rubric or title) named "See Also".
|
|
115
|
+
|
|
116
|
+
Two written forms: a bare ``.. rubric:: See Also`` (flat sibling), or
|
|
117
|
+
``See Also\\n--------`` underline text (docutils nests it as a section).
|
|
118
|
+
Both get the same treatment as a ``.. seealso::`` directive.
|
|
119
|
+
"""
|
|
120
|
+
return (
|
|
121
|
+
isinstance(node, (nodes.rubric, nodes.title))
|
|
122
|
+
and node.astext().strip().lower() == 'see also'
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_see_also_section(node: nodes.Node) -> bool:
|
|
127
|
+
"""Check whether ``node`` is a nested section titled "See Also"."""
|
|
128
|
+
return (
|
|
129
|
+
isinstance(node, nodes.section)
|
|
130
|
+
and bool(node.children)
|
|
131
|
+
and _is_see_also_heading(node.children[0])
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class _RenderContext:
|
|
137
|
+
"""State threaded through the conversion functions for one output format."""
|
|
138
|
+
|
|
139
|
+
app: Sphinx
|
|
140
|
+
docname: str
|
|
141
|
+
fmt: str # 'py' or 'ipynb'
|
|
142
|
+
in_see_also: bool = False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _resolve_link_url(node: nodes.reference, ctx: _RenderContext) -> str | None:
|
|
146
|
+
"""Resolve a reference node's target to an absolute URL, if possible.
|
|
147
|
+
|
|
148
|
+
Returns ``None`` for an unresolved target or when no base URL is
|
|
149
|
+
configured -- a standalone downloaded file needs an absolute URL, and
|
|
150
|
+
Sphinx's own ``refuri``/``refid`` are only meaningful relative to the
|
|
151
|
+
current page.
|
|
152
|
+
"""
|
|
153
|
+
refuri = node.get('refuri')
|
|
154
|
+
if refuri and urlsplit(refuri).netloc:
|
|
155
|
+
return refuri # already absolute (an external hyperlink)
|
|
156
|
+
|
|
157
|
+
base_url = ctx.app.config.sphinx_examples_as_code_base_url
|
|
158
|
+
if not base_url:
|
|
159
|
+
return None
|
|
160
|
+
current_page_url = urljoin(base_url, ctx.app.builder.get_target_uri(ctx.docname))
|
|
161
|
+
|
|
162
|
+
if refuri:
|
|
163
|
+
return urljoin(current_page_url, refuri)
|
|
164
|
+
|
|
165
|
+
refid = node.get('refid')
|
|
166
|
+
if refid:
|
|
167
|
+
return f'{current_page_url}#{refid}'
|
|
168
|
+
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _add_comment(lines: list[str], text: str) -> None:
|
|
173
|
+
"""Append ``text`` to ``lines`` as one or more Python comment lines."""
|
|
174
|
+
for line in text.splitlines():
|
|
175
|
+
line_ = line.rstrip().translate(ASCII_REPLACEMENTS)
|
|
176
|
+
lines.append(f'# {line_}' if line_ else '#')
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# Doctest/code-block content is preformatted, so docutils never resolves
|
|
180
|
+
# RST markup (xref roles, hyperlinks) written inside a comment there - it
|
|
181
|
+
# passes through as raw text. These clean it up without touching real code.
|
|
182
|
+
_STRAY_XREF_RE = re.compile(r':(?:py:)?\w+:`([^`<>]+?)\s*(?:<[^<>]+>)?`')
|
|
183
|
+
_STRAY_HYPERLINK_RE = re.compile(r'`([^`<>]+?)\s*(?:<([^`<>]+)>)?`_+')
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _clean_stray_rst_markup(text: str) -> str:
|
|
187
|
+
"""Strip unparsed cross-reference/hyperlink RST syntax from a comment line."""
|
|
188
|
+
text = _STRAY_XREF_RE.sub(r'\1', text)
|
|
189
|
+
return _STRAY_HYPERLINK_RE.sub(
|
|
190
|
+
lambda m: f'{m.group(1)} <{m.group(2)}>' if m.group(2) else m.group(1), text
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _clean_code_comment(line: str) -> str:
|
|
195
|
+
"""Apply stray-markup cleanup and ASCII replacements to comment lines."""
|
|
196
|
+
if line.lstrip().startswith('#'):
|
|
197
|
+
line = _clean_stray_rst_markup(line)
|
|
198
|
+
line = line.translate(ASCII_REPLACEMENTS)
|
|
199
|
+
return line
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _render_reference(node: nodes.reference, ctx: _RenderContext) -> str:
|
|
203
|
+
"""Render a resolved or unresolved cross-reference/hyperlink.
|
|
204
|
+
|
|
205
|
+
The target URL, if resolved, is used as a clickable markdown link in
|
|
206
|
+
notebooks (always), and as literal URL text in ``.py`` (only within a
|
|
207
|
+
"See Also" part -- elsewhere in ``.py`` the link is simply omitted).
|
|
208
|
+
"""
|
|
209
|
+
display = node.astext()
|
|
210
|
+
is_code = any(isinstance(child, nodes.literal) for child in node.children)
|
|
211
|
+
url = _resolve_link_url(node, ctx)
|
|
212
|
+
|
|
213
|
+
if url is None:
|
|
214
|
+
return f'`{display}`' if is_code else display
|
|
215
|
+
|
|
216
|
+
if ctx.fmt == 'ipynb':
|
|
217
|
+
return f'[`{display}`]({url})' if is_code else f'[{display}]({url})'
|
|
218
|
+
|
|
219
|
+
if ctx.in_see_also:
|
|
220
|
+
# Surrounding newlines set this off on its own line; _add_comment
|
|
221
|
+
# splits on them like any other multi-line text.
|
|
222
|
+
return f'\n{display} {url}\n'
|
|
223
|
+
|
|
224
|
+
return f'`{display}`' if is_code else display
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _render_inline(node: nodes.Node, ctx: _RenderContext) -> str:
|
|
228
|
+
"""Render a node's inline content to a plain string.
|
|
229
|
+
|
|
230
|
+
References go through ``_render_reference``. Other code-like spans
|
|
231
|
+
(double-backtick literals, an unresolved reference's inner literal) are
|
|
232
|
+
backtick-wrapped; everything else is flattened to plain text.
|
|
233
|
+
"""
|
|
234
|
+
if isinstance(node, nodes.reference):
|
|
235
|
+
return _render_reference(node, ctx)
|
|
236
|
+
if isinstance(node, nodes.literal):
|
|
237
|
+
return f'`{node.astext()}`'
|
|
238
|
+
if isinstance(node, (nodes.image, nodes.figure, nodes.raw, nodes.comment)):
|
|
239
|
+
return ''
|
|
240
|
+
if isinstance(node, nodes.Text):
|
|
241
|
+
return str(node)
|
|
242
|
+
if hasattr(node, 'children') and node.children:
|
|
243
|
+
return ''.join(_render_inline(child, ctx) for child in node.children)
|
|
244
|
+
return node.astext()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _join_segments(segments: list[Segment]) -> list[str]:
|
|
248
|
+
"""Flatten segments into lines, applying inter-segment spacing rules.
|
|
249
|
+
|
|
250
|
+
- a blank line always follows a ``code`` segment, whatever comes next
|
|
251
|
+
- a ``directive`` segment always gets a blank line both before and
|
|
252
|
+
after it
|
|
253
|
+
- otherwise (e.g. prose directly above a code block, or two prose
|
|
254
|
+
segments back to back), no blank line is forced
|
|
255
|
+
"""
|
|
256
|
+
lines: list[str] = []
|
|
257
|
+
prev_kind: str | None = None
|
|
258
|
+
for kind, seg_lines in segments:
|
|
259
|
+
if not seg_lines:
|
|
260
|
+
continue
|
|
261
|
+
need_blank = prev_kind is not None and (
|
|
262
|
+
prev_kind == 'code' or kind == 'directive' or prev_kind == 'directive'
|
|
263
|
+
)
|
|
264
|
+
if need_blank:
|
|
265
|
+
lines.append('')
|
|
266
|
+
lines.extend(seg_lines)
|
|
267
|
+
prev_kind = kind
|
|
268
|
+
return lines
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _convert_doctest_block(node: nodes.doctest_block) -> list[Segment]:
|
|
272
|
+
"""Convert a doctest block, stripping ``>>> ``/``... `` prompts.
|
|
273
|
+
|
|
274
|
+
Non-prompted, non-blank lines are expected doctest *output* -- we only
|
|
275
|
+
care about the input code, so those are dropped entirely rather than
|
|
276
|
+
kept as comments.
|
|
277
|
+
"""
|
|
278
|
+
lines: list[str] = []
|
|
279
|
+
has_code = False
|
|
280
|
+
for line in node.astext().splitlines():
|
|
281
|
+
if line.startswith('>>> ') or line == '>>>':
|
|
282
|
+
lines.append(_clean_code_comment(line[4:]))
|
|
283
|
+
has_code = True
|
|
284
|
+
elif line.startswith('... ') or line == '...':
|
|
285
|
+
lines.append(_clean_code_comment(line[4:]))
|
|
286
|
+
elif not line.strip():
|
|
287
|
+
lines.append('')
|
|
288
|
+
# else: doctest output line - dropped
|
|
289
|
+
if not has_code:
|
|
290
|
+
return []
|
|
291
|
+
while lines and not lines[-1].strip():
|
|
292
|
+
lines.pop()
|
|
293
|
+
return [('code', lines)]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _convert_literal_block(node: nodes.literal_block) -> list[Segment]:
|
|
297
|
+
"""Convert a ``.. code-block::``. Python blocks stay code, others become comments."""
|
|
298
|
+
language = node.get('language', '')
|
|
299
|
+
if language in _PYTHON_LANGUAGES:
|
|
300
|
+
lines = [_clean_code_comment(line) for line in node.astext().splitlines()]
|
|
301
|
+
while lines and not lines[-1].strip():
|
|
302
|
+
lines.pop()
|
|
303
|
+
return [('code', lines)] if lines else []
|
|
304
|
+
text = node.astext().strip()
|
|
305
|
+
if not text:
|
|
306
|
+
return []
|
|
307
|
+
comment_lines: list[str] = []
|
|
308
|
+
_add_comment(comment_lines, text)
|
|
309
|
+
return [('text', comment_lines)]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _convert_admonition(
|
|
313
|
+
node: nodes.Element, label: str, ctx: _RenderContext, *, skip_first_title: bool = False
|
|
314
|
+
) -> list[Segment]:
|
|
315
|
+
"""Convert an admonition-like container to a ``# LABEL:`` directive segment."""
|
|
316
|
+
inner_ctx = replace(ctx, in_see_also=True) if label.upper() == 'SEE ALSO' else ctx
|
|
317
|
+
inner: list[Segment] = [('text', [f'# {label}:'])]
|
|
318
|
+
for child in node.children:
|
|
319
|
+
if skip_first_title and isinstance(child, nodes.title):
|
|
320
|
+
continue
|
|
321
|
+
inner.extend(_convert_node(child, inner_ctx))
|
|
322
|
+
return [('directive', _join_segments(inner))]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _convert_node(node: nodes.Node, ctx: _RenderContext) -> list[Segment]:
|
|
326
|
+
"""Convert ``node`` into zero or more segments."""
|
|
327
|
+
if any(_has_class(node, css_class) for css_class in _SKIP_SUBTREE_CLASSES):
|
|
328
|
+
return []
|
|
329
|
+
if isinstance(node, _IGNORED_TYPES):
|
|
330
|
+
return []
|
|
331
|
+
if isinstance(node, nodes.doctest_block):
|
|
332
|
+
return _convert_doctest_block(node)
|
|
333
|
+
if isinstance(node, nodes.literal_block):
|
|
334
|
+
return _convert_literal_block(node)
|
|
335
|
+
if type(node) in _ADMONITION_LABELS:
|
|
336
|
+
return _convert_admonition(node, _ADMONITION_LABELS[type(node)], ctx)
|
|
337
|
+
if _is_see_also_section(node):
|
|
338
|
+
# a hand-written "See Also\n--------" heading nests as a full
|
|
339
|
+
# section rather than a flat sibling - treat it like the
|
|
340
|
+
# ``.. seealso::`` directive it's standing in for.
|
|
341
|
+
return _convert_admonition(node, 'SEE ALSO', ctx, skip_first_title=True)
|
|
342
|
+
if isinstance(node, nodes.admonition):
|
|
343
|
+
# generic ``.. admonition:: Custom Title`` - use its own title as the label
|
|
344
|
+
title_node = node.next_node(nodes.title)
|
|
345
|
+
label = title_node.astext().strip() if title_node is not None else 'NOTE'
|
|
346
|
+
return _convert_admonition(node, label, ctx, skip_first_title=True)
|
|
347
|
+
if isinstance(node, _CONTAINER_TYPES):
|
|
348
|
+
segments: list[Segment] = []
|
|
349
|
+
for child in node.children:
|
|
350
|
+
segments.extend(_convert_node(child, ctx))
|
|
351
|
+
return segments
|
|
352
|
+
|
|
353
|
+
# Plain text-bearing nodes (paragraphs, etc.) - render inline content,
|
|
354
|
+
# backticking code-like cross-references/literals along the way.
|
|
355
|
+
text = _render_inline(node, ctx).strip()
|
|
356
|
+
if not text:
|
|
357
|
+
return []
|
|
358
|
+
comment_lines: list[str] = []
|
|
359
|
+
_add_comment(comment_lines, text)
|
|
360
|
+
return [('text', comment_lines)]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _has_real_code(source: str) -> bool:
|
|
364
|
+
"""Check whether ``source`` contains at least one executable statement."""
|
|
365
|
+
try:
|
|
366
|
+
tree = ast.parse(source)
|
|
367
|
+
except SyntaxError:
|
|
368
|
+
return False
|
|
369
|
+
|
|
370
|
+
return any(
|
|
371
|
+
not isinstance(stmt, ast.Expr) or not isinstance(stmt.value, ast.Constant)
|
|
372
|
+
for stmt in tree.body
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _span_from(parent: nodes.Element, start: int) -> int:
|
|
377
|
+
"""Return the end index (exclusive) of a content span starting at ``start``.
|
|
378
|
+
|
|
379
|
+
Extends until the next boundary-type node, or to the end of ``parent``'s
|
|
380
|
+
children. A "See Also" heading/section is not a boundary -- otherwise it
|
|
381
|
+
would truncate everything after it.
|
|
382
|
+
"""
|
|
383
|
+
end = start
|
|
384
|
+
for i in range(start, len(parent.children)):
|
|
385
|
+
child = parent.children[i]
|
|
386
|
+
if _is_see_also_heading(child) or _is_see_also_section(child):
|
|
387
|
+
end = i + 1
|
|
388
|
+
continue
|
|
389
|
+
if isinstance(child, _BOUNDARY_TYPES):
|
|
390
|
+
break
|
|
391
|
+
end = i + 1
|
|
392
|
+
return end
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _find_external_see_also(parent: nodes.Element, start: int, end: int) -> nodes.Node | None:
|
|
396
|
+
"""Find a "See Also" part sited outside the normal ``[start, end)`` span.
|
|
397
|
+
|
|
398
|
+
numpydoc's own "See Also" field is canonically reordered to sit before
|
|
399
|
+
"Examples", so it would otherwise never be seen at all.
|
|
400
|
+
"""
|
|
401
|
+
for i, child in enumerate(parent.children):
|
|
402
|
+
if start <= i < end:
|
|
403
|
+
continue
|
|
404
|
+
if type(child) in _ADMONITION_LABELS and _ADMONITION_LABELS[type(child)] == 'SEE ALSO':
|
|
405
|
+
return child
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _examples_spans(doctree: nodes.document) -> list[tuple[nodes.Element, int, int, nodes.Node]]:
|
|
410
|
+
"""Find every "Examples" heading's content span.
|
|
411
|
+
|
|
412
|
+
Returns a list of ``(parent, start, end, heading)`` tuples.
|
|
413
|
+
"""
|
|
414
|
+
spans = []
|
|
415
|
+
for heading in doctree.findall(_is_examples_heading):
|
|
416
|
+
parent = heading.parent
|
|
417
|
+
start = parent.index(heading) + 1
|
|
418
|
+
end = _span_from(parent, start)
|
|
419
|
+
spans.append((parent, start, end, heading))
|
|
420
|
+
return spans
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _qualified_name_for(node: nodes.Node, docname: str, counter: int) -> str:
|
|
424
|
+
"""Best-effort identifier used to name the generated file and its title header."""
|
|
425
|
+
ancestor: nodes.Node | None = node.parent
|
|
426
|
+
while ancestor is not None:
|
|
427
|
+
if isinstance(ancestor, addnodes.desc):
|
|
428
|
+
signature = ancestor.next_node(addnodes.desc_signature)
|
|
429
|
+
if signature is not None and signature.get('ids'):
|
|
430
|
+
return signature['ids'][0]
|
|
431
|
+
ancestor = ancestor.parent
|
|
432
|
+
base = Path(docname).name or docname
|
|
433
|
+
return f'{base}-example-{counter}'
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _header_segment(qualified_name: str) -> Segment:
|
|
437
|
+
"""Build the title-header segment, e.g. ``# pyvista.read examples`` + underline."""
|
|
438
|
+
title = f'Examples from {qualified_name}'
|
|
439
|
+
underline = '-' * len(title)
|
|
440
|
+
return ('directive', [f'# {title}', f'# {underline}'])
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _strip_comment_prefix(line: str) -> str:
|
|
444
|
+
"""Remove the leading ``# `` (or bare ``#``) from a generated comment line."""
|
|
445
|
+
if line == '#':
|
|
446
|
+
return ''
|
|
447
|
+
return line.removeprefix('# ') if line.startswith('# ') else line.removeprefix('#')
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _segments_to_cells(segments: list[Segment]) -> list[tuple[str, list[str]]]:
|
|
451
|
+
"""Group segments into notebook cells: consecutive runs become one cell each.
|
|
452
|
+
|
|
453
|
+
A run of ``code`` segments becomes a code cell; a run of ``text``/
|
|
454
|
+
``directive`` segments becomes a markdown cell (same spacing rules as
|
|
455
|
+
``_join_segments``, with the ``#`` prefix stripped).
|
|
456
|
+
"""
|
|
457
|
+
cells: list[tuple[str, list[str]]] = []
|
|
458
|
+
run: list[Segment] = []
|
|
459
|
+
run_kind: str | None = None
|
|
460
|
+
|
|
461
|
+
def _flush() -> None:
|
|
462
|
+
if not run:
|
|
463
|
+
return
|
|
464
|
+
joined = _join_segments(run)
|
|
465
|
+
cells.append(
|
|
466
|
+
(
|
|
467
|
+
run_kind,
|
|
468
|
+
[_strip_comment_prefix(line) for line in joined]
|
|
469
|
+
if run_kind == 'markdown'
|
|
470
|
+
else joined,
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
for kind, lines in segments:
|
|
475
|
+
if not lines:
|
|
476
|
+
continue
|
|
477
|
+
cell_kind = 'code' if kind == 'code' else 'markdown'
|
|
478
|
+
if cell_kind != run_kind:
|
|
479
|
+
_flush()
|
|
480
|
+
run, run_kind = [], cell_kind
|
|
481
|
+
run.append((kind, lines))
|
|
482
|
+
_flush()
|
|
483
|
+
return cells
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _cell_source(lines: list[str], kind: str) -> list[str]:
|
|
487
|
+
r"""Format lines the way nbformat expects: each ending in ``\\n`` but the last.
|
|
488
|
+
|
|
489
|
+
Markdown cells get a trailing hard line break (two spaces) on each
|
|
490
|
+
non-blank line -- otherwise adjacent lines collapse into one paragraph,
|
|
491
|
+
since markdown treats a single newline as plain whitespace.
|
|
492
|
+
"""
|
|
493
|
+
if not lines:
|
|
494
|
+
return []
|
|
495
|
+
if kind == 'markdown':
|
|
496
|
+
lines = [f'{line} ' if line else line for line in lines]
|
|
497
|
+
return [f'{line}\n' for line in lines[:-1]] + [lines[-1]]
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _build_notebook(cells: list[tuple[str, list[str]]]) -> dict:
|
|
501
|
+
"""Build a minimal, valid nbformat-4.5 notebook dict from grouped cells."""
|
|
502
|
+
nb_cells = []
|
|
503
|
+
for i, (kind, lines) in enumerate(cells):
|
|
504
|
+
cell: dict = {
|
|
505
|
+
'cell_type': kind,
|
|
506
|
+
'metadata': {},
|
|
507
|
+
'source': _cell_source(lines, kind),
|
|
508
|
+
'id': f'cell-{i}',
|
|
509
|
+
}
|
|
510
|
+
if kind == 'code':
|
|
511
|
+
cell['execution_count'] = None
|
|
512
|
+
cell['outputs'] = []
|
|
513
|
+
nb_cells.append(cell)
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
'cells': nb_cells,
|
|
517
|
+
'metadata': {
|
|
518
|
+
'kernelspec': {
|
|
519
|
+
'display_name': 'Python 3',
|
|
520
|
+
'language': 'python',
|
|
521
|
+
'name': 'python3',
|
|
522
|
+
},
|
|
523
|
+
'language_info': {'name': 'python', 'pygments_lexer': 'ipython3'},
|
|
524
|
+
},
|
|
525
|
+
'nbformat': 4,
|
|
526
|
+
'nbformat_minor': 5,
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _write_download_file(app: Sphinx, name: str, extension: str, content: str) -> str:
|
|
531
|
+
"""Write generated content directly into the builder's downloads dir.
|
|
532
|
+
|
|
533
|
+
Returns the written file's path relative to the downloads directory --
|
|
534
|
+
the value to use as a ``download_reference``'s ``filename``.
|
|
535
|
+
|
|
536
|
+
Writes straight to ``<outdir>/_downloads/...`` instead of registering
|
|
537
|
+
through ``env.dlfiles``, because the HTML builder copies those files
|
|
538
|
+
during ``copy_assets()`` -- before any ``doctree-resolved`` handler
|
|
539
|
+
(this one included) runs, so registering here would be too late.
|
|
540
|
+
"""
|
|
541
|
+
# 32 hex chars, matching Sphinx's own native _downloads/<digest>/... layout.
|
|
542
|
+
digest = hashlib.sha256(content.encode()).hexdigest()[:32]
|
|
543
|
+
safe_name = name.replace('.', '_') if name else 'example'
|
|
544
|
+
filename = f'{safe_name}.{extension}'
|
|
545
|
+
rel_path = f'{digest}/{filename}'
|
|
546
|
+
|
|
547
|
+
out_path = Path(app.outdir) / '_downloads' / digest / filename
|
|
548
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
549
|
+
out_path.write_text(content, encoding='utf-8')
|
|
550
|
+
return rel_path
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _write_source(app: Sphinx, name: str, source: str) -> str:
|
|
554
|
+
"""Write a generated ``.py`` file; see ``_write_download_file``."""
|
|
555
|
+
return _write_download_file(app, name, 'py', source)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _write_notebook(app: Sphinx, name: str, notebook: dict) -> str:
|
|
559
|
+
"""Write a generated ``.ipynb`` file; see ``_write_download_file``."""
|
|
560
|
+
return _write_download_file(app, name, 'ipynb', json.dumps(notebook, indent=1))
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
#: Download link text per format, and the fixed order they're offered in
|
|
564
|
+
#: regardless of how ``sphinx_examples_as_code_formats`` lists them.
|
|
565
|
+
_FORMAT_LABELS = {
|
|
566
|
+
'py': 'Download Python source code',
|
|
567
|
+
'ipynb': 'Download Jupyter notebook',
|
|
568
|
+
}
|
|
569
|
+
_FORMAT_ORDER = ('py', 'ipynb')
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _make_download_node(entries: list[tuple[str, str]]) -> nodes.paragraph:
|
|
573
|
+
"""Build one paragraph holding a download link for each ``(label, rel_path)`` entry."""
|
|
574
|
+
paragraph = nodes.paragraph()
|
|
575
|
+
for i, (label, rel_path) in enumerate(entries):
|
|
576
|
+
if i > 0:
|
|
577
|
+
paragraph += nodes.Text(' | ')
|
|
578
|
+
reference = addnodes.download_reference('', reftarget=rel_path)
|
|
579
|
+
reference['filename'] = rel_path
|
|
580
|
+
reference += nodes.Text(label)
|
|
581
|
+
paragraph += reference
|
|
582
|
+
return paragraph
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _build_segments(nodes_in_span: list[nodes.Node], ctx: _RenderContext) -> list[Segment]:
|
|
586
|
+
"""Convert a span's nodes into segments.
|
|
587
|
+
|
|
588
|
+
A bare ``.. rubric:: See Also`` heading isn't wrapped in a container the
|
|
589
|
+
way ``.. seealso::`` or a nested section are, so this gathers everything
|
|
590
|
+
after it into one merged directive segment instead.
|
|
591
|
+
"""
|
|
592
|
+
segments: list[Segment] = []
|
|
593
|
+
for i, node in enumerate(nodes_in_span):
|
|
594
|
+
if isinstance(node, nodes.rubric) and _is_see_also_heading(node):
|
|
595
|
+
inner_ctx = replace(ctx, in_see_also=True)
|
|
596
|
+
inner: list[Segment] = [('text', ['# SEE ALSO:'])]
|
|
597
|
+
for later_node in nodes_in_span[i + 1 :]:
|
|
598
|
+
inner.extend(_convert_node(later_node, inner_ctx))
|
|
599
|
+
segments.append(('directive', _join_segments(inner)))
|
|
600
|
+
break
|
|
601
|
+
segments.extend(_convert_node(node, ctx))
|
|
602
|
+
return segments
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _process_span(
|
|
606
|
+
app: Sphinx,
|
|
607
|
+
docname: str,
|
|
608
|
+
parent: nodes.Element,
|
|
609
|
+
start: int,
|
|
610
|
+
end: int,
|
|
611
|
+
heading: nodes.Node,
|
|
612
|
+
counter: int,
|
|
613
|
+
position: str,
|
|
614
|
+
formats: list[str],
|
|
615
|
+
) -> None:
|
|
616
|
+
"""Convert one Examples span and insert download link(s) if it has real code."""
|
|
617
|
+
nodes_in_span = list(parent.children[start:end])
|
|
618
|
+
external_see_also = _find_external_see_also(parent, start, end)
|
|
619
|
+
if external_see_also is not None:
|
|
620
|
+
nodes_in_span.append(external_see_also)
|
|
621
|
+
|
|
622
|
+
py_ctx = _RenderContext(app=app, docname=docname, fmt='py')
|
|
623
|
+
py_segments = _build_segments(nodes_in_span, py_ctx)
|
|
624
|
+
|
|
625
|
+
if not any(kind == 'code' for kind, _lines in py_segments):
|
|
626
|
+
return
|
|
627
|
+
|
|
628
|
+
name = _qualified_name_for(heading, docname, counter)
|
|
629
|
+
source = '\n'.join(_join_segments([_header_segment(name), *py_segments])).rstrip() + '\n\n'
|
|
630
|
+
|
|
631
|
+
if not _has_real_code(source):
|
|
632
|
+
return
|
|
633
|
+
|
|
634
|
+
entries = []
|
|
635
|
+
for fmt in _FORMAT_ORDER:
|
|
636
|
+
if fmt not in formats:
|
|
637
|
+
continue
|
|
638
|
+
label = _FORMAT_LABELS[fmt]
|
|
639
|
+
if fmt == 'py':
|
|
640
|
+
rel_path = _write_source(app, name, source)
|
|
641
|
+
else:
|
|
642
|
+
ipynb_ctx = _RenderContext(app=app, docname=docname, fmt='ipynb')
|
|
643
|
+
ipynb_segments = _build_segments(nodes_in_span, ipynb_ctx)
|
|
644
|
+
cells = _segments_to_cells([_header_segment(name), *ipynb_segments])
|
|
645
|
+
rel_path = _write_notebook(app, name, _build_notebook(cells))
|
|
646
|
+
entries.append((label, rel_path))
|
|
647
|
+
|
|
648
|
+
if not entries:
|
|
649
|
+
return
|
|
650
|
+
|
|
651
|
+
download_node = _make_download_node(entries)
|
|
652
|
+
parent.insert(start if position == 'top' else end, download_node)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _process_doctree(app: Sphinx, doctree: nodes.document, docname: str) -> None:
|
|
656
|
+
"""Add a download link to every "Examples" section found on this page."""
|
|
657
|
+
if not getattr(app.builder, 'download_support', False):
|
|
658
|
+
# Only HTML-family builders serve a _downloads/ directory - skip
|
|
659
|
+
# everything else (latex, text, man, epub, ...).
|
|
660
|
+
return
|
|
661
|
+
|
|
662
|
+
position = app.config.sphinx_examples_as_code_link_position
|
|
663
|
+
formats = app.config.sphinx_examples_as_code_formats
|
|
664
|
+
|
|
665
|
+
# Process spans per shared parent, last to first: inserting a download
|
|
666
|
+
# node shifts every later sibling index by one, so this stays correct
|
|
667
|
+
# even with multiple Examples headings under one parent.
|
|
668
|
+
spans = _examples_spans(doctree)
|
|
669
|
+
numbered_spans = [(*span, i + 1) for i, span in enumerate(spans)]
|
|
670
|
+
for parent, start, end, heading, counter in sorted(
|
|
671
|
+
numbered_spans, key=lambda s: (id(s[0]), -s[1])
|
|
672
|
+
):
|
|
673
|
+
_process_span(app, docname, parent, start, end, heading, counter, position, formats)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _validate_base_url(_app: Sphinx, config: Config) -> None:
|
|
677
|
+
"""Validate and normalize ``sphinx_examples_as_code_base_url``.
|
|
678
|
+
|
|
679
|
+
Catches two common typos loudly instead of silently generating wrong
|
|
680
|
+
links: a missing scheme (parses with no netloc at all), and a subpath
|
|
681
|
+
with no trailing slash (``urljoin`` would drop the last segment).
|
|
682
|
+
"""
|
|
683
|
+
base_url = config.sphinx_examples_as_code_base_url
|
|
684
|
+
if not base_url:
|
|
685
|
+
return
|
|
686
|
+
|
|
687
|
+
parsed = urlsplit(base_url)
|
|
688
|
+
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
|
|
689
|
+
msg = (
|
|
690
|
+
f'sphinx_examples_as_code_base_url={base_url!r} does not look like a '
|
|
691
|
+
"valid absolute URL (expected something like 'https://docs.example.com/')."
|
|
692
|
+
)
|
|
693
|
+
raise ConfigError(msg)
|
|
694
|
+
|
|
695
|
+
if not base_url.endswith('/'):
|
|
696
|
+
config.sphinx_examples_as_code_base_url = base_url + '/'
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def setup(app: Sphinx) -> dict: # numpydoc ignore=RT01
|
|
700
|
+
"""Register the extension."""
|
|
701
|
+
app.connect('doctree-resolved', _process_doctree)
|
|
702
|
+
app.connect('config-inited', _validate_base_url)
|
|
703
|
+
app.add_config_value('sphinx_examples_as_code_link_position', 'top', 'env')
|
|
704
|
+
app.add_config_value('sphinx_examples_as_code_formats', ['py', 'ipynb'], 'env')
|
|
705
|
+
app.add_config_value('sphinx_examples_as_code_base_url', None, 'env')
|
|
706
|
+
|
|
707
|
+
return {
|
|
708
|
+
'version': '0.1',
|
|
709
|
+
'parallel_read_safe': True,
|
|
710
|
+
'parallel_write_safe': True,
|
|
711
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'gb5c987a88'
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sphinx-examples-as-code
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sphinx extension for converting docstring examples into downloadable code.
|
|
5
|
+
Author-email: The PyVista Developers <info@pyvista.org>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pyvista/sphinx-examples-as-code
|
|
8
|
+
Keywords: download,examples,sphinx
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Framework :: Sphinx :: Extension
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: pyvista>=0.48
|
|
25
|
+
|
|
26
|
+
# sphinx-examples-as-code
|
|
27
|
+
|
|
28
|
+
A Sphinx extension that turns docstring/page "Examples" sections into downloadable,
|
|
29
|
+
runnable `.py` and/or `.ipynb` files, with a download link inserted into the section.
|
|
30
|
+
|
|
31
|
+
Pages or docstrings without an Examples section are left completely untouched. Adding
|
|
32
|
+
`sphinx_examples_as_code` to `conf.py`'s `extensions` is the only on/off switch.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install sphinx-examples-as-code
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Add it to your Sphinx `conf.py`:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
extensions = [
|
|
44
|
+
...,
|
|
45
|
+
'sphinx_examples_as_code',
|
|
46
|
+
]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Set these in `conf.py`:
|
|
52
|
+
|
|
53
|
+
- `sphinx_examples_as_code_link_position`: where the download link(s) land within the
|
|
54
|
+
Examples section. `'top'` (default) or `'bottom'`.
|
|
55
|
+
- `sphinx_examples_as_code_formats`: which downloads to generate. A list containing
|
|
56
|
+
`'py'`, `'ipynb'`, or both (default). Always offered in that order regardless of how
|
|
57
|
+
the list is written.
|
|
58
|
+
- `sphinx_examples_as_code_base_url`: the site's published base URL (e.g.
|
|
59
|
+
`'https://docs.pyvista.org/'`), used to turn cross-references into absolute links a
|
|
60
|
+
downloaded, standalone file can actually use. `None` (default) means no links are
|
|
61
|
+
generated anywhere. A missing trailing slash is added automatically; a value with no
|
|
62
|
+
scheme or host raises a configuration error at build start.
|
|
63
|
+
|
|
64
|
+
## Conversion rules
|
|
65
|
+
|
|
66
|
+
What happens to the content of an Examples section:
|
|
67
|
+
|
|
68
|
+
- Doctest blocks (`>>> ...` / `... ...`) keep their input lines, prompts stripped, as
|
|
69
|
+
real Python source. Doctest *output* lines are dropped — only the input code matters.
|
|
70
|
+
- `.. code-block:: python` (or `py`) blocks are kept as-is; other languages become
|
|
71
|
+
comments.
|
|
72
|
+
- Admonitions (`.. note::`, `.. warning::`, `.. seealso::`, ...) become a `# LABEL:`
|
|
73
|
+
comment followed by their content as comments. "See Also" is recognized in any of its
|
|
74
|
+
three forms (`.. seealso::`, a bare `.. rubric:: See Also`, or a hand-written `See
|
|
75
|
+
Also` heading) and always renders the same way.
|
|
76
|
+
- Cross-references and inline code (`:class:`, `:meth:`, `:func:`, `:attr:`,
|
|
77
|
+
double-backtick literals, ...) keep their display text, wrapped in backticks (e.g.
|
|
78
|
+
`:class:\`pyvista.Plotter\`` -> `` `pyvista.Plotter` ``). If `..._base_url` is set and
|
|
79
|
+
the reference resolves: `.ipynb` turns it into a clickable link everywhere; `.py` only
|
|
80
|
+
writes the link inside a "See Also" part (as `name url` on its own line) — everywhere
|
|
81
|
+
else in `.py` the link is simply omitted.
|
|
82
|
+
- Plain prose-style references (`:ref:`, `:doc:`) are treated the same way, minus the
|
|
83
|
+
backticks.
|
|
84
|
+
- Everything else text-bearing (prose, captions, other non-Python code) becomes a plain
|
|
85
|
+
`#` comment.
|
|
86
|
+
- Figures/images, raw HTML, and sphinx-design dropdowns/tab-sets are dropped entirely.
|
|
87
|
+
|
|
88
|
+
Generated `.py` files start with a `# Examples from <qualified name>` title header and
|
|
89
|
+
follow a few whitespace conventions so the result reads like normal Python: prose
|
|
90
|
+
directly above a code block stays attached to it, a code block is always followed by a
|
|
91
|
+
blank line, and a directive (header, `# NOTE:`-style block) gets blank lines on both
|
|
92
|
+
sides.
|
|
93
|
+
|
|
94
|
+
Generated `.ipynb` notebooks use the same content, split into alternating code/markdown
|
|
95
|
+
cells instead.
|
|
96
|
+
|
|
97
|
+
A download link is only added if the resulting code contains at least one real
|
|
98
|
+
executable statement.
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
uv sync --group dev
|
|
104
|
+
uv run pytest
|
|
105
|
+
uv run pre-commit run --all-files
|
|
106
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
sphinx_examples_as_code/__init__.py,sha256=ejUFCorohuowDq8WB_3uTYS-oxdFFPDtS5jsL2x8V3s,26072
|
|
2
|
+
sphinx_examples_as_code/_version.py,sha256=1P1uJDJVLvq4CxyoRzz70kVR_PMjt370F6kJ3SXi-Z0,528
|
|
3
|
+
sphinx_examples_as_code-0.1.0.dist-info/METADATA,sha256=85WIHt_GIFtZRPy1W94wpoCyvTP_V5CBmjcFg8Ioabs,4450
|
|
4
|
+
sphinx_examples_as_code-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
5
|
+
sphinx_examples_as_code-0.1.0.dist-info/top_level.txt,sha256=rUCj1TJePAQNgw9_HF7RDm73AqEVeKLB1meAFyC_v-U,24
|
|
6
|
+
sphinx_examples_as_code-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sphinx_examples_as_code
|