sphinx-examples-as-code 0.2.1__tar.gz → 0.3.0__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.2.1 → sphinx_examples_as_code-0.3.0}/.github/workflows/ci.yml +1 -1
  2. sphinx_examples_as_code-0.3.0/LICENSE +21 -0
  3. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/PKG-INFO +42 -49
  4. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/README.md +39 -48
  5. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/pyproject.toml +2 -0
  6. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/sphinx_examples_as_code/__init__.py +102 -45
  7. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/sphinx_examples_as_code/_version.py +3 -3
  8. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/sphinx_examples_as_code.egg-info/PKG-INFO +42 -49
  9. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/sphinx_examples_as_code.egg-info/SOURCES.txt +6 -0
  10. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/sphinx_examples_as_code.egg-info/scm_file_list.json +19 -13
  11. sphinx_examples_as_code-0.3.0/sphinx_examples_as_code.egg-info/scm_version.json +8 -0
  12. sphinx_examples_as_code-0.3.0/tests/heading_level_fixture/conf.py +23 -0
  13. sphinx_examples_as_code-0.3.0/tests/heading_level_fixture/examples/GALLERY_HEADER.rst +2 -0
  14. sphinx_examples_as_code-0.3.0/tests/heading_level_fixture/examples/plot_reused_level.py +19 -0
  15. sphinx_examples_as_code-0.3.0/tests/heading_level_fixture/index.rst +7 -0
  16. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/test_gallery_downloads.py +16 -7
  17. sphinx_examples_as_code-0.3.0/tests/test_heading_level_reuse.py +41 -0
  18. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/test_sphinx_examples_as_code.py +303 -62
  19. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/test_tinypages.py +28 -21
  20. sphinx_examples_as_code-0.2.1/sphinx_examples_as_code.egg-info/scm_version.json +0 -8
  21. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/.github/dependabot.yml +0 -0
  22. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/.github/release.yml +0 -0
  23. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/.gitignore +0 -0
  24. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/.pre-commit-config.yaml +0 -0
  25. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/setup.cfg +0 -0
  26. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/sphinx_examples_as_code.egg-info/dependency_links.txt +0 -0
  27. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/sphinx_examples_as_code.egg-info/requires.txt +0 -0
  28. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/sphinx_examples_as_code.egg-info/top_level.txt +0 -0
  29. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/gallery_fixture/conf.py +0 -0
  30. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/gallery_fixture/examples/GALLERY_HEADER.rst +0 -0
  31. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/gallery_fixture/examples/plot_minimal.py +0 -0
  32. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/gallery_fixture/index.rst +0 -0
  33. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/single_function_fixture/conf.py +0 -0
  34. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/single_function_fixture/index.rst +0 -0
  35. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/single_function_fixture/mymodule.py +0 -0
  36. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/test_single_function_page.py +0 -0
  37. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/tinypages/conf.py +0 -0
  38. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/tinypages/docstring_cases.py +0 -0
  39. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/tinypages/docstring_cases.rst +0 -0
  40. {sphinx_examples_as_code-0.2.1 → sphinx_examples_as_code-0.3.0}/tests/tinypages/index.rst +0 -0
@@ -86,4 +86,4 @@ jobs:
86
86
  with:
87
87
  name: dist
88
88
  path: dist/
89
- - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1
89
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
@@ -0,0 +1,21 @@
1
+ The MIT License
2
+
3
+ Copyright (c) 2026 The PyVista Developers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to
7
+ deal in the Software without restriction, including without limitation the
8
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
9
+ sell copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21
+ IN THE SOFTWARE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sphinx-examples-as-code
3
- Version: 0.2.1
3
+ Version: 0.3.0
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
@@ -21,7 +21,9 @@ Classifier: Programming Language :: Python :: 3.14
21
21
  Classifier: Topic :: Scientific/Engineering :: Visualization
22
22
  Requires-Python: >=3.10
23
23
  Description-Content-Type: text/markdown
24
+ License-File: LICENSE
24
25
  Requires-Dist: pyvista>=0.48
26
+ Dynamic: license-file
25
27
 
26
28
  # sphinx-examples-as-code
27
29
 
@@ -55,12 +57,15 @@ the keys you want to change from their default:
55
57
  sphinx_examples_as_code_conf = {
56
58
  'link_position': 'top',
57
59
  'formats': ['py', 'ipynb'],
58
- 'base_url': None,
59
60
  'gallery_downloads': False,
60
61
  'footer': (
61
62
  'Generated by `sphinx-examples-as-code '
62
63
  '<https://github.com/pyvista/sphinx-examples-as-code>`_'
63
64
  ),
65
+ 'link_labels': {
66
+ 'py': 'Download Python source code',
67
+ 'ipynb': 'Download Jupyter notebook',
68
+ },
64
69
  }
65
70
  ```
66
71
 
@@ -68,29 +73,26 @@ sphinx_examples_as_code_conf = {
68
73
  (default) or `'bottom'`.
69
74
  - `formats`: which downloads to generate. A list containing `'py'`, `'ipynb'`, or both
70
75
  (default). Always offered in that order regardless of how the list is written.
71
- - `base_url`: the site's published base URL (e.g. `'https://docs.pyvista.org/'`), used
72
- to turn cross-references into absolute links a downloaded, standalone file can
73
- actually use. `None` (default) means no links are generated anywhere. A missing
74
- trailing slash is added automatically; a value with no scheme or host raises a
75
- configuration error at build start.
76
+ - `link_labels`: the text of the download link(s) themselves, per format. Set only the
77
+ format(s) you want to change; any left unset keep reading their own default shown above.
76
78
  - `gallery_downloads`: opt-in takeover of
77
79
  [sphinx-gallery](https://sphinx-gallery.github.io)'s own per-example downloads.
78
80
  `False` (default) leaves sphinx-gallery pages untouched. See
79
81
  [Sphinx-Gallery integration](#sphinx-gallery-integration) below.
80
82
  - `footer`: a string appended to the end of every generated file. Defaults to a one-line
81
83
  "generated file" credit linking back to this project; set to `None` to omit it
82
- entirely. Preceded by a blank line and a `-`-only divider line, marking it as trailing
83
- boilerplate rather than more of the example's own commentary -- the divider also
84
- renders as a real horizontal rule in `.ipynb`, where the footer always gets its own
85
- dedicated cell, regardless of what content precedes it. Parsed as RST, the same as any
86
- other prose this extension handles: a hyperlink written as `` `text <url>`_ `` becomes
87
- a real clickable Markdown link in `.ipynb`, and renders inline as `text url` in `.py`.
88
- Plain text with no markup at all becomes one comment line per line of text, and
89
- blank-line-separated paragraphs stay separated -- a longer, multi-paragraph custom
90
- footer is laid out the same way any other prose in this extension is.
91
-
92
- An unrecognized key (a typo, or a leftover from an older release) raises a configuration
93
- error at build start rather than silently doing nothing.
84
+ entirely. Preceded by a blank line and a `-`-only divider line, which also renders as a
85
+ real horizontal rule in `.ipynb`; the footer always gets its own dedicated cell there.
86
+ Parsed as RST: a hyperlink written as `` `text <url>`_ `` becomes a real clickable
87
+ Markdown link in `.ipynb`, and renders inline as `text url` in `.py`. Plain text with no
88
+ markup at all becomes one comment line per line of text, and blank-line-separated
89
+ paragraphs stay separated.
90
+
91
+ An unrecognized key raises a configuration error at build start.
92
+
93
+ Cross-references and hyperlinks resolve into absolute links using Sphinx's own
94
+ [`html_baseurl`](https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_baseurl).
95
+ Leave it unset and no links are generated anywhere.
94
96
 
95
97
  Overriding a single key from the command line works via a dotted `-D` flag, e.g.
96
98
  `-D sphinx_examples_as_code_conf.link_position=bottom` -- this only touches that one
@@ -103,16 +105,14 @@ What happens to the content of an Examples section:
103
105
  - Doctest blocks (`>>> ...` / `... ...`) keep their input lines, prompts stripped, as
104
106
  real Python source. Doctest *output* lines are dropped — only the input code matters.
105
107
  - `.. code-block:: python` (or `py`) blocks are kept as-is; other languages become
106
- comments, set off with blank lines on both sides like any other directive -- so a
107
- preformatted block (e.g. an RST `::`-block illustrating a data format) still reads as
108
- a distinct block rather than melting into the surrounding prose.
108
+ comments, set off with blank lines on both sides like any other directive.
109
109
  - Admonitions (`.. note::`, `.. warning::`, `.. seealso::`, ...) become a `# LABEL:`
110
110
  comment followed by their content as comments. "See Also" is recognized in any of its
111
111
  three forms (`.. seealso::`, a bare `.. rubric:: See Also`, or a hand-written `See
112
112
  Also` heading) and always renders the same way.
113
113
  - Cross-references and inline code (`:class:`, `:meth:`, `:func:`, `:attr:`,
114
114
  double-backtick literals, ...) keep their display text, wrapped in backticks (e.g.
115
- ``:class:`pyvista.Plotter` `` -> `` `pyvista.Plotter` ``). If `base_url` is set and
115
+ ``:class:`pyvista.Plotter` `` -> `` `pyvista.Plotter` ``). If `html_baseurl` is set and
116
116
  the reference resolves: `.ipynb` turns it into a clickable link everywhere; `.py` only
117
117
  writes the link inside a "See Also" part (as `name url` on its own line) — everywhere
118
118
  else in `.py` the link is simply omitted.
@@ -121,15 +121,13 @@ What happens to the content of an Examples section:
121
121
  - Everything else text-bearing (prose, captions, other non-Python code) becomes a plain
122
122
  `#` comment.
123
123
  - Figures/images, raw HTML, sphinx-design dropdowns/tab-sets, and sphinx-tags' `.. tags::`
124
- line are dropped entirely -- none of them are documentation content a downloaded,
125
- standalone file has any use for.
124
+ line are dropped entirely.
126
125
 
127
126
  Generated `.py` files start with a `# Examples from <qualified name>` title header
128
- (gallery mode uses the page's own title instead -- see below) and follow a few
129
- whitespace conventions so the result reads like normal Python: prose
130
- directly above a code block stays attached to it, a code block is always followed by a
131
- blank line, and a directive (header, `# NOTE:`-style block) gets blank lines on both
132
- sides.
127
+ (gallery mode uses the page's own title instead -- see below), with a few whitespace
128
+ conventions: prose directly above a code block stays attached to it, a code block is
129
+ always followed by a blank line, and a directive (header, `# NOTE:`-style block) gets
130
+ blank lines on both sides.
133
131
 
134
132
  Generated `.ipynb` notebooks use the same content, split into alternating code/markdown
135
133
  cells instead.
@@ -146,35 +144,30 @@ full example code" note and the `.py`/`.ipynb`/`.zip` download footer are remove
146
144
  the page, replaced with download link(s) built by this extension instead — same
147
145
  conversion rules as above, applied to the whole page rather than one Examples section.
148
146
  The generated file's header uses the page's own title (e.g. `# Create Circular Arcs`)
149
- rather than the generic `# Examples from <docname>` — gallery mode converts the whole
150
- page, not an Examples section carved out of a larger docstring, so there's no "from"
151
- framing to make.
147
+ rather than the generic `# Examples from <docname>`.
152
148
 
153
149
  The "Total running time" line and the "Gallery generated by Sphinx-Gallery" credit line
154
150
  stay on the rendered page untouched, but never make it into the generated download
155
- itself: a "Total running time" sitting inside a file you just downloaded and ran
156
- yourself would read like it's timing *your* run, not the build's, and a "Generated by
157
- Sphinx-Gallery" credit is simply wrong inside a file this extension generated.
151
+ itself.
158
152
 
159
153
  Detection is automatic and per-page — any page without a sphinx-gallery download footer
160
- is left completely untouched, so turning this on is safe even on a site that mixes
161
- gallery pages with ordinary docstring/prose pages using the Examples-section behavior
162
- above.
154
+ is left completely untouched. Gallery pages and ordinary docstring/prose pages (using
155
+ the Examples-section behavior above) can coexist on the same site.
163
156
 
164
157
  A `# %%` cell with its own RST heading gets the same title-plus-underline treatment as
165
- the file's own header, rather than melting into whatever prose follows it -- and for
166
- that reason renders as a real Markdown heading in `.ipynb`, not just plain text.
158
+ the file's own header, and renders as a real Markdown heading in `.ipynb` (`<h1>` for the
159
+ file's own header, `# Title` + `# ====`; `<h2>` for a cell heading, `# Title` + `# ----`).
160
+ A cell heading nested under the page's own title (the common case) is level 2; one that
161
+ reuses the page title's own underline character -- a real RST heading, one level up --
162
+ is level 1, matching the file's own header.
167
163
 
168
164
  Two things worth knowing before turning this on:
169
165
 
170
- - It's built against sphinx-gallery's own long-standing RST/HTML output (the same
171
- `sphx-glr-*` CSS classes its own theming depends on), not a documented extension API.
172
- It's unlikely to change, but a future sphinx-gallery release could still shift that
173
- structure without warning.
174
- - sphinx-gallery registers its own `.py`/`.ipynb`/`.zip` downloads for copying into
175
- `_downloads/` before this extension gets a chance to remove the links pointing to
176
- them, so those files still end up in the build output even though nothing on the page
177
- links to them anymore.
166
+ - It's built against sphinx-gallery's own RST/HTML output (the same `sphx-glr-*` CSS
167
+ classes its own theming depends on), not a documented extension API. A future
168
+ sphinx-gallery release could shift that structure without warning.
169
+ - sphinx-gallery's own `.py`/`.ipynb`/`.zip` downloads still end up copied into
170
+ `_downloads/`, even though nothing on the page links to them anymore.
178
171
 
179
172
  ## Development
180
173
 
@@ -30,12 +30,15 @@ the keys you want to change from their default:
30
30
  sphinx_examples_as_code_conf = {
31
31
  'link_position': 'top',
32
32
  'formats': ['py', 'ipynb'],
33
- 'base_url': None,
34
33
  'gallery_downloads': False,
35
34
  'footer': (
36
35
  'Generated by `sphinx-examples-as-code '
37
36
  '<https://github.com/pyvista/sphinx-examples-as-code>`_'
38
37
  ),
38
+ 'link_labels': {
39
+ 'py': 'Download Python source code',
40
+ 'ipynb': 'Download Jupyter notebook',
41
+ },
39
42
  }
40
43
  ```
41
44
 
@@ -43,29 +46,26 @@ sphinx_examples_as_code_conf = {
43
46
  (default) or `'bottom'`.
44
47
  - `formats`: which downloads to generate. A list containing `'py'`, `'ipynb'`, or both
45
48
  (default). Always offered in that order regardless of how the list is written.
46
- - `base_url`: the site's published base URL (e.g. `'https://docs.pyvista.org/'`), used
47
- to turn cross-references into absolute links a downloaded, standalone file can
48
- actually use. `None` (default) means no links are generated anywhere. A missing
49
- trailing slash is added automatically; a value with no scheme or host raises a
50
- configuration error at build start.
49
+ - `link_labels`: the text of the download link(s) themselves, per format. Set only the
50
+ format(s) you want to change; any left unset keep reading their own default shown above.
51
51
  - `gallery_downloads`: opt-in takeover of
52
52
  [sphinx-gallery](https://sphinx-gallery.github.io)'s own per-example downloads.
53
53
  `False` (default) leaves sphinx-gallery pages untouched. See
54
54
  [Sphinx-Gallery integration](#sphinx-gallery-integration) below.
55
55
  - `footer`: a string appended to the end of every generated file. Defaults to a one-line
56
56
  "generated file" credit linking back to this project; set to `None` to omit it
57
- entirely. Preceded by a blank line and a `-`-only divider line, marking it as trailing
58
- boilerplate rather than more of the example's own commentary -- the divider also
59
- renders as a real horizontal rule in `.ipynb`, where the footer always gets its own
60
- dedicated cell, regardless of what content precedes it. Parsed as RST, the same as any
61
- other prose this extension handles: a hyperlink written as `` `text <url>`_ `` becomes
62
- a real clickable Markdown link in `.ipynb`, and renders inline as `text url` in `.py`.
63
- Plain text with no markup at all becomes one comment line per line of text, and
64
- blank-line-separated paragraphs stay separated -- a longer, multi-paragraph custom
65
- footer is laid out the same way any other prose in this extension is.
66
-
67
- An unrecognized key (a typo, or a leftover from an older release) raises a configuration
68
- error at build start rather than silently doing nothing.
57
+ entirely. Preceded by a blank line and a `-`-only divider line, which also renders as a
58
+ real horizontal rule in `.ipynb`; the footer always gets its own dedicated cell there.
59
+ Parsed as RST: a hyperlink written as `` `text <url>`_ `` becomes a real clickable
60
+ Markdown link in `.ipynb`, and renders inline as `text url` in `.py`. Plain text with no
61
+ markup at all becomes one comment line per line of text, and blank-line-separated
62
+ paragraphs stay separated.
63
+
64
+ An unrecognized key raises a configuration error at build start.
65
+
66
+ Cross-references and hyperlinks resolve into absolute links using Sphinx's own
67
+ [`html_baseurl`](https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_baseurl).
68
+ Leave it unset and no links are generated anywhere.
69
69
 
70
70
  Overriding a single key from the command line works via a dotted `-D` flag, e.g.
71
71
  `-D sphinx_examples_as_code_conf.link_position=bottom` -- this only touches that one
@@ -78,16 +78,14 @@ What happens to the content of an Examples section:
78
78
  - Doctest blocks (`>>> ...` / `... ...`) keep their input lines, prompts stripped, as
79
79
  real Python source. Doctest *output* lines are dropped — only the input code matters.
80
80
  - `.. code-block:: python` (or `py`) blocks are kept as-is; other languages become
81
- comments, set off with blank lines on both sides like any other directive -- so a
82
- preformatted block (e.g. an RST `::`-block illustrating a data format) still reads as
83
- a distinct block rather than melting into the surrounding prose.
81
+ comments, set off with blank lines on both sides like any other directive.
84
82
  - Admonitions (`.. note::`, `.. warning::`, `.. seealso::`, ...) become a `# LABEL:`
85
83
  comment followed by their content as comments. "See Also" is recognized in any of its
86
84
  three forms (`.. seealso::`, a bare `.. rubric:: See Also`, or a hand-written `See
87
85
  Also` heading) and always renders the same way.
88
86
  - Cross-references and inline code (`:class:`, `:meth:`, `:func:`, `:attr:`,
89
87
  double-backtick literals, ...) keep their display text, wrapped in backticks (e.g.
90
- ``:class:`pyvista.Plotter` `` -> `` `pyvista.Plotter` ``). If `base_url` is set and
88
+ ``:class:`pyvista.Plotter` `` -> `` `pyvista.Plotter` ``). If `html_baseurl` is set and
91
89
  the reference resolves: `.ipynb` turns it into a clickable link everywhere; `.py` only
92
90
  writes the link inside a "See Also" part (as `name url` on its own line) — everywhere
93
91
  else in `.py` the link is simply omitted.
@@ -96,15 +94,13 @@ What happens to the content of an Examples section:
96
94
  - Everything else text-bearing (prose, captions, other non-Python code) becomes a plain
97
95
  `#` comment.
98
96
  - Figures/images, raw HTML, sphinx-design dropdowns/tab-sets, and sphinx-tags' `.. tags::`
99
- line are dropped entirely -- none of them are documentation content a downloaded,
100
- standalone file has any use for.
97
+ line are dropped entirely.
101
98
 
102
99
  Generated `.py` files start with a `# Examples from <qualified name>` title header
103
- (gallery mode uses the page's own title instead -- see below) and follow a few
104
- whitespace conventions so the result reads like normal Python: prose
105
- directly above a code block stays attached to it, a code block is always followed by a
106
- blank line, and a directive (header, `# NOTE:`-style block) gets blank lines on both
107
- sides.
100
+ (gallery mode uses the page's own title instead -- see below), with a few whitespace
101
+ conventions: prose directly above a code block stays attached to it, a code block is
102
+ always followed by a blank line, and a directive (header, `# NOTE:`-style block) gets
103
+ blank lines on both sides.
108
104
 
109
105
  Generated `.ipynb` notebooks use the same content, split into alternating code/markdown
110
106
  cells instead.
@@ -121,35 +117,30 @@ full example code" note and the `.py`/`.ipynb`/`.zip` download footer are remove
121
117
  the page, replaced with download link(s) built by this extension instead — same
122
118
  conversion rules as above, applied to the whole page rather than one Examples section.
123
119
  The generated file's header uses the page's own title (e.g. `# Create Circular Arcs`)
124
- rather than the generic `# Examples from <docname>` — gallery mode converts the whole
125
- page, not an Examples section carved out of a larger docstring, so there's no "from"
126
- framing to make.
120
+ rather than the generic `# Examples from <docname>`.
127
121
 
128
122
  The "Total running time" line and the "Gallery generated by Sphinx-Gallery" credit line
129
123
  stay on the rendered page untouched, but never make it into the generated download
130
- itself: a "Total running time" sitting inside a file you just downloaded and ran
131
- yourself would read like it's timing *your* run, not the build's, and a "Generated by
132
- Sphinx-Gallery" credit is simply wrong inside a file this extension generated.
124
+ itself.
133
125
 
134
126
  Detection is automatic and per-page — any page without a sphinx-gallery download footer
135
- is left completely untouched, so turning this on is safe even on a site that mixes
136
- gallery pages with ordinary docstring/prose pages using the Examples-section behavior
137
- above.
127
+ is left completely untouched. Gallery pages and ordinary docstring/prose pages (using
128
+ the Examples-section behavior above) can coexist on the same site.
138
129
 
139
130
  A `# %%` cell with its own RST heading gets the same title-plus-underline treatment as
140
- the file's own header, rather than melting into whatever prose follows it -- and for
141
- that reason renders as a real Markdown heading in `.ipynb`, not just plain text.
131
+ the file's own header, and renders as a real Markdown heading in `.ipynb` (`<h1>` for the
132
+ file's own header, `# Title` + `# ====`; `<h2>` for a cell heading, `# Title` + `# ----`).
133
+ A cell heading nested under the page's own title (the common case) is level 2; one that
134
+ reuses the page title's own underline character -- a real RST heading, one level up --
135
+ is level 1, matching the file's own header.
142
136
 
143
137
  Two things worth knowing before turning this on:
144
138
 
145
- - It's built against sphinx-gallery's own long-standing RST/HTML output (the same
146
- `sphx-glr-*` CSS classes its own theming depends on), not a documented extension API.
147
- It's unlikely to change, but a future sphinx-gallery release could still shift that
148
- structure without warning.
149
- - sphinx-gallery registers its own `.py`/`.ipynb`/`.zip` downloads for copying into
150
- `_downloads/` before this extension gets a chance to remove the links pointing to
151
- them, so those files still end up in the build output even though nothing on the page
152
- links to them anymore.
139
+ - It's built against sphinx-gallery's own RST/HTML output (the same `sphx-glr-*` CSS
140
+ classes its own theming depends on), not a documented extension API. A future
141
+ sphinx-gallery release could shift that structure without warning.
142
+ - sphinx-gallery's own `.py`/`.ipynb`/`.zip` downloads still end up copied into
143
+ `_downloads/`, even though nothing on the page links to them anymore.
153
144
 
154
145
  ## Development
155
146
 
@@ -96,6 +96,7 @@ ignore = ['D203', 'D211', 'D213']
96
96
  # sphinx-gallery example scripts: the module docstring is an RST page title
97
97
  # (title line + underline), not prose -- pydocstyle's summary-line rules don't apply.
98
98
  'tests/gallery_fixture/examples/*' = ['D205', 'D400', 'D415', 'T201']
99
+ 'tests/heading_level_fixture/examples/*' = ['D205', 'D400', 'D415', 'T201']
99
100
 
100
101
  [tool.ruff.lint.isort]
101
102
  combine-as-imports = true
@@ -104,6 +105,7 @@ force-sort-within-sections = true
104
105
  required-imports = ['from __future__ import annotations']
105
106
 
106
107
  [tool.setuptools]
108
+ license-files = ['LICENSE']
107
109
  packages = ['sphinx_examples_as_code']
108
110
 
109
111
  [tool.setuptools_scm]
@@ -169,6 +169,21 @@ class _RenderContext:
169
169
  in_footer: bool = False
170
170
 
171
171
 
172
+ def _normalized_html_baseurl(app: Sphinx) -> str | None:
173
+ """Sphinx's own ``html_baseurl`` config, normalized for use with ``urljoin``.
174
+
175
+ ``None`` if unset or not a usable absolute URL. Adds a missing trailing
176
+ slash.
177
+ """
178
+ base_url = app.config.html_baseurl
179
+ if not base_url:
180
+ return None
181
+ parsed = urlsplit(base_url)
182
+ if parsed.scheme not in ('http', 'https') or not parsed.netloc:
183
+ return None
184
+ return base_url if base_url.endswith('/') else base_url + '/'
185
+
186
+
172
187
  def _resolve_link_url(node: nodes.reference, ctx: _RenderContext) -> str | None:
173
188
  """Resolve a reference node's target to an absolute URL, if possible.
174
189
 
@@ -181,7 +196,7 @@ def _resolve_link_url(node: nodes.reference, ctx: _RenderContext) -> str | None:
181
196
  if refuri and urlsplit(refuri).netloc:
182
197
  return refuri # already absolute (an external hyperlink)
183
198
 
184
- base_url = ctx.app.config.sphinx_examples_as_code_conf['base_url']
199
+ base_url = _normalized_html_baseurl(ctx.app)
185
200
  if not base_url:
186
201
  return None
187
202
  current_page_url = urljoin(base_url, ctx.app.builder.get_target_uri(ctx.docname))
@@ -353,18 +368,42 @@ def _convert_literal_block(node: nodes.literal_block) -> list[Segment]:
353
368
  return [('directive', comment_lines)]
354
369
 
355
370
 
356
- def _title_underline_segment(title: str) -> Segment:
357
- """Build a title + underline directive segment, e.g. ``# Title`` + ``# -----``.
371
+ #: Setext underline character per heading level -- ``=`` for the file's own
372
+ #: top-level header (level 1), ``-`` for a subheading (level 2). CommonMark's
373
+ #: setext syntax only supports these two levels.
374
+ _TITLE_UNDERLINE_CHARS = {1: '=', 2: '-'}
375
+
358
376
 
359
- Shared by the file's own header (see ``_header_segment``) and gallery
360
- mode's sibling-section headings. The underline is what makes this render
361
- as a real Markdown heading in ``.ipynb`` (setext-style, once the ``#``
362
- comment prefix is stripped for markdown cells) rather than plain text.
377
+ def _title_underline_segment(title: str, level: int = 1) -> Segment:
378
+ """Build a title + underline directive segment, e.g. ``# Title`` + ``# =====``.
379
+
380
+ Shared by the file's own header (see ``_header_segment``), which is
381
+ always level 1, and gallery mode's sibling-section headings, which are
382
+ level 2. The underline is what makes this render as a real Markdown
383
+ heading in ``.ipynb`` (setext-style, once the ``#`` comment prefix is
384
+ stripped for markdown cells) rather than plain text.
363
385
  """
364
- underline = '-' * len(title)
386
+ underline = _TITLE_UNDERLINE_CHARS[level] * len(title)
365
387
  return ('directive', [f'# {title}', f'# {underline}'])
366
388
 
367
389
 
390
+ def _heading_level(title: nodes.title) -> int:
391
+ """Compute a title's heading level from its ``nodes.section`` nesting depth.
392
+
393
+ Counts the title's own section and each ancestor ``nodes.section``.
394
+ Running this on the file's own header would give level 1; a title
395
+ nested inside that comes out level 2, and so does anything nested
396
+ deeper (clamped -- the deepest level CommonMark's setext syntax can
397
+ represent).
398
+ """
399
+ level = 0
400
+ node: nodes.Node | None = title.parent
401
+ while isinstance(node, nodes.section):
402
+ level += 1
403
+ node = node.parent
404
+ return min(level, 2)
405
+
406
+
368
407
  def _convert_admonition(
369
408
  node: nodes.Element, label: str, ctx: _RenderContext, *, skip_first_title: bool = False
370
409
  ) -> list[Segment]:
@@ -403,12 +442,12 @@ def _convert_node(node: nodes.Node, ctx: _RenderContext) -> list[Segment]:
403
442
  if isinstance(node, nodes.title):
404
443
  # Gallery mode only: a sibling section's own heading (the page's own
405
444
  # outer title is filtered out before reaching here -- see
406
- # _process_gallery_page). Same title+underline treatment as the
407
- # file's own header, so it reads as a real heading -- and renders as
408
- # an actual Markdown heading in .ipynb -- instead of melting into
409
- # whatever prose follows it with no visual break.
445
+ # _process_gallery_page). Renders as a real Markdown heading in
446
+ # .ipynb, at a level relative to its own section nesting (see
447
+ # _heading_level).
410
448
  title_text = _render_inline(node, ctx).strip()
411
- return [_title_underline_segment(title_text)] if title_text else []
449
+ level = _heading_level(node)
450
+ return [_title_underline_segment(title_text, level=level)] if title_text else []
412
451
  if isinstance(node, (*_CONTAINER_TYPES, nodes.section)):
413
452
  # nodes.section (gallery mode only): a sphinx-gallery ``# %%`` cell
414
453
  # with its own RST heading becomes a *sibling* section at the
@@ -699,12 +738,13 @@ def _write_notebook(app: Sphinx, name: str, notebook: dict) -> str:
699
738
  return _write_download_file(app, name, 'ipynb', json.dumps(notebook, indent=1))
700
739
 
701
740
 
702
- #: Download link text per format, and the fixed order they're offered in
703
- #: regardless of how ``sphinx_examples_as_code_conf['formats']`` lists them.
704
- _FORMAT_LABELS = {
741
+ #: Default download link text per format -- see ``sphinx_examples_as_code_conf['link_labels']``.
742
+ _DEFAULT_LINK_LABELS = {
705
743
  'py': 'Download Python source code',
706
744
  'ipynb': 'Download Jupyter notebook',
707
745
  }
746
+ #: The fixed order formats are offered in, regardless of how
747
+ #: ``sphinx_examples_as_code_conf['formats']`` lists them.
708
748
  _FORMAT_ORDER = ('py', 'ipynb')
709
749
 
710
750
 
@@ -749,6 +789,7 @@ def _build_download_entries(
749
789
  nodes_in_span: list[nodes.Node],
750
790
  formats: list[str],
751
791
  footer: str | None,
792
+ link_labels: dict[str, str],
752
793
  ) -> list[tuple[str, str]]:
753
794
  """Convert a span of nodes into written ``.py``/``.ipynb`` files, per ``formats``.
754
795
 
@@ -775,7 +816,7 @@ def _build_download_entries(
775
816
  for fmt in _FORMAT_ORDER:
776
817
  if fmt not in formats:
777
818
  continue
778
- label = _FORMAT_LABELS[fmt]
819
+ label = link_labels[fmt]
779
820
  if fmt == 'py':
780
821
  rel_path = _write_source(app, name, source)
781
822
  else:
@@ -807,6 +848,7 @@ def _process_span(
807
848
  position: str,
808
849
  formats: list[str],
809
850
  footer: str | None,
851
+ link_labels: dict[str, str],
810
852
  ) -> None:
811
853
  """Convert one Examples span and insert download link(s) if it has real code."""
812
854
  nodes_in_span = list(parent.children[start:end])
@@ -816,7 +858,7 @@ def _process_span(
816
858
 
817
859
  name = _qualified_name_for(heading, docname, counter)
818
860
  entries = _build_download_entries(
819
- app, docname, name, _header_segment(name), nodes_in_span, formats, footer
861
+ app, docname, name, _header_segment(name), nodes_in_span, formats, footer, link_labels
820
862
  )
821
863
  if not entries:
822
864
  return
@@ -900,6 +942,7 @@ def _process_gallery_page(
900
942
  position: str,
901
943
  formats: list[str],
902
944
  footer: str | None,
945
+ link_labels: dict[str, str],
903
946
  ) -> None:
904
947
  """Replace a sphinx-gallery page's own download footer with converted downloads.
905
948
 
@@ -936,7 +979,9 @@ def _process_gallery_page(
936
979
  # no "from" framing to make. Falls back to the usual header on the off
937
980
  # chance the page has no title of its own.
938
981
  header = _title_underline_segment(title) if title else _header_segment(name)
939
- entries = _build_download_entries(app, docname, name, header, nodes_in_span, formats, footer)
982
+ entries = _build_download_entries(
983
+ app, docname, name, header, nodes_in_span, formats, footer, link_labels
984
+ )
940
985
  if not entries:
941
986
  return
942
987
 
@@ -960,9 +1005,10 @@ def _process_doctree(app: Sphinx, doctree: nodes.document, docname: str) -> None
960
1005
  position = conf['link_position']
961
1006
  formats = conf['formats']
962
1007
  footer = conf['footer']
1008
+ link_labels = conf['link_labels']
963
1009
 
964
1010
  if conf['gallery_downloads']:
965
- _process_gallery_page(app, docname, doctree, position, formats, footer)
1011
+ _process_gallery_page(app, docname, doctree, position, formats, footer, link_labels)
966
1012
 
967
1013
  # Process spans per shared parent, last to first: inserting a download
968
1014
  # node shifts every later sibling index by one, so this stays correct
@@ -972,7 +1018,19 @@ def _process_doctree(app: Sphinx, doctree: nodes.document, docname: str) -> None
972
1018
  for parent, start, end, heading, counter in sorted(
973
1019
  numbered_spans, key=lambda s: (id(s[0]), -s[1])
974
1020
  ):
975
- _process_span(app, docname, parent, start, end, heading, counter, position, formats, footer)
1021
+ _process_span(
1022
+ app,
1023
+ docname,
1024
+ parent,
1025
+ start,
1026
+ end,
1027
+ heading,
1028
+ counter,
1029
+ position,
1030
+ formats,
1031
+ footer,
1032
+ link_labels,
1033
+ )
976
1034
 
977
1035
 
978
1036
  # One line, one link: a second sentence pointing at the issue tracker read
@@ -989,33 +1047,12 @@ _DEFAULT_FOOTER = (
989
1047
  _CONF_DEFAULTS: dict[str, object] = {
990
1048
  'link_position': 'top',
991
1049
  'formats': ['py', 'ipynb'],
992
- 'base_url': None,
993
1050
  'gallery_downloads': False,
994
1051
  'footer': _DEFAULT_FOOTER,
1052
+ 'link_labels': _DEFAULT_LINK_LABELS,
995
1053
  }
996
1054
 
997
1055
 
998
- def _normalize_base_url(base_url: str | None) -> str | None:
999
- """Validate and normalize a ``base_url`` value.
1000
-
1001
- Catches two common typos loudly instead of silently generating wrong
1002
- links: a missing scheme (parses with no netloc at all), and a subpath
1003
- with no trailing slash (``urljoin`` would drop the last segment).
1004
- """
1005
- if not base_url:
1006
- return None
1007
-
1008
- parsed = urlsplit(base_url)
1009
- if parsed.scheme not in ('http', 'https') or not parsed.netloc:
1010
- msg = (
1011
- f"sphinx_examples_as_code_conf['base_url']={base_url!r} does not look "
1012
- "like a valid absolute URL (expected something like 'https://docs.example.com/')."
1013
- )
1014
- raise ConfigError(msg)
1015
-
1016
- return base_url if base_url.endswith('/') else base_url + '/'
1017
-
1018
-
1019
1056
  def _coerce_conf_value(key: str, value: object) -> object:
1020
1057
  """Coerce a value that may have arrived as a raw string; pass anything else through.
1021
1058
 
@@ -1039,7 +1076,27 @@ def _coerce_conf_value(key: str, value: object) -> object:
1039
1076
  f"sphinx_examples_as_code_conf['gallery_downloads'] must be '0' or '1', got {value!r}."
1040
1077
  )
1041
1078
  raise ConfigError(msg)
1042
- return value # link_position, base_url, footer: a string is already the real type
1079
+ return value # link_position, footer: a string is already the real type
1080
+
1081
+
1082
+ def _merged_link_labels(value: object) -> dict[str, str]:
1083
+ """Merge a user-provided ``link_labels`` override over the defaults.
1084
+
1085
+ A partial override (e.g. just ``{'py': 'Get the script'}``) only changes
1086
+ that one format's label; any not given keep their default.
1087
+ """
1088
+ if not isinstance(value, dict):
1089
+ msg = f"sphinx_examples_as_code_conf['link_labels'] must be a dict, got {value!r}."
1090
+ raise ConfigError(msg)
1091
+ unknown = sorted(set(value) - set(_DEFAULT_LINK_LABELS))
1092
+ if unknown:
1093
+ valid = ', '.join(sorted(_DEFAULT_LINK_LABELS))
1094
+ msg = (
1095
+ f"sphinx_examples_as_code_conf['link_labels'] has unknown key(s) {unknown} "
1096
+ f'(valid keys: {valid}).'
1097
+ )
1098
+ raise ConfigError(msg)
1099
+ return {**_DEFAULT_LINK_LABELS, **value}
1043
1100
 
1044
1101
 
1045
1102
  def _finalize_conf(_app: Sphinx, config: Config) -> None:
@@ -1059,7 +1116,7 @@ def _finalize_conf(_app: Sphinx, config: Config) -> None:
1059
1116
 
1060
1117
  merged = dict(_CONF_DEFAULTS)
1061
1118
  merged.update((key, _coerce_conf_value(key, value)) for key, value in user_conf.items())
1062
- merged['base_url'] = _normalize_base_url(merged['base_url'])
1119
+ merged['link_labels'] = _merged_link_labels(merged['link_labels'])
1063
1120
  config.sphinx_examples_as_code_conf = merged
1064
1121
 
1065
1122