sphinx-examples-as-code 0.3.1__tar.gz → 0.3.3__tar.gz

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.
Files changed (40) hide show
  1. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/PKG-INFO +21 -12
  2. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/README.md +20 -11
  3. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/sphinx_examples_as_code/__init__.py +74 -37
  4. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/sphinx_examples_as_code/_version.py +3 -3
  5. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/sphinx_examples_as_code.egg-info/PKG-INFO +21 -12
  6. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/sphinx_examples_as_code.egg-info/scm_version.json +2 -2
  7. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/test_gallery_downloads.py +26 -23
  8. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/test_heading_level_reuse.py +13 -4
  9. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/test_sphinx_examples_as_code.py +134 -32
  10. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/test_tinypages.py +26 -10
  11. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/.github/dependabot.yml +0 -0
  12. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/.github/release.yml +0 -0
  13. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/.github/workflows/ci.yml +0 -0
  14. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/.gitignore +0 -0
  15. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/.pre-commit-config.yaml +0 -0
  16. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/LICENSE +0 -0
  17. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/pyproject.toml +0 -0
  18. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/setup.cfg +0 -0
  19. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/sphinx_examples_as_code.egg-info/SOURCES.txt +0 -0
  20. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/sphinx_examples_as_code.egg-info/dependency_links.txt +0 -0
  21. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/sphinx_examples_as_code.egg-info/requires.txt +0 -0
  22. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/sphinx_examples_as_code.egg-info/scm_file_list.json +0 -0
  23. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/sphinx_examples_as_code.egg-info/top_level.txt +0 -0
  24. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/gallery_fixture/conf.py +0 -0
  25. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/gallery_fixture/examples/GALLERY_HEADER.rst +0 -0
  26. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/gallery_fixture/examples/plot_minimal.py +0 -0
  27. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/gallery_fixture/index.rst +0 -0
  28. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/heading_level_fixture/conf.py +0 -0
  29. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/heading_level_fixture/examples/GALLERY_HEADER.rst +0 -0
  30. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/heading_level_fixture/examples/plot_reused_level.py +0 -0
  31. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/heading_level_fixture/examples/plot_three_levels.py +0 -0
  32. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/heading_level_fixture/index.rst +0 -0
  33. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/single_function_fixture/conf.py +0 -0
  34. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/single_function_fixture/index.rst +0 -0
  35. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/single_function_fixture/mymodule.py +0 -0
  36. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/test_single_function_page.py +0 -0
  37. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/tinypages/conf.py +0 -0
  38. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/tinypages/docstring_cases.py +0 -0
  39. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/tinypages/docstring_cases.rst +0 -0
  40. {sphinx_examples_as_code-0.3.1 → sphinx_examples_as_code-0.3.3}/tests/tinypages/index.rst +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sphinx-examples-as-code
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Sphinx extension for converting docstring examples into downloadable code.
5
5
  Author-email: The PyVista Developers <info@pyvista.org>
6
6
  License-Expression: MIT
@@ -107,9 +107,11 @@ What happens to the content of an Examples section:
107
107
  - `.. code-block:: python` (or `py`) blocks are kept as-is; other languages become
108
108
  comments, set off with blank lines on both sides like any other directive.
109
109
  - Admonitions (`.. note::`, `.. warning::`, `.. seealso::`, ...) become a `# LABEL:`
110
- comment followed by their content as comments. "See Also" is recognized in any of its
111
- three forms (`.. seealso::`, a bare `.. rubric:: See Also`, or a hand-written `See
112
- Also` heading) and always renders the same way.
110
+ comment followed by their content as comments, indented one level under the label in
111
+ `.py` (not `.ipynb`, where leading whitespace within a paragraph has no visible effect
112
+ once rendered). "See Also" is recognized in any of its three forms (`.. seealso::`, a
113
+ bare `.. rubric:: See Also`, or a hand-written `See Also` heading) and always renders
114
+ the same way.
113
115
  - Cross-references and inline code (`:class:`, `:meth:`, `:func:`, `:attr:`,
114
116
  double-backtick literals, ...) keep their display text, wrapped in backticks (e.g.
115
117
  ``:class:`pyvista.Plotter` `` -> `` `pyvista.Plotter` ``). If `html_baseurl` is set and
@@ -154,14 +156,21 @@ Detection is automatic and per-page — any page without a sphinx-gallery downlo
154
156
  is left completely untouched. Gallery pages and ordinary docstring/prose pages (using
155
157
  the Examples-section behavior above) can coexist on the same site.
156
158
 
157
- A `# %%` cell with its own RST heading gets the same title-plus-underline treatment as
158
- the file's own header, and renders as a real Markdown heading in `.ipynb`. Level is
159
- relative to actual RST section nesting, not always one below the file's own header:
160
- level 1 (`<h1>`, `# Title` + `# ====`) for the file's own header, or a cell heading that
161
- reuses the page title's own underline character; level 2 (`<h2>`, `# Title` + `# ----`)
162
- for a cell heading nested under it (the common case), or one level below whatever
163
- heading precedes it. Level 3 and deeper use ATX syntax instead (`# ### Title`), since
164
- Markdown's underline-style headings only support two levels.
159
+ A `# %%` cell with its own RST heading gets the same header treatment as the file's own
160
+ title, and renders as a real Markdown heading in `.ipynb`. Level is relative to actual
161
+ RST section nesting, not always one below the file's own header: a cell heading nested
162
+ under the page's own title (the common case) is one level below it; one that reuses the
163
+ page title's own underline character is level 1, the same as the file's own header; a
164
+ cell heading nested under *that* is level 2 relative to it, and so on.
165
+
166
+ Each format uses one heading style consistently across every level, rather than mixing
167
+ styles:
168
+
169
+ - `.py` uses an RST-style title + underline, one character per level -- the same
170
+ sequence Sphinx's own documentation uses for sections through sub-paragraphs: `=`, `-`,
171
+ `~`, `^`, `"`, `'` for levels 1 through 6.
172
+ - `.ipynb` always uses ATX syntax (`#`, `##`, `###`, ...), since that's a real Markdown
173
+ heading at any level, whereas an underline only reads as one for the first two.
165
174
 
166
175
  Two things worth knowing before turning this on:
167
176
 
@@ -80,9 +80,11 @@ What happens to the content of an Examples section:
80
80
  - `.. code-block:: python` (or `py`) blocks are kept as-is; other languages become
81
81
  comments, set off with blank lines on both sides like any other directive.
82
82
  - Admonitions (`.. note::`, `.. warning::`, `.. seealso::`, ...) become a `# LABEL:`
83
- comment followed by their content as comments. "See Also" is recognized in any of its
84
- three forms (`.. seealso::`, a bare `.. rubric:: See Also`, or a hand-written `See
85
- Also` heading) and always renders the same way.
83
+ comment followed by their content as comments, indented one level under the label in
84
+ `.py` (not `.ipynb`, where leading whitespace within a paragraph has no visible effect
85
+ once rendered). "See Also" is recognized in any of its three forms (`.. seealso::`, a
86
+ bare `.. rubric:: See Also`, or a hand-written `See Also` heading) and always renders
87
+ the same way.
86
88
  - Cross-references and inline code (`:class:`, `:meth:`, `:func:`, `:attr:`,
87
89
  double-backtick literals, ...) keep their display text, wrapped in backticks (e.g.
88
90
  ``:class:`pyvista.Plotter` `` -> `` `pyvista.Plotter` ``). If `html_baseurl` is set and
@@ -127,14 +129,21 @@ Detection is automatic and per-page — any page without a sphinx-gallery downlo
127
129
  is left completely untouched. Gallery pages and ordinary docstring/prose pages (using
128
130
  the Examples-section behavior above) can coexist on the same site.
129
131
 
130
- A `# %%` cell with its own RST heading gets the same title-plus-underline treatment as
131
- the file's own header, and renders as a real Markdown heading in `.ipynb`. Level is
132
- relative to actual RST section nesting, not always one below the file's own header:
133
- level 1 (`<h1>`, `# Title` + `# ====`) for the file's own header, or a cell heading that
134
- reuses the page title's own underline character; level 2 (`<h2>`, `# Title` + `# ----`)
135
- for a cell heading nested under it (the common case), or one level below whatever
136
- heading precedes it. Level 3 and deeper use ATX syntax instead (`# ### Title`), since
137
- Markdown's underline-style headings only support two levels.
132
+ A `# %%` cell with its own RST heading gets the same header treatment as the file's own
133
+ title, and renders as a real Markdown heading in `.ipynb`. Level is relative to actual
134
+ RST section nesting, not always one below the file's own header: a cell heading nested
135
+ under the page's own title (the common case) is one level below it; one that reuses the
136
+ page title's own underline character is level 1, the same as the file's own header; a
137
+ cell heading nested under *that* is level 2 relative to it, and so on.
138
+
139
+ Each format uses one heading style consistently across every level, rather than mixing
140
+ styles:
141
+
142
+ - `.py` uses an RST-style title + underline, one character per level -- the same
143
+ sequence Sphinx's own documentation uses for sections through sub-paragraphs: `=`, `-`,
144
+ `~`, `^`, `"`, `'` for levels 1 through 6.
145
+ - `.ipynb` always uses ATX syntax (`#`, `##`, `###`, ...), since that's a real Markdown
146
+ heading at any level, whereas an underline only reads as one for the first two.
138
147
 
139
148
  Two things worth knowing before turning this on:
140
149
 
@@ -368,29 +368,31 @@ def _convert_literal_block(node: nodes.literal_block) -> list[Segment]:
368
368
  return [('directive', comment_lines)]
369
369
 
370
370
 
371
- #: Setext underline character for the two levels it supports -- ``=`` for
372
- #: the file's own top-level header (level 1), ``-`` for a subheading
373
- #: (level 2). Anything deeper uses ATX ``#`` syntax instead (see
374
- #: ``_title_underline_segment``).
375
- _TITLE_UNDERLINE_CHARS = {1: '=', 2: '-'}
371
+ #: RST underline character per heading level, for ``.py`` output -- the
372
+ #: same sequence Sphinx's own documentation uses for sections, subsections,
373
+ #: subsubsections, paragraphs, and sub-paragraphs.
374
+ _TITLE_UNDERLINE_CHARS = {1: '=', 2: '-', 3: '~', 4: '^', 5: '"', 6: "'"}
376
375
 
376
+ #: Deepest heading level either heading style below can represent.
377
+ _MAX_HEADING_LEVEL = 6
377
378
 
378
- def _title_underline_segment(title: str, level: int = 1) -> Segment:
379
+
380
+ def _title_underline_segment(title: str, level: int, fmt: str) -> Segment:
379
381
  """Build a heading directive segment for ``title`` at the given level.
380
382
 
381
- Levels 1 and 2 use a setext-style title + underline (e.g. ``# Title`` +
382
- ``# =====``), matching the file's own header (see ``_header_segment``)
383
- and gallery mode's sibling-section headings respectively. Level 3+ uses
384
- ATX ``#`` syntax instead (e.g. ``# ### Title``), since CommonMark's
385
- setext syntax only supports the first two levels. Either way, this is
386
- what makes the heading render as real Markdown in ``.ipynb`` (once the
387
- ``#`` comment prefix is stripped for markdown cells) rather than plain
388
- text.
383
+ Consistent within each format rather than mixed: ``.py`` always uses an
384
+ RST-style title + underline (e.g. ``# Title`` + ``# =====``), one
385
+ character per level (see ``_TITLE_UNDERLINE_CHARS``); ``.ipynb`` always
386
+ uses ATX ``#`` syntax instead (e.g. ``# ### Title``), since that's a
387
+ real Markdown heading at any level once the ``#`` comment prefix is
388
+ stripped for markdown cells, whereas an RST-style underline only reads
389
+ as a heading there for the first two levels.
389
390
  """
390
- if level <= 2:
391
- underline = _TITLE_UNDERLINE_CHARS[level] * len(title)
392
- return ('directive', [f'# {title}', f'# {underline}'])
393
- return ('directive', [f'# {"#" * level} {title}'])
391
+ level = min(level, _MAX_HEADING_LEVEL)
392
+ if fmt == 'ipynb':
393
+ return ('directive', [f'# {"#" * level} {title}'])
394
+ underline = _TITLE_UNDERLINE_CHARS[level] * len(title)
395
+ return ('directive', [f'# {title}', f'# {underline}'])
394
396
 
395
397
 
396
398
  def _heading_level(title: nodes.title) -> int:
@@ -399,15 +401,44 @@ def _heading_level(title: nodes.title) -> int:
399
401
  Counts the title's own section and each ancestor ``nodes.section``.
400
402
  Running this on the file's own header would give level 1; a title
401
403
  nested inside that comes out level 2, one nested inside that comes out
402
- level 3, and so on -- clamped to 6, the deepest level CommonMark's ATX
403
- heading syntax can represent.
404
+ level 3, and so on -- clamped to ``_MAX_HEADING_LEVEL``.
404
405
  """
405
406
  level = 0
406
407
  node: nodes.Node | None = title.parent
407
408
  while isinstance(node, nodes.section):
408
409
  level += 1
409
410
  node = node.parent
410
- return min(level, 6)
411
+ return min(level, _MAX_HEADING_LEVEL)
412
+
413
+
414
+ #: Extra indent for an admonition's content, under its ``# LABEL:`` line --
415
+ #: ``.py`` only (see ``_convert_admonition``).
416
+ _ADMONITION_CONTENT_INDENT = ' '
417
+
418
+
419
+ def _indent_comment_line(line: str) -> str:
420
+ """Indent a ``# text`` comment line's content by ``_ADMONITION_CONTENT_INDENT``.
421
+
422
+ A bare ``#`` (blank line within a comment block) or a blank separator
423
+ line (``''``) has no content to indent, so both pass through unchanged
424
+ -- as does a raw code line (no ``#`` prefix at all), left alone so
425
+ indenting an admonition's content never touches real Python source.
426
+ """
427
+ if not line.startswith('# '):
428
+ return line
429
+ return f'# {_ADMONITION_CONTENT_INDENT}{line.removeprefix("# ")}'
430
+
431
+
432
+ def _indent_label_content(lines: list[str], fmt: str) -> list[str]:
433
+ """Indent every line after the first (a ``# LABEL:`` line) under it.
434
+
435
+ ``.py`` only: in ``.ipynb``, once rendered as markdown, leading
436
+ whitespace within a paragraph collapses to nothing (confirmed against a
437
+ real CommonMark parse), so it would be invisible there anyway.
438
+ """
439
+ if fmt != 'py' or not lines:
440
+ return lines
441
+ return [lines[0], *(_indent_comment_line(line) for line in lines[1:])]
411
442
 
412
443
 
413
444
  def _convert_admonition(
@@ -420,7 +451,8 @@ def _convert_admonition(
420
451
  if skip_first_title and isinstance(child, nodes.title):
421
452
  continue
422
453
  inner.extend(_convert_node(child, inner_ctx))
423
- return [('directive', _join_segments(inner))]
454
+ lines = _indent_label_content(_join_segments(inner), ctx.fmt)
455
+ return [('directive', lines)]
424
456
 
425
457
 
426
458
  def _convert_node(node: nodes.Node, ctx: _RenderContext) -> list[Segment]:
@@ -453,7 +485,7 @@ def _convert_node(node: nodes.Node, ctx: _RenderContext) -> list[Segment]:
453
485
  # _heading_level).
454
486
  title_text = _render_inline(node, ctx).strip()
455
487
  level = _heading_level(node)
456
- return [_title_underline_segment(title_text, level=level)] if title_text else []
488
+ return [_title_underline_segment(title_text, level, ctx.fmt)] if title_text else []
457
489
  if isinstance(node, (*_CONTAINER_TYPES, nodes.section)):
458
490
  # nodes.section (gallery mode only): a sphinx-gallery ``# %%`` cell
459
491
  # with its own RST heading becomes a *sibling* section at the
@@ -577,9 +609,9 @@ def _qualified_name_for(node: nodes.Node, docname: str, counter: int) -> str:
577
609
  return f'{base}-example-{counter}'
578
610
 
579
611
 
580
- def _header_segment(qualified_name: str) -> Segment:
581
- """Build the title-header segment, e.g. ``# pyvista.read examples`` + underline."""
582
- return _title_underline_segment(f'Examples from {qualified_name}')
612
+ def _header_title(qualified_name: str) -> str:
613
+ """Build the file's own header title text, e.g. ``Examples from pyvista.read``."""
614
+ return f'Examples from {qualified_name}'
583
615
 
584
616
 
585
617
  # A plain divider, not tied to any specific text's length the way a title
@@ -596,8 +628,8 @@ def _footer_segment(footer: str | None, fmt: str, app: Sphinx, docname: str) ->
596
628
  """Build the footer directive segment, if a footer is configured.
597
629
 
598
630
  Empty (0 or 1 elements, not a bare ``Segment | None``) so callers can
599
- just splat it into a segment list like ``_header_segment``'s result.
600
- Starts with ``_FOOTER_SEPARATOR`` -- see its comment for why.
631
+ just splat it into a segment list. Starts with ``_FOOTER_SEPARATOR``
632
+ -- see its comment for why.
601
633
 
602
634
  Parsed as RST -- the same way any other prose in this extension is --
603
635
  rather than treated as a special case: a hyperlink written as
@@ -781,7 +813,8 @@ def _build_segments(nodes_in_span: list[nodes.Node], ctx: _RenderContext) -> lis
781
813
  inner: list[Segment] = [('text', ['# SEE ALSO:'])]
782
814
  for later_node in nodes_in_span[i + 1 :]:
783
815
  inner.extend(_convert_node(later_node, inner_ctx))
784
- segments.append(('directive', _join_segments(inner)))
816
+ lines = _indent_label_content(_join_segments(inner), ctx.fmt)
817
+ segments.append(('directive', lines))
785
818
  break
786
819
  segments.extend(_convert_node(node, ctx))
787
820
  return segments
@@ -791,7 +824,7 @@ def _build_download_entries(
791
824
  app: Sphinx,
792
825
  docname: str,
793
826
  name: str,
794
- header: Segment,
827
+ header_title: str,
795
828
  nodes_in_span: list[nodes.Node],
796
829
  formats: list[str],
797
830
  footer: str | None,
@@ -803,8 +836,10 @@ def _build_download_entries(
803
836
  requested -- empty if the span has no real code, or ``formats`` itself
804
837
  is empty. Shared by the docstring-Examples path and gallery-page path;
805
838
  the only differences between them are how ``nodes_in_span``/``name``
806
- get built, and what ``header`` says (see ``_header_segment`` vs.
807
- ``_process_gallery_page``'s own title handling).
839
+ get built, and what ``header_title`` says (see ``_header_title`` vs.
840
+ ``_process_gallery_page``'s own title handling). Always level 1 -- the
841
+ file's own header is the one heading every generated file has exactly
842
+ one of (see ``_title_underline_segment``).
808
843
  """
809
844
  py_ctx = _RenderContext(app=app, docname=docname, fmt='py')
810
845
  py_segments = _build_segments(nodes_in_span, py_ctx)
@@ -812,7 +847,8 @@ def _build_download_entries(
812
847
  if not any(kind == 'code' for kind, _lines in py_segments):
813
848
  return []
814
849
 
815
- py_segments_full = [header, *py_segments, *_footer_segment(footer, 'py', app, docname)]
850
+ py_header = _title_underline_segment(header_title, 1, 'py')
851
+ py_segments_full = [py_header, *py_segments, *_footer_segment(footer, 'py', app, docname)]
816
852
  source = '\n'.join(_join_segments(py_segments_full)).rstrip() + '\n\n'
817
853
 
818
854
  if not _has_real_code(source):
@@ -828,6 +864,7 @@ def _build_download_entries(
828
864
  else:
829
865
  ipynb_ctx = _RenderContext(app=app, docname=docname, fmt='ipynb')
830
866
  ipynb_segments = _build_segments(nodes_in_span, ipynb_ctx)
867
+ ipynb_header = _title_underline_segment(header_title, 1, 'ipynb')
831
868
  # Converted separately from the footer and concatenated, rather
832
869
  # than joined into one segment list and converted together:
833
870
  # _segments_to_cells only starts a new cell when the kind
@@ -835,7 +872,7 @@ def _build_download_entries(
835
872
  # following non-code content (e.g. a trailing admonition) would
836
873
  # otherwise share its cell instead of always getting one of its
837
874
  # own.
838
- cells = _segments_to_cells([header, *ipynb_segments])
875
+ cells = _segments_to_cells([ipynb_header, *ipynb_segments])
839
876
  cells.extend(_segments_to_cells(_footer_segment(footer, 'ipynb', app, docname)))
840
877
  rel_path = _write_notebook(app, name, _build_notebook(cells))
841
878
  entries.append((label, rel_path))
@@ -864,7 +901,7 @@ def _process_span(
864
901
 
865
902
  name = _qualified_name_for(heading, docname, counter)
866
903
  entries = _build_download_entries(
867
- app, docname, name, _header_segment(name), nodes_in_span, formats, footer, link_labels
904
+ app, docname, name, _header_title(name), nodes_in_span, formats, footer, link_labels
868
905
  )
869
906
  if not entries:
870
907
  return
@@ -984,9 +1021,9 @@ def _process_gallery_page(
984
1021
  # not an Examples section carved out of a larger docstring, so there's
985
1022
  # no "from" framing to make. Falls back to the usual header on the off
986
1023
  # chance the page has no title of its own.
987
- header = _title_underline_segment(title) if title else _header_segment(name)
1024
+ header_title = title if title else _header_title(name)
988
1025
  entries = _build_download_entries(
989
- app, docname, name, header, nodes_in_span, formats, footer, link_labels
1026
+ app, docname, name, header_title, nodes_in_span, formats, footer, link_labels
990
1027
  )
991
1028
  if not entries:
992
1029
  return
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.3.1'
22
- __version_tuple__ = version_tuple = (0, 3, 1)
21
+ __version__ = version = '0.3.3'
22
+ __version_tuple__ = version_tuple = (0, 3, 3)
23
23
 
24
- __commit_id__ = commit_id = 'g6bae5922a'
24
+ __commit_id__ = commit_id = 'g2a42d27a0'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sphinx-examples-as-code
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Sphinx extension for converting docstring examples into downloadable code.
5
5
  Author-email: The PyVista Developers <info@pyvista.org>
6
6
  License-Expression: MIT
@@ -107,9 +107,11 @@ What happens to the content of an Examples section:
107
107
  - `.. code-block:: python` (or `py`) blocks are kept as-is; other languages become
108
108
  comments, set off with blank lines on both sides like any other directive.
109
109
  - Admonitions (`.. note::`, `.. warning::`, `.. seealso::`, ...) become a `# LABEL:`
110
- comment followed by their content as comments. "See Also" is recognized in any of its
111
- three forms (`.. seealso::`, a bare `.. rubric:: See Also`, or a hand-written `See
112
- Also` heading) and always renders the same way.
110
+ comment followed by their content as comments, indented one level under the label in
111
+ `.py` (not `.ipynb`, where leading whitespace within a paragraph has no visible effect
112
+ once rendered). "See Also" is recognized in any of its three forms (`.. seealso::`, a
113
+ bare `.. rubric:: See Also`, or a hand-written `See Also` heading) and always renders
114
+ the same way.
113
115
  - Cross-references and inline code (`:class:`, `:meth:`, `:func:`, `:attr:`,
114
116
  double-backtick literals, ...) keep their display text, wrapped in backticks (e.g.
115
117
  ``:class:`pyvista.Plotter` `` -> `` `pyvista.Plotter` ``). If `html_baseurl` is set and
@@ -154,14 +156,21 @@ Detection is automatic and per-page — any page without a sphinx-gallery downlo
154
156
  is left completely untouched. Gallery pages and ordinary docstring/prose pages (using
155
157
  the Examples-section behavior above) can coexist on the same site.
156
158
 
157
- A `# %%` cell with its own RST heading gets the same title-plus-underline treatment as
158
- the file's own header, and renders as a real Markdown heading in `.ipynb`. Level is
159
- relative to actual RST section nesting, not always one below the file's own header:
160
- level 1 (`<h1>`, `# Title` + `# ====`) for the file's own header, or a cell heading that
161
- reuses the page title's own underline character; level 2 (`<h2>`, `# Title` + `# ----`)
162
- for a cell heading nested under it (the common case), or one level below whatever
163
- heading precedes it. Level 3 and deeper use ATX syntax instead (`# ### Title`), since
164
- Markdown's underline-style headings only support two levels.
159
+ A `# %%` cell with its own RST heading gets the same header treatment as the file's own
160
+ title, and renders as a real Markdown heading in `.ipynb`. Level is relative to actual
161
+ RST section nesting, not always one below the file's own header: a cell heading nested
162
+ under the page's own title (the common case) is one level below it; one that reuses the
163
+ page title's own underline character is level 1, the same as the file's own header; a
164
+ cell heading nested under *that* is level 2 relative to it, and so on.
165
+
166
+ Each format uses one heading style consistently across every level, rather than mixing
167
+ styles:
168
+
169
+ - `.py` uses an RST-style title + underline, one character per level -- the same
170
+ sequence Sphinx's own documentation uses for sections through sub-paragraphs: `=`, `-`,
171
+ `~`, `^`, `"`, `'` for levels 1 through 6.
172
+ - `.ipynb` always uses ATX syntax (`#`, `##`, `###`, ...), since that's a real Markdown
173
+ heading at any level, whereas an underline only reads as one for the first two.
165
174
 
166
175
  Two things worth knowing before turning this on:
167
176
 
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "0.3.1",
2
+ "tag": "0.3.3",
3
3
  "distance": 0,
4
- "node": "g6bae5922a337380c3e8f439cfea8c645d1d11342",
4
+ "node": "g2a42d27a03ee769351e69a23c3392b167a7cbe45",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-08-07"
@@ -68,26 +68,29 @@ def _is_ours(path: Path) -> bool:
68
68
  """Distinguish our generated file from sphinx-gallery's own same-named one.
69
69
 
70
70
  Both write a ``plot_minimal.py``/``.ipynb`` under ``_downloads/<digest>/``
71
- -- ours is identifiable structurally, by the title+underline header this
72
- extension always starts a generated file with (its first two lines in
73
- ``.py``, or its first markdown cell's first two lines in ``.ipynb``): a
74
- title line followed by a line of ``=`` matching that title's length.
71
+ -- ours is identifiable structurally, by the level-1 header this
72
+ extension always starts a generated file with: a title line followed by
73
+ a line of ``=`` matching that title's length in ``.py``, or a single
74
+ ``# Title`` (ATX level 1) line as the first markdown cell's first line
75
+ in ``.ipynb`` (see ``_title_underline_segment``).
75
76
  """
76
77
  if path.suffix == '.py':
77
78
  lines = path.read_text(encoding='utf-8').splitlines()
78
- else:
79
- cells = json.loads(path.read_text(encoding='utf-8'))['cells']
80
- # markdown lines carry a trailing hard-break " " -- strip it before comparing
81
- lines = (
82
- [line.rstrip() for line in ''.join(cells[0]['source']).splitlines()] if cells else []
83
- )
84
-
85
- if len(lines) < 2:
79
+ if len(lines) < 2:
80
+ return False
81
+ title, underline = lines[0].removeprefix('# '), lines[1].removeprefix('# ')
82
+ return bool(title) and underline == '=' * len(title)
83
+
84
+ cells = json.loads(path.read_text(encoding='utf-8'))['cells']
85
+ if not cells or not cells[0]['source']:
86
86
  return False
87
- title, underline = lines[0], lines[1]
88
- if path.suffix == '.py':
89
- title, underline = title.removeprefix('# '), underline.removeprefix('# ')
90
- return bool(title) and underline == '=' * len(title)
87
+ # sphinx-gallery's own native notebook also titles its first cell
88
+ # "# <title>" (plain ATX, no hard break) -- ours is only distinguishable
89
+ # by the trailing two-space hard break _cell_source always adds
90
+ raw_first_line = cells[0]['source'][0]
91
+ first_line = raw_first_line.rstrip()
92
+ has_hard_break = raw_first_line.rstrip('\n').endswith(' ')
93
+ return has_hard_break and first_line.startswith('# ') and not first_line.startswith('# #')
91
94
 
92
95
 
93
96
  def test_gallery_downloads_disabled_by_default(tmp_path: Path):
@@ -221,7 +224,7 @@ def test_gallery_downloads_footer_is_its_own_dedicated_ipynb_cell(gallery_build:
221
224
  def test_gallery_downloads_sibling_section_heading_is_markdown_in_ipynb(
222
225
  gallery_build: tuple[Path, str],
223
226
  ):
224
- """The sibling section's heading is a level-2 (``-`` underline) Markdown heading."""
227
+ """The sibling section's heading is a level-2 (``##``) Markdown heading."""
225
228
  html_dir, _html = gallery_build
226
229
  notebook = json.loads(_generated(html_dir, '.ipynb').read_text(encoding='utf-8'))
227
230
  heading_cell = next(
@@ -229,20 +232,20 @@ def test_gallery_downloads_sibling_section_heading_is_markdown_in_ipynb(
229
232
  for c in notebook['cells']
230
233
  if c['cell_type'] == 'markdown' and 'A headed cell' in ''.join(c['source'])
231
234
  )
232
- source = ''.join(heading_cell['source'])
233
- assert '-' * len('A headed cell') in source # setext underline
235
+ lines = [line.rstrip() for line in heading_cell['source']]
236
+ assert '## A headed cell' in lines
234
237
 
235
238
 
236
239
  def test_gallery_downloads_page_title_is_level_1_sibling_heading_is_level_2(
237
240
  gallery_build: tuple[Path, str],
238
241
  ):
239
- """The page's own title (``=``) outranks a sibling section's heading (``-``)."""
242
+ """The page's own title (``#``) outranks a sibling section's heading (``##``)."""
240
243
  html_dir, _html = gallery_build
241
244
  notebook = json.loads(_generated(html_dir, '.ipynb').read_text(encoding='utf-8'))
242
245
  title_cell = notebook['cells'][0]
243
- title_source = ''.join(title_cell['source'])
244
- assert '=' * len('A minimal gallery example') in title_source
245
- assert '-' * len('A minimal gallery example') not in title_source
246
+ lines = [line.rstrip() for line in title_cell['source']]
247
+ assert '# A minimal gallery example' in lines
248
+ assert '## A minimal gallery example' not in lines
246
249
 
247
250
 
248
251
  def test_gallery_downloads_generated_py_executes(gallery_build: tuple[Path, str]):
@@ -58,8 +58,16 @@ def test_reused_underline_char_renders_as_level_1(built: Path):
58
58
  assert lines[reused_idx + 1] == '# ' + '=' * len('Reused Top Level')
59
59
 
60
60
 
61
- def test_three_genuine_levels_uses_atx_for_the_third(built: Path):
62
- """A subsection nested two sections deep gets ATX ``###``, not another dash line.
61
+ def test_reused_underline_char_renders_as_level_1_in_ipynb(built: Path):
62
+ nb_path = _generated(built, 'plot_reused_level.ipynb')
63
+ notebook = json.loads(nb_path.read_text(encoding='utf-8'))
64
+ all_lines = [line.rstrip() for cell in notebook['cells'] for line in cell['source']]
65
+ assert '## Nested Subsection' in all_lines
66
+ assert '# Reused Top Level' in all_lines
67
+
68
+
69
+ def test_three_genuine_levels_py_uses_tilde_for_the_third(built: Path):
70
+ """A subsection nested two sections deep gets a ``~`` underline, not another dash line.
63
71
 
64
72
  Otherwise it would render identically to its own parent section's
65
73
  heading -- indistinguishable from a sibling instead of a subsection.
@@ -70,11 +78,12 @@ def test_three_genuine_levels_uses_atx_for_the_third(built: Path):
70
78
  section_idx = lines.index('# Section')
71
79
  assert lines[section_idx + 1] == '# ' + '-' * len('Section')
72
80
 
73
- assert '# ### Subsection' in lines
74
- assert '# Subsection' not in lines # not a level-2 dash heading
81
+ subsection_idx = lines.index('# Subsection')
82
+ assert lines[subsection_idx + 1] == '# ' + '~' * len('Subsection')
75
83
 
76
84
 
77
85
  def test_three_genuine_levels_atx_renders_as_h3_in_ipynb(built: Path):
86
+ """In .ipynb, every level uses ATX -- the third level is a plain ``###`` line."""
78
87
  nb_path = _generated(built, 'plot_three_levels.ipynb')
79
88
  notebook = json.loads(nb_path.read_text(encoding='utf-8'))
80
89
  all_source = ''.join(''.join(cell['source']) for cell in notebook['cells'])
@@ -444,6 +444,47 @@ def test_clean_code_comment_replaces_non_ascii_chars(unicode_char: int, ascii_ch
444
444
  assert seac._clean_code_comment(line) == f'# comment {ascii_char} text'
445
445
 
446
446
 
447
+ # ---------------------------------------------------------------------------
448
+ # _indent_comment_line / _indent_label_content
449
+ # ---------------------------------------------------------------------------
450
+
451
+
452
+ def test_indent_comment_line_indents_content():
453
+ assert seac._indent_comment_line('# hello') == '# hello'
454
+
455
+
456
+ def test_indent_comment_line_bare_hash_unchanged():
457
+ assert seac._indent_comment_line('#') == '#'
458
+
459
+
460
+ def test_indent_comment_line_blank_separator_unchanged():
461
+ assert seac._indent_comment_line('') == ''
462
+
463
+
464
+ def test_indent_comment_line_code_line_unchanged():
465
+ # no '#' prefix at all -- indenting it would change real Python source
466
+ assert seac._indent_comment_line('x = 1') == 'x = 1'
467
+
468
+
469
+ def test_indent_label_content_py_indents_everything_after_the_first_line():
470
+ lines = ['# NOTE:', '# hello', '# world']
471
+ assert seac._indent_label_content(lines, 'py') == ['# NOTE:', '# hello', '# world']
472
+
473
+
474
+ def test_indent_label_content_ipynb_unchanged():
475
+ lines = ['# NOTE:', '# hello', '# world']
476
+ assert seac._indent_label_content(lines, 'ipynb') == lines
477
+
478
+
479
+ def test_indent_label_content_empty_list_unchanged():
480
+ assert seac._indent_label_content([], 'py') == []
481
+
482
+
483
+ def test_indent_label_content_skips_only_the_first_line():
484
+ # a lone label with no content: nothing after it to indent
485
+ assert seac._indent_label_content(['# NOTE:'], 'py') == ['# NOTE:']
486
+
487
+
447
488
  # ---------------------------------------------------------------------------
448
489
  # _convert_admonition / _convert_node dispatch
449
490
  # ---------------------------------------------------------------------------
@@ -451,7 +492,19 @@ def test_clean_code_comment_replaces_non_ascii_chars(unicode_char: int, ascii_ch
451
492
 
452
493
  def test_convert_node_note():
453
494
  doctree = _parse('.. note::\n\n hello')
454
- assert seac._convert_node(doctree[0], _ctx()) == [('directive', ['# NOTE:', '# hello'])]
495
+ assert seac._convert_node(doctree[0], _ctx(fmt='py')) == [
496
+ ('directive', ['# NOTE:', '# hello'])
497
+ ]
498
+
499
+
500
+ def test_convert_node_note_ipynb_not_indented():
501
+ # unlike .py, .ipynb content isn't indented under the label -- once
502
+ # rendered as markdown, leading whitespace within a paragraph collapses
503
+ # to nothing anyway
504
+ doctree = _parse('.. note::\n\n hello')
505
+ assert seac._convert_node(doctree[0], _ctx(fmt='ipynb')) == [
506
+ ('directive', ['# NOTE:', '# hello'])
507
+ ]
455
508
 
456
509
 
457
510
  def test_convert_node_seealso():
@@ -462,8 +515,8 @@ def test_convert_node_seealso():
462
515
  p2 += nodes.Text('More info')
463
516
  node += p1
464
517
  node += p2
465
- assert seac._convert_node(node, _ctx()) == [
466
- ('directive', ['# SEE ALSO:', '# See X', '# More info'])
518
+ assert seac._convert_node(node, _ctx(fmt='py')) == [
519
+ ('directive', ['# SEE ALSO:', '# See X', '# More info'])
467
520
  ]
468
521
 
469
522
 
@@ -478,7 +531,10 @@ def test_convert_node_seealso_propagates_in_see_also_to_references():
478
531
  ctx = _ctx(fmt='py', base_url='https://docs.pyvista.org/')
479
532
  segments = seac._convert_node(node, ctx)
480
533
  assert segments == [
481
- ('directive', ['# SEE ALSO:', '# pyvista.Plotter https://docs.pyvista.org/plotter.html'])
534
+ (
535
+ 'directive',
536
+ ['# SEE ALSO:', '# pyvista.Plotter https://docs.pyvista.org/plotter.html'],
537
+ )
482
538
  ]
483
539
 
484
540
 
@@ -490,8 +546,8 @@ def test_convert_node_see_also_section_treated_like_admonition():
490
546
 
491
547
  def test_convert_node_generic_admonition_uses_title():
492
548
  doctree = _parse('.. admonition:: Custom Title\n\n body text')
493
- assert seac._convert_node(doctree[0], _ctx()) == [
494
- ('directive', ['# Custom Title:', '# body text'])
549
+ assert seac._convert_node(doctree[0], _ctx(fmt='py')) == [
550
+ ('directive', ['# Custom Title:', '# body text'])
495
551
  ]
496
552
 
497
553
 
@@ -500,7 +556,7 @@ def test_convert_node_generic_admonition_no_title_defaults_to_note():
500
556
  p = nodes.paragraph()
501
557
  p += nodes.Text('body')
502
558
  node += p
503
- assert seac._convert_node(node, _ctx()) == [('directive', ['# NOTE:', '# body'])]
559
+ assert seac._convert_node(node, _ctx(fmt='py')) == [('directive', ['# NOTE:', '# body'])]
504
560
 
505
561
 
506
562
  def test_convert_node_admonition_with_no_body_keeps_label():
@@ -508,6 +564,16 @@ def test_convert_node_admonition_with_no_body_keeps_label():
508
564
  assert seac._convert_node(node, _ctx()) == [('directive', ['# NOTE:'])]
509
565
 
510
566
 
567
+ def test_convert_node_note_with_code_indents_text_not_code():
568
+ # a doctest example inside a note: the surrounding prose is indented
569
+ # under the label, but the code itself is left flush -- indenting it
570
+ # would be invalid Python (it isn't actually nested inside anything)
571
+ doctree = _parse('.. note::\n\n See below.\n\n >>> x = 1')
572
+ assert seac._convert_node(doctree[0], _ctx(fmt='py')) == [
573
+ ('directive', ['# NOTE:', '# See below.', 'x = 1'])
574
+ ]
575
+
576
+
511
577
  def test_convert_node_skip_subtree_class():
512
578
  node = nodes.container(classes=['sd-dropdown'])
513
579
  p = nodes.paragraph()
@@ -747,38 +813,43 @@ def test_qualified_name_for_examples_section_sibling_no_desc_falls_back():
747
813
  # ---------------------------------------------------------------------------
748
814
 
749
815
 
750
- def test_header_segment_format():
751
- kind, lines = seac._header_segment('pyvista.read')
752
- assert kind == 'directive'
753
- assert lines[0] == '# Examples from pyvista.read'
754
- assert lines[1] == '# ' + '=' * len('Examples from pyvista.read')
816
+ def test_header_title_format():
817
+ assert seac._header_title('pyvista.read') == 'Examples from pyvista.read'
755
818
 
756
819
 
757
820
  # ---------------------------------------------------------------------------
758
821
  # _title_underline_segment
759
822
  # ---------------------------------------------------------------------------
760
823
 
824
+ # The full level -> underline character convention, for .py -- same
825
+ # sequence Sphinx's own documentation uses for sections through
826
+ # sub-paragraphs.
827
+ _LEVEL_UNDERLINE_CHARS = {1: '=', 2: '-', 3: '~', 4: '^', 5: '"', 6: "'"}
828
+
761
829
 
762
- def test_title_underline_segment_format():
763
- kind, lines = seac._title_underline_segment('A Title')
830
+ @pytest.mark.parametrize('level', [1, 2, 3, 4, 5, 6])
831
+ def test_title_underline_segment_py_uses_level_specific_underline(level):
832
+ kind, lines = seac._title_underline_segment('A Title', level, 'py')
764
833
  assert kind == 'directive'
765
- assert lines == ['# A Title', '# =======']
834
+ char = _LEVEL_UNDERLINE_CHARS[level]
835
+ assert lines == ['# A Title', '# ' + char * len('A Title')]
766
836
 
767
837
 
768
- def test_title_underline_segment_level_2_uses_dashes():
769
- kind, lines = seac._title_underline_segment('A Title', level=2)
838
+ @pytest.mark.parametrize('level', [1, 2, 3, 4, 5, 6])
839
+ def test_title_underline_segment_ipynb_always_uses_atx(level):
840
+ kind, lines = seac._title_underline_segment('A Title', level, 'ipynb')
770
841
  assert kind == 'directive'
771
- assert lines == ['# A Title', '# -------']
842
+ assert lines == [f'# {"#" * level} A Title']
772
843
 
773
844
 
774
- def test_title_underline_segment_level_3_uses_atx():
775
- kind, lines = seac._title_underline_segment('A Title', level=3)
845
+ def test_title_underline_segment_py_level_beyond_6_clamped():
846
+ kind, lines = seac._title_underline_segment('A Title', 9, 'py')
776
847
  assert kind == 'directive'
777
- assert lines == ['# ### A Title']
848
+ assert lines == ['# A Title', "# '''''''"]
778
849
 
779
850
 
780
- def test_title_underline_segment_level_6_uses_atx():
781
- kind, lines = seac._title_underline_segment('A Title', level=6)
851
+ def test_title_underline_segment_ipynb_level_beyond_6_clamped():
852
+ kind, lines = seac._title_underline_segment('A Title', 9, 'ipynb')
782
853
  assert kind == 'directive'
783
854
  assert lines == ['# ###### A Title']
784
855
 
@@ -841,39 +912,70 @@ def test_heading_level_reused_top_level_char_then_nested_subsection():
841
912
  assert seac._heading_level(title) == 2
842
913
 
843
914
 
844
- def test_convert_node_title_uses_underline_treatment():
915
+ def _nest_title_one_deep(text: str) -> nodes.title:
845
916
  # nested one section inside a page's own title section -- the common
846
917
  # case for a gallery sibling section's heading
847
918
  page_section = nodes.section()
848
919
  section = nodes.section()
849
- title = nodes.title('', 'A subsection')
920
+ title = nodes.title('', text)
850
921
  section += title
851
922
  page_section += section
852
- assert seac._convert_node(title, _ctx()) == [
923
+ return title
924
+
925
+
926
+ def test_convert_node_title_uses_underline_treatment():
927
+ title = _nest_title_one_deep('A subsection')
928
+ assert seac._convert_node(title, _ctx(fmt='py')) == [
853
929
  ('directive', ['# A subsection', '# ------------'])
854
930
  ]
855
931
 
856
932
 
933
+ def test_convert_node_title_one_deep_ipynb_uses_atx():
934
+ title = _nest_title_one_deep('A subsection')
935
+ assert seac._convert_node(title, _ctx(fmt='ipynb')) == [('directive', ['# ## A subsection'])]
936
+
937
+
857
938
  def test_convert_node_title_not_nested_uses_level_1():
858
939
  section = nodes.section()
859
940
  title = nodes.title('', 'A subsection')
860
941
  section += title
861
- assert seac._convert_node(title, _ctx()) == [
942
+ assert seac._convert_node(title, _ctx(fmt='py')) == [
862
943
  ('directive', ['# A subsection', '# ============'])
863
944
  ]
864
945
 
865
946
 
866
- def test_convert_node_title_two_sections_deep_uses_atx():
947
+ def test_convert_node_title_not_nested_ipynb_uses_atx():
948
+ section = nodes.section()
949
+ title = nodes.title('', 'A subsection')
950
+ section += title
951
+ assert seac._convert_node(title, _ctx(fmt='ipynb')) == [('directive', ['# # A subsection'])]
952
+
953
+
954
+ def _nest_title_three_deep(text: str) -> nodes.title:
867
955
  # e.g. a "# %%" cell's own heading nested inside another sibling
868
956
  # section's heading, rather than directly under the page's own title
869
957
  page_section = nodes.section()
870
958
  section = nodes.section()
871
959
  subsection = nodes.section()
872
- title = nodes.title('', 'A sub-subsection')
960
+ title = nodes.title('', text)
873
961
  subsection += title
874
962
  section += subsection
875
963
  page_section += section
876
- assert seac._convert_node(title, _ctx()) == [('directive', ['# ### A sub-subsection'])]
964
+ return title
965
+
966
+
967
+ def test_convert_node_title_two_sections_deep_py_uses_tilde():
968
+ title = _nest_title_three_deep('A sub-subsection')
969
+ assert seac._convert_node(title, _ctx(fmt='py')) == [
970
+ ('directive', ['# A sub-subsection', '# ~~~~~~~~~~~~~~~~'])
971
+ ]
972
+
973
+
974
+ def test_convert_node_title_two_sections_deep_ipynb_uses_atx():
975
+ title = _nest_title_three_deep('A sub-subsection')
976
+ assert seac._convert_node(title, _ctx(fmt='ipynb')) == [
977
+ ('directive', ['# ### A sub-subsection'])
978
+ ]
877
979
 
878
980
 
879
981
  def test_convert_node_empty_title_returns_empty():
@@ -1190,7 +1292,7 @@ def test_build_segments_bare_rubric_see_also_affects_only_what_follows():
1190
1292
  'directive',
1191
1293
  [
1192
1294
  '# SEE ALSO:',
1193
- '# pyvista.Plotter https://docs.pyvista.org/plotter.html',
1295
+ '# pyvista.Plotter https://docs.pyvista.org/plotter.html',
1194
1296
  'x = 1',
1195
1297
  ],
1196
1298
  )
@@ -1316,7 +1418,7 @@ def test_process_span_includes_external_see_also(tmp_path: Path):
1316
1418
  written = next((tmp_path / '_downloads').rglob('*.py'))
1317
1419
  content = written.read_text()
1318
1420
  assert '# SEE ALSO:' in content
1319
- assert '# related info' in content
1421
+ assert '# related info' in content
1320
1422
 
1321
1423
 
1322
1424
  def test_process_span_appends_footer_with_blank_line_before(tmp_path: Path):
@@ -177,14 +177,14 @@ def test_docstring_examples_conversion(built: tuple[Path, list[Path]]):
177
177
 
178
178
  note_src = _read(examples, 'case_note')
179
179
  assert '# NOTE:' in note_src
180
- assert '# This is a note' in note_src
180
+ assert '# This is a note' in note_src # indented under the label
181
181
 
182
182
  warning_src = _read(examples, 'case_warning')
183
183
  assert '# WARNING:' in warning_src
184
184
 
185
185
  multi_note_src = _read(examples, 'case_multi_paragraph_note')
186
- assert '# First paragraph of the note.' in multi_note_src
187
- assert '# Second paragraph of the note.' in multi_note_src
186
+ assert '# First paragraph of the note.' in multi_note_src
187
+ assert '# Second paragraph of the note.' in multi_note_src
188
188
 
189
189
  admonition_src = _read(examples, 'case_generic_admonition')
190
190
  assert '# Custom Title:' in admonition_src
@@ -249,6 +249,15 @@ def test_header_format(built: tuple[Path, list[Path]]):
249
249
  assert lines[2] == ''
250
250
 
251
251
 
252
+ def test_header_format_ipynb_uses_atx(built_notebooks: list[Path]):
253
+ """The same header, in .ipynb, is a single ATX level-1 line -- no underline."""
254
+ nb_path = next(p for p in built_notebooks if p.stem == 'docstring_cases_case_note')
255
+ notebook = json.loads(nb_path.read_text(encoding='utf-8'))
256
+ header_cell = notebook['cells'][0]
257
+ lines = [line.rstrip() for line in header_cell['source']]
258
+ assert lines[0] == '# Examples from docstring_cases.case_note'
259
+
260
+
252
261
  def test_whitespace_conventions(built: tuple[Path, list[Path]]):
253
262
  """Check the spacing rules.
254
263
 
@@ -298,10 +307,10 @@ def test_seealso_admonition(built: tuple[Path, list[Path]]):
298
307
  """
299
308
  src = _read(built[1], 'case_seealso')
300
309
  assert '# SEE ALSO:' in src
301
- assert '# Some Target' in src
302
- assert '# See this in the gallery for more info.' in src
310
+ assert '# Some Target' in src
311
+ assert '# See this in the gallery for more info.' in src
303
312
  # each paragraph must be its own line -- not concatenated together
304
- assert 'Some Target\n# See' in src or 'Some TargetSee' not in src
313
+ assert 'Some Target\n# See' in src or 'Some TargetSee' not in src
305
314
 
306
315
 
307
316
  def test_stray_markup_in_doctest_comment_cleaned(built: tuple[Path, list[Path]]):
@@ -521,7 +530,7 @@ def test_base_url_set_resolves_links(tmp_path: Path):
521
530
  ):
522
531
  py_src = _read(py_files, name)
523
532
  assert '# SEE ALSO:' in py_src
524
- assert '# docstring_cases.Sample https://docs.example.com/' in py_src
533
+ assert '# docstring_cases.Sample https://docs.example.com/' in py_src
525
534
  assert '`docstring_cases.Sample`' not in py_src # no backticks in See Also url lines
526
535
 
527
536
  nb_path = next(p for p in ipynb_files if p.stem == f'docstring_cases_{name}')
@@ -533,12 +542,19 @@ def test_markdown_cells_use_hard_line_breaks(built_notebooks: list[Path]):
533
542
  """Adjacent lines within one markdown cell need an explicit hard break.
534
543
 
535
544
  Without it, markdown treats a single newline as whitespace and runs
536
- everything in the cell together into one paragraph.
545
+ everything in the cell together into one paragraph. Checked on the
546
+ note's own "# NOTE:" label line, immediately followed by its content
547
+ with no blank line between -- not the header cell, which is a single
548
+ ATX heading line with nothing else to connect to.
537
549
  """
538
550
  nb_path = next(p for p in built_notebooks if p.stem == 'docstring_cases_case_note')
539
551
  notebook = json.loads(nb_path.read_text(encoding='utf-8'))
540
- header_cell = next(c for c in notebook['cells'] if c['cell_type'] == 'markdown')
541
- assert header_cell['source'][0].endswith(' \n')
552
+ note_cell = next(
553
+ c
554
+ for c in notebook['cells']
555
+ if c['cell_type'] == 'markdown' and 'useful information' in ''.join(c['source'])
556
+ )
557
+ assert note_cell['source'][0].endswith(' \n')
542
558
 
543
559
 
544
560
  def test_html_baseurl_missing_scheme_disables_links_without_failing_build(tmp_path: Path):