sphinx-examples-as-code 0.8.0__tar.gz → 0.8.2__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.8.0 → sphinx_examples_as_code-0.8.2}/PKG-INFO +14 -9
  2. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/README.md +13 -8
  3. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/sphinx_examples_as_code/__init__.py +99 -15
  4. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/sphinx_examples_as_code/_version.py +3 -3
  5. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/sphinx_examples_as_code.egg-info/PKG-INFO +14 -9
  6. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/sphinx_examples_as_code.egg-info/scm_version.json +2 -2
  7. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/test_gallery_downloads.py +1 -1
  8. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/test_single_function_page.py +2 -2
  9. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/test_sphinx_examples_as_code.py +93 -2
  10. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/test_tinypages.py +12 -24
  11. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/.github/dependabot.yml +0 -0
  12. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/.github/release.yml +0 -0
  13. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/.github/workflows/ci.yml +0 -0
  14. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/.gitignore +0 -0
  15. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/.pre-commit-config.yaml +0 -0
  16. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/LICENSE +0 -0
  17. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/pyproject.toml +0 -0
  18. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/setup.cfg +0 -0
  19. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/sphinx_examples_as_code.egg-info/SOURCES.txt +0 -0
  20. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/sphinx_examples_as_code.egg-info/dependency_links.txt +0 -0
  21. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/sphinx_examples_as_code.egg-info/requires.txt +0 -0
  22. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/sphinx_examples_as_code.egg-info/scm_file_list.json +0 -0
  23. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/sphinx_examples_as_code.egg-info/top_level.txt +0 -0
  24. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/gallery_fixture/conf.py +0 -0
  25. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/gallery_fixture/examples/GALLERY_HEADER.rst +0 -0
  26. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/gallery_fixture/examples/plot_minimal.py +0 -0
  27. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/gallery_fixture/index.rst +0 -0
  28. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/heading_level_fixture/conf.py +0 -0
  29. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/heading_level_fixture/examples/GALLERY_HEADER.rst +0 -0
  30. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/heading_level_fixture/examples/plot_reused_level.py +0 -0
  31. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/heading_level_fixture/examples/plot_three_levels.py +0 -0
  32. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/heading_level_fixture/index.rst +0 -0
  33. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/single_function_fixture/conf.py +0 -0
  34. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/single_function_fixture/index.rst +0 -0
  35. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/single_function_fixture/mymodule.py +0 -0
  36. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/test_heading_level_reuse.py +0 -0
  37. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/tinypages/conf.py +0 -0
  38. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/tinypages/docstring_cases.py +0 -0
  39. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/tests/tinypages/docstring_cases.rst +0 -0
  40. {sphinx_examples_as_code-0.8.0 → sphinx_examples_as_code-0.8.2}/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.8.0
3
+ Version: 0.8.2
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
@@ -96,11 +96,13 @@ sphinx_examples_as_code_conf = {
96
96
  (default) or `'bottom'`.
97
97
  - `formats`: which downloads to generate. A list containing any of `'python'` (a plain
98
98
  Python script, `<name>.py`), `'jupyter'` (a Jupyter notebook, `<name>.ipynb`) and
99
- `'marimo'` (a marimo notebook, `<name>.marimo.py`); defaults to all three. Always
99
+ `'marimo'` (a marimo notebook, `<name>_marimo.py`); defaults to all three. Always
100
100
  offered in that order regardless of how the list is written. Without marimo 0.14 or
101
101
  newer installed, the default skips the marimo download, and a `formats` that names
102
102
  `'marimo'` (in `conf.py` or through `-D`) stops the build with a configuration error. An unknown format raises a configuration error too;
103
- the old `'py'` and `'ipynb'` names say what replaced them.
103
+ the old `'py'` and `'ipynb'` names say what replaced them. `<name>` is the documented
104
+ object's full name without its top-level package, e.g. `Plotter_show` for
105
+ `pyvista.Plotter.show`, or the gallery example's file name.
104
106
  - `link_labels`: the text of the download link(s) themselves, per format. Set only the
105
107
  format(s) you want to change; any left unset keep reading their own default shown above.
106
108
  - `gallery_downloads`: opt-in takeover of
@@ -149,8 +151,9 @@ imports, plus any `dependencies` set for its format, and says how to run it:
149
151
  needed, so the command works in bash, zsh, PowerShell and cmd. An argument containing
150
152
  `"`, `$`, a backtick, `\`, `%` or a `!` not followed by `=` is single-quoted instead,
151
153
  which cmd does not understand.
152
- - marimo notebooks start with the same `# /// script` block, which also lists `marimo`,
153
- followed by `# Open this notebook with: uvx marimo edit --sandbox <name>.marimo.py`.
154
+ - marimo notebooks start with the same `# /// script` block, which also lists `marimo`
155
+ unless another dependency installs it,
156
+ followed by `# Open this notebook with: uvx marimo edit --sandbox <name>_marimo.py`.
154
157
  - Each run command is followed by a line linking to
155
158
  [uv's installation guide](https://docs.astral.sh/uv/getting-started/installation/).
156
159
 
@@ -158,7 +161,9 @@ Imports are read from the code, not run. Each imported top-level module maps to
158
161
  project that provides it in the environment building the docs; standard-library
159
162
  modules, relative imports and modules no single installed project provides are left
160
163
  out. Projects the code needs but never imports, such as an optional backend, go in
161
- `dependencies`.
164
+ `dependencies`. A dependency with no version or extras of its own is left out when another listed
165
+ one already installs it on every platform, e.g. `numpy` and `marimo` next to
166
+ `pyvista[marimo]`.
162
167
 
163
168
  The project named by Sphinx's own `project` setting (e.g. `project = 'PyVista'`) gets
164
169
  extra treatment when an example imports it and it is installed: its dependency becomes
@@ -169,8 +174,8 @@ Unless `requires_python` is set, the block's `requires-python` combines:
169
174
 
170
175
  - that project's own `Requires-Python`, e.g. `>=3.10`, when it is listed;
171
176
  - `<3.N`, one minor version above the newest Python named in the `Programming Language ::
172
- Python :: 3.X` classifiers of that project and of every listed dependency installed
173
- from a platform-specific wheel, as installed in the environment building the docs.
177
+ Python :: 3.X` classifiers of that project and of every dependency, listed or left out,
178
+ installed from a platform-specific wheel, as installed in the environment building the docs.
174
179
  Pure-Python dependencies and dependencies without such classifiers don't count, and
175
180
  the bound is dropped when it would exclude every Python the project's own
176
181
  `Requires-Python` allows.
@@ -221,7 +226,7 @@ cells instead.
221
226
 
222
227
  Generated marimo notebooks are that same Jupyter notebook converted by marimo's own
223
228
  converter, the one behind `marimo convert`. `from __future__` imports are dropped first,
224
- so the notebook also runs as a plain script (`python <name>.marimo.py`).
229
+ so the notebook also runs as a plain script (`python <name>_marimo.py`).
225
230
 
226
231
  A download link is only added if the resulting code contains at least one real
227
232
  executable statement.
@@ -64,11 +64,13 @@ sphinx_examples_as_code_conf = {
64
64
  (default) or `'bottom'`.
65
65
  - `formats`: which downloads to generate. A list containing any of `'python'` (a plain
66
66
  Python script, `<name>.py`), `'jupyter'` (a Jupyter notebook, `<name>.ipynb`) and
67
- `'marimo'` (a marimo notebook, `<name>.marimo.py`); defaults to all three. Always
67
+ `'marimo'` (a marimo notebook, `<name>_marimo.py`); defaults to all three. Always
68
68
  offered in that order regardless of how the list is written. Without marimo 0.14 or
69
69
  newer installed, the default skips the marimo download, and a `formats` that names
70
70
  `'marimo'` (in `conf.py` or through `-D`) stops the build with a configuration error. An unknown format raises a configuration error too;
71
- the old `'py'` and `'ipynb'` names say what replaced them.
71
+ the old `'py'` and `'ipynb'` names say what replaced them. `<name>` is the documented
72
+ object's full name without its top-level package, e.g. `Plotter_show` for
73
+ `pyvista.Plotter.show`, or the gallery example's file name.
72
74
  - `link_labels`: the text of the download link(s) themselves, per format. Set only the
73
75
  format(s) you want to change; any left unset keep reading their own default shown above.
74
76
  - `gallery_downloads`: opt-in takeover of
@@ -117,8 +119,9 @@ imports, plus any `dependencies` set for its format, and says how to run it:
117
119
  needed, so the command works in bash, zsh, PowerShell and cmd. An argument containing
118
120
  `"`, `$`, a backtick, `\`, `%` or a `!` not followed by `=` is single-quoted instead,
119
121
  which cmd does not understand.
120
- - marimo notebooks start with the same `# /// script` block, which also lists `marimo`,
121
- followed by `# Open this notebook with: uvx marimo edit --sandbox <name>.marimo.py`.
122
+ - marimo notebooks start with the same `# /// script` block, which also lists `marimo`
123
+ unless another dependency installs it,
124
+ followed by `# Open this notebook with: uvx marimo edit --sandbox <name>_marimo.py`.
122
125
  - Each run command is followed by a line linking to
123
126
  [uv's installation guide](https://docs.astral.sh/uv/getting-started/installation/).
124
127
 
@@ -126,7 +129,9 @@ Imports are read from the code, not run. Each imported top-level module maps to
126
129
  project that provides it in the environment building the docs; standard-library
127
130
  modules, relative imports and modules no single installed project provides are left
128
131
  out. Projects the code needs but never imports, such as an optional backend, go in
129
- `dependencies`.
132
+ `dependencies`. A dependency with no version or extras of its own is left out when another listed
133
+ one already installs it on every platform, e.g. `numpy` and `marimo` next to
134
+ `pyvista[marimo]`.
130
135
 
131
136
  The project named by Sphinx's own `project` setting (e.g. `project = 'PyVista'`) gets
132
137
  extra treatment when an example imports it and it is installed: its dependency becomes
@@ -137,8 +142,8 @@ Unless `requires_python` is set, the block's `requires-python` combines:
137
142
 
138
143
  - that project's own `Requires-Python`, e.g. `>=3.10`, when it is listed;
139
144
  - `<3.N`, one minor version above the newest Python named in the `Programming Language ::
140
- Python :: 3.X` classifiers of that project and of every listed dependency installed
141
- from a platform-specific wheel, as installed in the environment building the docs.
145
+ Python :: 3.X` classifiers of that project and of every dependency, listed or left out,
146
+ installed from a platform-specific wheel, as installed in the environment building the docs.
142
147
  Pure-Python dependencies and dependencies without such classifiers don't count, and
143
148
  the bound is dropped when it would exclude every Python the project's own
144
149
  `Requires-Python` allows.
@@ -189,7 +194,7 @@ cells instead.
189
194
 
190
195
  Generated marimo notebooks are that same Jupyter notebook converted by marimo's own
191
196
  converter, the one behind `marimo convert`. `from __future__` imports are dropped first,
192
- so the notebook also runs as a plain script (`python <name>.marimo.py`).
197
+ so the notebook also runs as a plain script (`python <name>_marimo.py`).
193
198
 
194
199
  A download link is only added if the resulting code contains at least one real
195
200
  executable statement.
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import ast
9
9
  from dataclasses import dataclass
10
+ from dataclasses import replace
10
11
  import functools
11
12
  import hashlib
12
13
  import importlib.metadata
@@ -181,6 +182,9 @@ _FMT_PYTHON = 'python'
181
182
  _FMT_JUPYTER = 'jupyter'
182
183
  _FMT_MARIMO = 'marimo'
183
184
 
185
+ #: File name suffix of each download format, e.g. ``pyvista_read_marimo.py``.
186
+ _SUFFIXES = {_FMT_PYTHON: '.py', _FMT_JUPYTER: '.ipynb', _FMT_MARIMO: '_marimo.py'}
187
+
184
188
 
185
189
  @dataclass(frozen=True)
186
190
  class _RenderContext:
@@ -805,6 +809,14 @@ def _qualified_name_for(node: nodes.Node, docname: str, counter: int) -> str:
805
809
  return f'{base}-example-{counter}'
806
810
 
807
811
 
812
+ def _download_stem(node: nodes.Node, docname: str, counter: int) -> str:
813
+ """Name a span's downloads, e.g. ``Plotter.show`` for ``pyvista.Plotter.show``."""
814
+ name = _qualified_name_for(node, docname, counter)
815
+ if any(_desc_id(desc) is not None for desc in _enclosing_descs(node)):
816
+ return name.partition('.')[2] or name
817
+ return name
818
+
819
+
808
820
  def _header_title(qualified_name: str) -> str:
809
821
  """Build the file's own header title text, e.g. ``Examples from pyvista.read``."""
810
822
  return f'Examples from {qualified_name}'
@@ -931,13 +943,13 @@ def _build_notebook(cells: list[tuple[str, list[str]]]) -> dict:
931
943
  }
932
944
 
933
945
 
934
- def _download_filename(name: str, extension: str) -> str:
935
- """Return the file name a download is written under, e.g. ``pyvista_read.py``."""
946
+ def _download_filename(name: str, fmt: str) -> str:
947
+ """Return the file name a download is written under, e.g. ``Plotter_show.py``."""
936
948
  safe_name = name.replace('.', '_') if name else 'example'
937
- return f'{safe_name}.{extension}'
949
+ return f'{safe_name}{_SUFFIXES[fmt]}'
938
950
 
939
951
 
940
- def _write_download_file(app: Sphinx, name: str, extension: str, content: str) -> str:
952
+ def _write_download_file(app: Sphinx, name: str, fmt: str, content: str) -> str:
941
953
  """Write generated content directly into the builder's downloads dir.
942
954
 
943
955
  Returns the written file's path relative to the downloads directory --
@@ -948,7 +960,7 @@ def _write_download_file(app: Sphinx, name: str, extension: str, content: str) -
948
960
  """
949
961
  # 32 hex chars, matching Sphinx's own native _downloads/<digest>/... layout.
950
962
  digest = hashlib.sha256(content.encode()).hexdigest()[:32]
951
- filename = _download_filename(name, extension)
963
+ filename = _download_filename(name, fmt)
952
964
  rel_path = f'{digest}/{filename}'
953
965
 
954
966
  out_path = Path(app.outdir) / '_downloads' / digest / filename
@@ -959,12 +971,23 @@ def _write_download_file(app: Sphinx, name: str, extension: str, content: str) -
959
971
 
960
972
  def _write_python_script(app: Sphinx, name: str, source: str) -> str:
961
973
  """Write a generated Python script; see ``_write_download_file``."""
962
- return _write_download_file(app, name, 'py', source)
974
+ return _write_download_file(app, name, _FMT_PYTHON, source)
963
975
 
964
976
 
965
977
  def _write_jupyter_notebook(app: Sphinx, name: str, notebook: dict) -> str:
966
978
  """Write a generated Jupyter notebook; see ``_write_download_file``."""
967
- return _write_download_file(app, name, 'ipynb', json.dumps(notebook, indent=1))
979
+ return _write_download_file(app, name, _FMT_JUPYTER, json.dumps(notebook, indent=1))
980
+
981
+
982
+ def _title_first(cells: list) -> list:
983
+ """Move marimo's leading ``import marimo as mo`` cell below the title cell after it."""
984
+ if (
985
+ len(cells) > 1
986
+ and cells[0].code == 'import marimo as mo'
987
+ and cells[1].code.startswith('mo.md(')
988
+ ):
989
+ return [cells[1], cells[0], *cells[2:]]
990
+ return cells
968
991
 
969
992
 
970
993
  def _drop_future_imports(source: list[str]) -> list[str]:
@@ -994,8 +1017,9 @@ def _write_marimo_notebook(app: Sphinx, name: str, notebook: dict, header: str =
994
1017
  else cell
995
1018
  for cell in notebook['cells']
996
1019
  ]
997
- source = MarimoConvert.from_ipynb(json.dumps({**notebook, 'cells': cells})).to_py()
998
- return _write_download_file(app, name, 'marimo.py', header + source)
1020
+ ir = MarimoConvert.from_ipynb(json.dumps({**notebook, 'cells': cells})).to_ir()
1021
+ source = MarimoConvert.from_ir(replace(ir, cells=_title_first(ir.cells))).to_py()
1022
+ return _write_download_file(app, name, _FMT_MARIMO, header + source)
999
1023
 
1000
1024
 
1001
1025
  @functools.cache
@@ -1067,6 +1091,66 @@ def _keep_pin(requirement: str, pin: str | None) -> str:
1067
1091
  return str(parsed)
1068
1092
 
1069
1093
 
1094
+ _QUOTED = re.compile(r'"[^"]*"|\'[^\']*\'')
1095
+ _MARKER_WORDS = {'and', 'or', 'not', 'in'}
1096
+
1097
+
1098
+ def _depends_only_on_extras(requirement: Requirement, extras: frozenset[str]) -> bool:
1099
+ """Check whether ``requirement`` applies with ``extras`` on every platform and Python."""
1100
+ if requirement.marker is None:
1101
+ return True
1102
+ names = set(re.findall(r'[A-Za-z_.]+', _QUOTED.sub('', str(requirement.marker))))
1103
+ if names - _MARKER_WORDS != {'extra'}:
1104
+ return False
1105
+ return any(requirement.marker.evaluate({'extra': extra}) for extra in extras | {''})
1106
+
1107
+
1108
+ @functools.cache
1109
+ def _installed_requirements(name: str, extras: frozenset[str]) -> frozenset[Requirement]:
1110
+ """Return the unconditional requirements of an installed distribution with ``extras``."""
1111
+ try:
1112
+ requires = importlib.metadata.requires(name) or []
1113
+ except importlib.metadata.PackageNotFoundError:
1114
+ return frozenset()
1115
+ parsed = (Requirement(req) for req in requires)
1116
+ return frozenset(req for req in parsed if _depends_only_on_extras(req, extras))
1117
+
1118
+
1119
+ def _transitive_names(requirement: str) -> set[str]:
1120
+ """Return the normalized names every install of ``requirement`` pulls in, itself excluded."""
1121
+ root = Requirement(requirement)
1122
+ seen: set[tuple[str, frozenset[str]]] = set()
1123
+ todo = [(canonicalize_name(root.name), frozenset(root.extras))]
1124
+ names: set[str] = set()
1125
+ while todo:
1126
+ key = todo.pop()
1127
+ if key in seen:
1128
+ continue
1129
+ seen.add(key)
1130
+ for req in _installed_requirements(*key):
1131
+ names.add(canonicalize_name(req.name))
1132
+ todo.append((canonicalize_name(req.name), frozenset(req.extras)))
1133
+ names.discard(canonicalize_name(root.name))
1134
+ return names
1135
+
1136
+
1137
+ def _drop_covered(deps: list[str]) -> list[str]:
1138
+ """Drop each bare-name dependency that another listed dependency already installs."""
1139
+ pulled = {dep: _transitive_names(dep) for dep in deps}
1140
+ names = {dep: _canonical_name(dep) for dep in deps}
1141
+
1142
+ def covered(dep: str) -> bool:
1143
+ if dep != Requirement(dep).name:
1144
+ return False
1145
+ return any(
1146
+ names[dep] in pulled[other] and names[other] not in pulled[dep]
1147
+ for other in deps
1148
+ if other != dep
1149
+ )
1150
+
1151
+ return [dep for dep in deps if not covered(dep)]
1152
+
1153
+
1070
1154
  @dataclass(frozen=True)
1071
1155
  class _DocumentedProject:
1072
1156
  """The installed distribution named by Sphinx's ``project``."""
@@ -1294,28 +1378,28 @@ def _build_download_entries(
1294
1378
  cap = _python_upper_bound(deps[fmt], project.name if project else None)
1295
1379
  return _python_range(floor, cap)
1296
1380
 
1381
+ shown = {fmt: _drop_covered(fmt_deps) for fmt, fmt_deps in deps.items()}
1382
+
1297
1383
  def header_for(fmt: str, run_line: str) -> str:
1298
1384
  if not deps:
1299
1385
  return ''
1300
- return _script_metadata_header(deps[fmt], requires_python_for(fmt), run_line)
1386
+ return _script_metadata_header(shown[fmt], requires_python_for(fmt), run_line)
1301
1387
 
1302
1388
  entries = []
1303
1389
  for fmt in _FORMAT_ORDER:
1304
1390
  if fmt not in formats:
1305
1391
  continue
1306
1392
  label = link_labels[fmt]
1393
+ filename = _download_filename(name, fmt)
1307
1394
  if fmt == _FMT_PYTHON:
1308
- filename = _download_filename(name, 'py')
1309
1395
  header = header_for(fmt, f'Run this script with: uv run {filename}')
1310
1396
  rel_path = _write_python_script(app, name, header + source)
1311
1397
  elif fmt == _FMT_JUPYTER:
1312
- filename = _download_filename(name, 'ipynb')
1313
1398
  run_line = (
1314
- _jupyter_run_line(deps[fmt], requires_python_for(fmt), filename) if deps else None
1399
+ _jupyter_run_line(shown[fmt], requires_python_for(fmt), filename) if deps else None
1315
1400
  )
1316
1401
  rel_path = _write_jupyter_notebook(app, name, notebook_for(run_line))
1317
1402
  else:
1318
- filename = _download_filename(name, 'marimo.py')
1319
1403
  run_line = f'Open this notebook with: uvx marimo edit --sandbox {filename}'
1320
1404
  rel_path = _write_marimo_notebook(
1321
1405
  app, name, notebook_for(None), header_for(fmt, run_line)
@@ -1346,7 +1430,7 @@ def _process_span(
1346
1430
  entries = _build_download_entries(
1347
1431
  app,
1348
1432
  docname,
1349
- name,
1433
+ _download_stem(heading, docname, counter),
1350
1434
  _header_title(name),
1351
1435
  nodes_in_span,
1352
1436
  formats,
@@ -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.8.0'
22
- __version_tuple__ = version_tuple = (0, 8, 0)
21
+ __version__ = version = '0.8.2'
22
+ __version_tuple__ = version_tuple = (0, 8, 2)
23
23
 
24
- __commit_id__ = commit_id = 'g35e7c7f07'
24
+ __commit_id__ = commit_id = 'g69288337b'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sphinx-examples-as-code
3
- Version: 0.8.0
3
+ Version: 0.8.2
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
@@ -96,11 +96,13 @@ sphinx_examples_as_code_conf = {
96
96
  (default) or `'bottom'`.
97
97
  - `formats`: which downloads to generate. A list containing any of `'python'` (a plain
98
98
  Python script, `<name>.py`), `'jupyter'` (a Jupyter notebook, `<name>.ipynb`) and
99
- `'marimo'` (a marimo notebook, `<name>.marimo.py`); defaults to all three. Always
99
+ `'marimo'` (a marimo notebook, `<name>_marimo.py`); defaults to all three. Always
100
100
  offered in that order regardless of how the list is written. Without marimo 0.14 or
101
101
  newer installed, the default skips the marimo download, and a `formats` that names
102
102
  `'marimo'` (in `conf.py` or through `-D`) stops the build with a configuration error. An unknown format raises a configuration error too;
103
- the old `'py'` and `'ipynb'` names say what replaced them.
103
+ the old `'py'` and `'ipynb'` names say what replaced them. `<name>` is the documented
104
+ object's full name without its top-level package, e.g. `Plotter_show` for
105
+ `pyvista.Plotter.show`, or the gallery example's file name.
104
106
  - `link_labels`: the text of the download link(s) themselves, per format. Set only the
105
107
  format(s) you want to change; any left unset keep reading their own default shown above.
106
108
  - `gallery_downloads`: opt-in takeover of
@@ -149,8 +151,9 @@ imports, plus any `dependencies` set for its format, and says how to run it:
149
151
  needed, so the command works in bash, zsh, PowerShell and cmd. An argument containing
150
152
  `"`, `$`, a backtick, `\`, `%` or a `!` not followed by `=` is single-quoted instead,
151
153
  which cmd does not understand.
152
- - marimo notebooks start with the same `# /// script` block, which also lists `marimo`,
153
- followed by `# Open this notebook with: uvx marimo edit --sandbox <name>.marimo.py`.
154
+ - marimo notebooks start with the same `# /// script` block, which also lists `marimo`
155
+ unless another dependency installs it,
156
+ followed by `# Open this notebook with: uvx marimo edit --sandbox <name>_marimo.py`.
154
157
  - Each run command is followed by a line linking to
155
158
  [uv's installation guide](https://docs.astral.sh/uv/getting-started/installation/).
156
159
 
@@ -158,7 +161,9 @@ Imports are read from the code, not run. Each imported top-level module maps to
158
161
  project that provides it in the environment building the docs; standard-library
159
162
  modules, relative imports and modules no single installed project provides are left
160
163
  out. Projects the code needs but never imports, such as an optional backend, go in
161
- `dependencies`.
164
+ `dependencies`. A dependency with no version or extras of its own is left out when another listed
165
+ one already installs it on every platform, e.g. `numpy` and `marimo` next to
166
+ `pyvista[marimo]`.
162
167
 
163
168
  The project named by Sphinx's own `project` setting (e.g. `project = 'PyVista'`) gets
164
169
  extra treatment when an example imports it and it is installed: its dependency becomes
@@ -169,8 +174,8 @@ Unless `requires_python` is set, the block's `requires-python` combines:
169
174
 
170
175
  - that project's own `Requires-Python`, e.g. `>=3.10`, when it is listed;
171
176
  - `<3.N`, one minor version above the newest Python named in the `Programming Language ::
172
- Python :: 3.X` classifiers of that project and of every listed dependency installed
173
- from a platform-specific wheel, as installed in the environment building the docs.
177
+ Python :: 3.X` classifiers of that project and of every dependency, listed or left out,
178
+ installed from a platform-specific wheel, as installed in the environment building the docs.
174
179
  Pure-Python dependencies and dependencies without such classifiers don't count, and
175
180
  the bound is dropped when it would exclude every Python the project's own
176
181
  `Requires-Python` allows.
@@ -221,7 +226,7 @@ cells instead.
221
226
 
222
227
  Generated marimo notebooks are that same Jupyter notebook converted by marimo's own
223
228
  converter, the one behind `marimo convert`. `from __future__` imports are dropped first,
224
- so the notebook also runs as a plain script (`python <name>.marimo.py`).
229
+ so the notebook also runs as a plain script (`python <name>_marimo.py`).
225
230
 
226
231
  A download link is only added if the resulting code contains at least one real
227
232
  executable statement.
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "0.8.0",
2
+ "tag": "0.8.2",
3
3
  "distance": 0,
4
- "node": "g35e7c7f071baf732904a6fad8b7ea7a63f03d1f2",
4
+ "node": "g69288337b5a4907e168725fbd4dbc5e1ea9450f8",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-11-01"
@@ -275,7 +275,7 @@ def test_gallery_downloads_generated_python_executes(gallery_build: tuple[Path,
275
275
  def test_gallery_downloads_marimo_notebook_executes(gallery_build: tuple[Path, str]):
276
276
  """The marimo notebook holds the page's code and runs as a plain script."""
277
277
  html_dir, _html = gallery_build
278
- (marimo_path,) = (html_dir / '_downloads').rglob('*.marimo.py')
278
+ (marimo_path,) = (html_dir / '_downloads').rglob('*_marimo.py')
279
279
  src = marimo_path.read_text(encoding='utf-8')
280
280
  assert 'app = marimo.App(' in src
281
281
  assert 'A minimal gallery example' in src
@@ -29,12 +29,12 @@ def test_download_named_after_the_function_not_the_docname(tmp_path: Path):
29
29
  assert returncode == 0, f'sphinx build failed with stdout:\n{out}\nstderr:\n{err}\n'
30
30
 
31
31
  downloads_dir = html_dir / '_downloads'
32
- python_files = [p for p in downloads_dir.rglob('*.py') if not p.name.endswith('.marimo.py')]
32
+ python_files = [p for p in downloads_dir.rglob('*.py') if not p.name.endswith('_marimo.py')]
33
33
  assert len(python_files) == 1, f'expected exactly one generated .py, got {python_files}'
34
34
  python_path = python_files[0]
35
35
 
36
36
  # not "index-example-1" (the docname-derived fallback name)
37
- assert python_path.stem == 'mymodule_download_bunny'
37
+ assert python_path.stem == 'download_bunny'
38
38
 
39
39
  lines = python_path.read_text(encoding='utf-8').splitlines()
40
40
  assert lines[0] == '# Examples from mymodule.download_bunny'
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import copy
11
11
  import json
12
+ from types import SimpleNamespace
12
13
  from typing import TYPE_CHECKING
13
14
  from unittest.mock import Mock
14
15
 
@@ -1140,6 +1141,24 @@ def test_qualified_name_for_desc_ancestor():
1140
1141
  assert seac._qualified_name_for(heading, 'page', 1) == 'pkg.mod.func'
1141
1142
 
1142
1143
 
1144
+ @pytest.mark.parametrize(('desc_id', 'expected'), [('pkg.mod.func', 'mod.func'), ('func', 'func')])
1145
+ def test_download_stem_drops_top_level_package(desc_id: str, expected: str):
1146
+ desc = addnodes.desc()
1147
+ desc += addnodes.desc_signature(ids=[desc_id])
1148
+ content = addnodes.desc_content()
1149
+ heading = nodes.rubric()
1150
+ content += heading
1151
+ desc += content
1152
+ assert seac._download_stem(heading, 'page', 1) == expected
1153
+
1154
+
1155
+ def test_download_stem_keeps_dotted_fallback_name():
1156
+ section = nodes.section()
1157
+ heading = nodes.rubric()
1158
+ section += heading
1159
+ assert seac._download_stem(heading, 'release.0.44', 1) == 'release.0.44-example-1'
1160
+
1161
+
1143
1162
  def test_qualified_name_for_fallback_no_desc_ancestor():
1144
1163
  section = nodes.section()
1145
1164
  heading = nodes.rubric()
@@ -1613,6 +1632,18 @@ def test_build_notebook_valid_nbformat():
1613
1632
  nbformat.validate(node) # raises if invalid
1614
1633
 
1615
1634
 
1635
+ @pytest.mark.parametrize(
1636
+ ('name', 'fmt', 'expected'),
1637
+ [
1638
+ ('ImageDataFilters.resample', 'marimo', 'ImageDataFilters_resample_marimo.py'),
1639
+ ('plot_vtk9.4', 'python', 'plot_vtk9_4.py'),
1640
+ ('', 'jupyter', 'example.ipynb'),
1641
+ ],
1642
+ )
1643
+ def test_download_filename(name: str, fmt: str, expected: str):
1644
+ assert seac._download_filename(name, fmt) == expected
1645
+
1646
+
1616
1647
  # ---------------------------------------------------------------------------
1617
1648
  # _write_jupyter_notebook
1618
1649
  # ---------------------------------------------------------------------------
@@ -1856,8 +1887,8 @@ def test_process_span_footer_in_notebook_is_linkified(tmp_path: Path):
1856
1887
  (['jupyter'], ['.ipynb']),
1857
1888
  (['python', 'jupyter'], ['.py', '.ipynb']),
1858
1889
  (['jupyter', 'python'], ['.py', '.ipynb']), # canonical order regardless of config order
1859
- (['marimo'], ['.marimo.py']),
1860
- (['marimo', 'python', 'jupyter'], ['.py', '.ipynb', '.marimo.py']),
1890
+ (['marimo'], ['_marimo.py']),
1891
+ (['marimo', 'python', 'jupyter'], ['.py', '.ipynb', '_marimo.py']),
1861
1892
  ([], []),
1862
1893
  ],
1863
1894
  )
@@ -3052,6 +3083,21 @@ def test_script_metadata_marimo(tmp_path: Path):
3052
3083
  )
3053
3084
  assert source.startswith(header)
3054
3085
  assert 'Open this notebook' not in source.removeprefix(header)
3086
+ assert source.index('mo.md(') < source.index('import marimo as mo')
3087
+
3088
+
3089
+ @pytest.mark.parametrize(
3090
+ ('codes', 'expected'),
3091
+ [
3092
+ (['import marimo as mo', 'mo.md("# T")', 'x = 1'], [1, 0, 2]),
3093
+ (['import marimo as mo', 'x = 1'], [0, 1]),
3094
+ (['mo.md("# T")', 'import marimo as mo'], [0, 1]),
3095
+ (['import marimo as mo'], [0]),
3096
+ ],
3097
+ )
3098
+ def test_title_first(codes: list[str], expected: list[int]):
3099
+ cells = [SimpleNamespace(code=code) for code in codes]
3100
+ assert seac._title_first(cells) == [cells[i] for i in expected]
3055
3101
 
3056
3102
 
3057
3103
  def test_process_doctree_script_metadata_enabled(tmp_path: Path):
@@ -3203,3 +3249,48 @@ def test_script_metadata_documented_project_not_imported(
3203
3249
  source = _python_source_for(tmp_path, 'NumPy', '.. rubric:: Examples\n\n>>> import docutils')
3204
3250
  assert '# requires-python = "<3.15"\n' in source
3205
3251
  assert 'numpy' not in source
3252
+
3253
+
3254
+ @pytest.fixture
3255
+ def fake_requires(monkeypatch: pytest.MonkeyPatch):
3256
+ """Serve installed requirements from a dict."""
3257
+
3258
+ def install(requires: dict[str, list[str]]) -> None:
3259
+ def fake(name: str) -> list[str]:
3260
+ if name not in requires:
3261
+ raise seac.importlib.metadata.PackageNotFoundError(name)
3262
+ return requires[name]
3263
+
3264
+ monkeypatch.setattr(seac.importlib.metadata, 'requires', fake)
3265
+
3266
+ seac._installed_requirements.cache_clear()
3267
+ yield install
3268
+ seac._installed_requirements.cache_clear()
3269
+
3270
+
3271
+ def test_drop_covered_follows_extras_transitively(fake_requires):
3272
+ fake_requires(
3273
+ {
3274
+ 'pyvista': ['numpy>=1.21', 'trame-pyvista[marimo]>=0.3; extra == "marimo"'],
3275
+ 'trame-pyvista': ['marimo; extra == "marimo"'],
3276
+ }
3277
+ )
3278
+ deps = ['marimo', 'numpy', 'pyvista[marimo]', 'scipy']
3279
+ assert seac._drop_covered(deps) == ['pyvista[marimo]', 'scipy']
3280
+ assert seac._drop_covered(['marimo', 'numpy', 'pyvista']) == ['marimo', 'pyvista']
3281
+
3282
+
3283
+ def test_drop_covered_keeps_pinned_platform_specific_and_cycles(fake_requires):
3284
+ fake_requires(
3285
+ {
3286
+ 'pyvista': ['numpy', 'pooch; sys_platform == "linux"', 'cycle'],
3287
+ 'cycle': ['pyvista'],
3288
+ }
3289
+ )
3290
+ deps = ['cycle', 'numpy>=2', 'pooch', 'pyvista']
3291
+ assert seac._drop_covered(deps) == deps
3292
+
3293
+
3294
+ def test_drop_covered_without_metadata_keeps_everything(fake_requires):
3295
+ fake_requires({})
3296
+ assert seac._drop_covered(['numpy', 'pyvista']) == ['numpy', 'pyvista']
@@ -92,7 +92,7 @@ def built(tmp_path_factory) -> tuple[Path, list[Path]]:
92
92
  assert returncode == 0, f'sphinx build failed with stdout:\n{out}\nstderr:\n{err}\n'
93
93
 
94
94
  downloads_dir = html_dir / '_downloads'
95
- examples = sorted(p for p in downloads_dir.rglob('*.py') if not p.name.endswith('.marimo.py'))
95
+ examples = sorted(p for p in downloads_dir.rglob('*.py') if not p.name.endswith('_marimo.py'))
96
96
  return html_dir, examples
97
97
 
98
98
 
@@ -228,7 +228,7 @@ def test_table_becomes_an_rst_simple_table_in_python(built: tuple[Path, list[Pat
228
228
 
229
229
  def test_table_becomes_a_markdown_table_in_jupyter(built_jupyter_notebooks: list[Path]):
230
230
  """The same table is a Markdown pipe table in a notebook's markdown cell."""
231
- nb_path = next(p for p in built_jupyter_notebooks if p.stem == 'docstring_cases_case_table')
231
+ nb_path = next(p for p in built_jupyter_notebooks if p.stem == 'case_table')
232
232
  notebook = json.loads(nb_path.read_text(encoding='utf-8'))
233
233
  lines = [
234
234
  line.rstrip()
@@ -246,9 +246,7 @@ def test_table_becomes_a_markdown_table_in_jupyter(built_jupyter_notebooks: list
246
246
 
247
247
  def test_header_less_table_gets_a_blank_markdown_header(built_jupyter_notebooks: list[Path]):
248
248
  """A Markdown pipe table needs a header row, so one is left blank."""
249
- nb_path = next(
250
- p for p in built_jupyter_notebooks if p.stem == 'docstring_cases_case_table_without_header'
251
- )
249
+ nb_path = next(p for p in built_jupyter_notebooks if p.stem == 'case_table_without_header')
252
250
  notebook = json.loads(nb_path.read_text(encoding='utf-8'))
253
251
  lines = [
254
252
  line.rstrip()
@@ -301,7 +299,7 @@ def test_header_format(built: tuple[Path, list[Path]]):
301
299
 
302
300
  def test_header_format_jupyter_uses_atx(built_jupyter_notebooks: list[Path]):
303
301
  """The same header, in .ipynb, is a single ATX level-1 line -- no underline."""
304
- nb_path = next(p for p in built_jupyter_notebooks if p.stem == 'docstring_cases_case_note')
302
+ nb_path = next(p for p in built_jupyter_notebooks if p.stem == 'case_note')
305
303
  notebook = json.loads(nb_path.read_text(encoding='utf-8'))
306
304
  header_cell = notebook['cells'][0]
307
305
  lines = [line.rstrip() for line in header_cell['source']]
@@ -451,9 +449,7 @@ def test_jupyter_notebook_code_cells_match_python_code(
451
449
  line for line in python_src.splitlines() if line and not line.startswith('#')
452
450
  ]
453
451
 
454
- notebook_path = next(
455
- p for p in built_jupyter_notebooks if p.stem == 'docstring_cases_case_combined'
456
- )
452
+ notebook_path = next(p for p in built_jupyter_notebooks if p.stem == 'case_combined')
457
453
  notebook = json.loads(notebook_path.read_text(encoding='utf-8'))
458
454
  code_cells = [c for c in notebook['cells'] if c['cell_type'] == 'code']
459
455
  notebook_code_lines = [
@@ -494,7 +490,7 @@ def test_formats_config_selection(
494
490
  assert returncode == 0, f'sphinx build failed with stdout:\n{out}\nstderr:\n{err}\n'
495
491
 
496
492
  downloads_dir = html_dir / '_downloads'
497
- marimo_files = list(downloads_dir.rglob('*.marimo.py'))
493
+ marimo_files = list(downloads_dir.rglob('*_marimo.py'))
498
494
  python_files = [p for p in downloads_dir.rglob('*.py') if p not in marimo_files]
499
495
  jupyter_files = list(downloads_dir.rglob('*.ipynb'))
500
496
 
@@ -520,11 +516,7 @@ def test_base_url_unset_no_links_anywhere(
520
516
  python_src = _read(built[1], 'case_regular_xref_for_base_url')
521
517
  assert 'http' not in python_src
522
518
 
523
- nb_path = next(
524
- p
525
- for p in built_jupyter_notebooks
526
- if p.stem == 'docstring_cases_case_regular_xref_for_base_url'
527
- )
519
+ nb_path = next(p for p in built_jupyter_notebooks if p.stem == 'case_regular_xref_for_base_url')
528
520
  nb_text = nb_path.read_text(encoding='utf-8')
529
521
  assert 'http' not in nb_text
530
522
  assert '](' not in nb_text # no markdown links
@@ -553,7 +545,7 @@ def test_base_url_set_resolves_links(tmp_path: Path):
553
545
  assert returncode == 0, f'sphinx build failed with stdout:\n{out}\nstderr:\n{err}\n'
554
546
 
555
547
  downloads_dir = html_dir / '_downloads'
556
- python_files = [p for p in downloads_dir.rglob('*.py') if not p.name.endswith('.marimo.py')]
548
+ python_files = [p for p in downloads_dir.rglob('*.py') if not p.name.endswith('_marimo.py')]
557
549
  jupyter_files = list(downloads_dir.rglob('*.ipynb'))
558
550
 
559
551
  # a regular reference: link resolved in .ipynb, omitted in .py
@@ -562,7 +554,7 @@ def test_base_url_set_resolves_links(tmp_path: Path):
562
554
  assert '`docstring_cases.Sample`' in regular_python
563
555
 
564
556
  regular_nb = next(
565
- p for p in jupyter_files if p.stem == 'docstring_cases_case_regular_xref_for_base_url'
557
+ p for p in jupyter_files if p.stem == 'case_regular_xref_for_base_url'
566
558
  ).read_text(encoding='utf-8')
567
559
  assert '[`docstring_cases.Sample`](https://docs.example.com/' in regular_nb
568
560
 
@@ -601,7 +593,7 @@ def test_markdown_cells_use_hard_line_breaks(built_jupyter_notebooks: list[Path]
601
593
  with no blank line between -- not the header cell, which is a single
602
594
  ATX heading line with nothing else to connect to.
603
595
  """
604
- nb_path = next(p for p in built_jupyter_notebooks if p.stem == 'docstring_cases_case_note')
596
+ nb_path = next(p for p in built_jupyter_notebooks if p.stem == 'case_note')
605
597
  notebook = json.loads(nb_path.read_text(encoding='utf-8'))
606
598
  note_cell = next(
607
599
  c
@@ -632,9 +624,7 @@ def test_html_baseurl_missing_scheme_disables_links_without_failing_build(tmp_pa
632
624
  )
633
625
  assert returncode == 0, f'sphinx build failed with stdout:\n{out}\nstderr:\n{err}\n'
634
626
 
635
- nb_path = next(
636
- (html_dir / '_downloads').rglob('docstring_cases_case_regular_xref_for_base_url.ipynb')
637
- )
627
+ nb_path = next((html_dir / '_downloads').rglob('case_regular_xref_for_base_url.ipynb'))
638
628
  nb_text = nb_path.read_text(encoding='utf-8')
639
629
  assert '](' not in nb_text # no markdown links -- same as html_baseurl unset entirely
640
630
 
@@ -653,9 +643,7 @@ def test_base_url_missing_trailing_slash_still_resolves_subpath(tmp_path: Path):
653
643
  )
654
644
  assert returncode == 0, f'sphinx build failed with stdout:\n{out}\nstderr:\n{err}\n'
655
645
 
656
- nb_path = next(
657
- (html_dir / '_downloads').rglob('docstring_cases_case_regular_xref_for_base_url.ipynb')
658
- )
646
+ nb_path = next((html_dir / '_downloads').rglob('case_regular_xref_for_base_url.ipynb'))
659
647
  nb_text = nb_path.read_text(encoding='utf-8')
660
648
  assert 'https://docs.example.com/en/stable/docstring_cases.html' in nb_text
661
649