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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ sphinx_examples_as_code