sphinx-examples-as-code 0.8.1__tar.gz → 0.8.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 (41) hide show
  1. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/.github/workflows/ci.yml +1 -1
  2. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/PKG-INFO +11 -7
  3. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/README.md +9 -4
  4. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/pyproject.toml +1 -2
  5. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/sphinx_examples_as_code/__init__.py +92 -5
  6. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/sphinx_examples_as_code/_version.py +3 -3
  7. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/sphinx_examples_as_code.egg-info/PKG-INFO +11 -7
  8. sphinx_examples_as_code-0.8.3/sphinx_examples_as_code.egg-info/scm_version.json +8 -0
  9. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/test_single_function_page.py +1 -1
  10. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/test_sphinx_examples_as_code.py +101 -0
  11. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/test_tinypages.py +9 -21
  12. sphinx_examples_as_code-0.8.1/sphinx_examples_as_code.egg-info/scm_version.json +0 -8
  13. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/.github/dependabot.yml +0 -0
  14. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/.github/release.yml +0 -0
  15. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/.gitignore +0 -0
  16. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/.pre-commit-config.yaml +0 -0
  17. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/LICENSE +0 -0
  18. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/setup.cfg +0 -0
  19. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/sphinx_examples_as_code.egg-info/SOURCES.txt +0 -0
  20. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/sphinx_examples_as_code.egg-info/dependency_links.txt +0 -0
  21. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/sphinx_examples_as_code.egg-info/requires.txt +0 -0
  22. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/sphinx_examples_as_code.egg-info/scm_file_list.json +0 -0
  23. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/sphinx_examples_as_code.egg-info/top_level.txt +0 -0
  24. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/gallery_fixture/conf.py +0 -0
  25. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/gallery_fixture/examples/GALLERY_HEADER.rst +0 -0
  26. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/gallery_fixture/examples/plot_minimal.py +0 -0
  27. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/gallery_fixture/index.rst +0 -0
  28. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/heading_level_fixture/conf.py +0 -0
  29. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/heading_level_fixture/examples/GALLERY_HEADER.rst +0 -0
  30. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/heading_level_fixture/examples/plot_reused_level.py +0 -0
  31. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/heading_level_fixture/examples/plot_three_levels.py +0 -0
  32. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/heading_level_fixture/index.rst +0 -0
  33. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/single_function_fixture/conf.py +0 -0
  34. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/single_function_fixture/index.rst +0 -0
  35. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/single_function_fixture/mymodule.py +0 -0
  36. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/test_gallery_downloads.py +0 -0
  37. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/test_heading_level_reuse.py +0 -0
  38. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/tinypages/conf.py +0 -0
  39. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/tinypages/docstring_cases.py +0 -0
  40. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/tinypages/docstring_cases.rst +0 -0
  41. {sphinx_examples_as_code-0.8.1 → sphinx_examples_as_code-0.8.3}/tests/tinypages/index.rst +0 -0
@@ -37,7 +37,7 @@ jobs:
37
37
  strategy:
38
38
  fail-fast: false
39
39
  matrix:
40
- python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
40
+ python-version: ['3.11', '3.12', '3.13', '3.14']
41
41
  steps:
42
42
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
43
43
  with:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sphinx-examples-as-code
3
- Version: 0.8.1
3
+ Version: 0.8.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
@@ -13,13 +13,12 @@ Classifier: Operating System :: MacOS
13
13
  Classifier: Operating System :: Microsoft :: Windows
14
14
  Classifier: Operating System :: POSIX :: Linux
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.10
17
16
  Classifier: Programming Language :: Python :: 3.11
18
17
  Classifier: Programming Language :: Python :: 3.12
19
18
  Classifier: Programming Language :: Python :: 3.13
20
19
  Classifier: Programming Language :: Python :: 3.14
21
20
  Classifier: Topic :: Scientific/Engineering :: Visualization
22
- Requires-Python: >=3.10
21
+ Requires-Python: >=3.11
23
22
  Description-Content-Type: text/markdown
24
23
  License-File: LICENSE
25
24
  Requires-Dist: packaging>=22
@@ -100,7 +99,9 @@ sphinx_examples_as_code_conf = {
100
99
  offered in that order regardless of how the list is written. Without marimo 0.14 or
101
100
  newer installed, the default skips the marimo download, and a `formats` that names
102
101
  `'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.
102
+ the old `'py'` and `'ipynb'` names say what replaced them. `<name>` is the documented
103
+ object's full name without its top-level package, e.g. `Plotter_show` for
104
+ `pyvista.Plotter.show`, or the gallery example's file name.
104
105
  - `link_labels`: the text of the download link(s) themselves, per format. Set only the
105
106
  format(s) you want to change; any left unset keep reading their own default shown above.
106
107
  - `gallery_downloads`: opt-in takeover of
@@ -158,7 +159,10 @@ Imports are read from the code, not run. Each imported top-level module maps to
158
159
  project that provides it in the environment building the docs; standard-library
159
160
  modules, relative imports and modules no single installed project provides are left
160
161
  out. Projects the code needs but never imports, such as an optional backend, go in
161
- `dependencies`.
162
+ `dependencies`. In Python scripts and Jupyter notebooks, a dependency with no version or extras of
163
+ its own is left out when another listed one already installs it on every platform, e.g. `numpy`
164
+ next to `pyvista`. marimo notebooks list every dependency, since marimo's sandbox adds back any
165
+ it does not find by name.
162
166
 
163
167
  The project named by Sphinx's own `project` setting (e.g. `project = 'PyVista'`) gets
164
168
  extra treatment when an example imports it and it is installed: its dependency becomes
@@ -169,8 +173,8 @@ Unless `requires_python` is set, the block's `requires-python` combines:
169
173
 
170
174
  - that project's own `Requires-Python`, e.g. `>=3.10`, when it is listed;
171
175
  - `<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.
176
+ Python :: 3.X` classifiers of that project and of every dependency, listed or left out,
177
+ installed from a platform-specific wheel, as installed in the environment building the docs.
174
178
  Pure-Python dependencies and dependencies without such classifiers don't count, and
175
179
  the bound is dropped when it would exclude every Python the project's own
176
180
  `Requires-Python` allows.
@@ -68,7 +68,9 @@ sphinx_examples_as_code_conf = {
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
@@ -126,7 +128,10 @@ Imports are read from the code, not run. Each imported top-level module maps to
126
128
  project that provides it in the environment building the docs; standard-library
127
129
  modules, relative imports and modules no single installed project provides are left
128
130
  out. Projects the code needs but never imports, such as an optional backend, go in
129
- `dependencies`.
131
+ `dependencies`. In Python scripts and Jupyter notebooks, a dependency with no version or extras of
132
+ its own is left out when another listed one already installs it on every platform, e.g. `numpy`
133
+ next to `pyvista`. marimo notebooks list every dependency, since marimo's sandbox adds back any
134
+ it does not find by name.
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.
@@ -25,7 +25,6 @@ classifiers = [
25
25
  'Operating System :: Microsoft :: Windows',
26
26
  'Operating System :: POSIX :: Linux',
27
27
  'Programming Language :: Python :: 3',
28
- 'Programming Language :: Python :: 3.10',
29
28
  'Programming Language :: Python :: 3.11',
30
29
  'Programming Language :: Python :: 3.12',
31
30
  'Programming Language :: Python :: 3.13',
@@ -39,7 +38,7 @@ keywords = ['download', 'examples', 'sphinx']
39
38
  license = 'MIT'
40
39
  name = 'sphinx-examples-as-code'
41
40
  readme = 'README.md'
42
- requires-python = '>=3.10'
41
+ requires-python = '>=3.11'
43
42
 
44
43
  [project.optional-dependencies]
45
44
  gallery = ['sphinx-gallery>=0.22,<0.23']
@@ -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
@@ -808,6 +809,14 @@ def _qualified_name_for(node: nodes.Node, docname: str, counter: int) -> str:
808
809
  return f'{base}-example-{counter}'
809
810
 
810
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
+
811
820
  def _header_title(qualified_name: str) -> str:
812
821
  """Build the file's own header title text, e.g. ``Examples from pyvista.read``."""
813
822
  return f'Examples from {qualified_name}'
@@ -935,7 +944,7 @@ def _build_notebook(cells: list[tuple[str, list[str]]]) -> dict:
935
944
 
936
945
 
937
946
  def _download_filename(name: str, fmt: str) -> str:
938
- """Return the file name a download is written under, e.g. ``pyvista_read.py``."""
947
+ """Return the file name a download is written under, e.g. ``Plotter_show.py``."""
939
948
  safe_name = name.replace('.', '_') if name else 'example'
940
949
  return f'{safe_name}{_SUFFIXES[fmt]}'
941
950
 
@@ -970,6 +979,17 @@ def _write_jupyter_notebook(app: Sphinx, name: str, notebook: dict) -> str:
970
979
  return _write_download_file(app, name, _FMT_JUPYTER, json.dumps(notebook, indent=1))
971
980
 
972
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
991
+
992
+
973
993
  def _drop_future_imports(source: list[str]) -> list[str]:
974
994
  """Remove the lines of every ``from __future__`` import that has its lines to itself."""
975
995
  try:
@@ -997,7 +1017,8 @@ def _write_marimo_notebook(app: Sphinx, name: str, notebook: dict, header: str =
997
1017
  else cell
998
1018
  for cell in notebook['cells']
999
1019
  ]
1000
- source = MarimoConvert.from_ipynb(json.dumps({**notebook, 'cells': cells})).to_py()
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()
1001
1022
  return _write_download_file(app, name, _FMT_MARIMO, header + source)
1002
1023
 
1003
1024
 
@@ -1070,6 +1091,66 @@ def _keep_pin(requirement: str, pin: str | None) -> str:
1070
1091
  return str(parsed)
1071
1092
 
1072
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
+
1073
1154
  @dataclass(frozen=True)
1074
1155
  class _DocumentedProject:
1075
1156
  """The installed distribution named by Sphinx's ``project``."""
@@ -1297,10 +1378,16 @@ def _build_download_entries(
1297
1378
  cap = _python_upper_bound(deps[fmt], project.name if project else None)
1298
1379
  return _python_range(floor, cap)
1299
1380
 
1381
+ # marimo's sandbox adds back any import or marimo itself that its header does not name.
1382
+ shown = {
1383
+ fmt: fmt_deps if fmt == _FMT_MARIMO else _drop_covered(fmt_deps)
1384
+ for fmt, fmt_deps in deps.items()
1385
+ }
1386
+
1300
1387
  def header_for(fmt: str, run_line: str) -> str:
1301
1388
  if not deps:
1302
1389
  return ''
1303
- return _script_metadata_header(deps[fmt], requires_python_for(fmt), run_line)
1390
+ return _script_metadata_header(shown[fmt], requires_python_for(fmt), run_line)
1304
1391
 
1305
1392
  entries = []
1306
1393
  for fmt in _FORMAT_ORDER:
@@ -1313,7 +1400,7 @@ def _build_download_entries(
1313
1400
  rel_path = _write_python_script(app, name, header + source)
1314
1401
  elif fmt == _FMT_JUPYTER:
1315
1402
  run_line = (
1316
- _jupyter_run_line(deps[fmt], requires_python_for(fmt), filename) if deps else None
1403
+ _jupyter_run_line(shown[fmt], requires_python_for(fmt), filename) if deps else None
1317
1404
  )
1318
1405
  rel_path = _write_jupyter_notebook(app, name, notebook_for(run_line))
1319
1406
  else:
@@ -1347,7 +1434,7 @@ def _process_span(
1347
1434
  entries = _build_download_entries(
1348
1435
  app,
1349
1436
  docname,
1350
- name,
1437
+ _download_stem(heading, docname, counter),
1351
1438
  _header_title(name),
1352
1439
  nodes_in_span,
1353
1440
  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.1'
22
- __version_tuple__ = version_tuple = (0, 8, 1)
21
+ __version__ = version = '0.8.3'
22
+ __version_tuple__ = version_tuple = (0, 8, 3)
23
23
 
24
- __commit_id__ = commit_id = 'g6e63b87fa'
24
+ __commit_id__ = commit_id = 'g1289062e0'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sphinx-examples-as-code
3
- Version: 0.8.1
3
+ Version: 0.8.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
@@ -13,13 +13,12 @@ Classifier: Operating System :: MacOS
13
13
  Classifier: Operating System :: Microsoft :: Windows
14
14
  Classifier: Operating System :: POSIX :: Linux
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.10
17
16
  Classifier: Programming Language :: Python :: 3.11
18
17
  Classifier: Programming Language :: Python :: 3.12
19
18
  Classifier: Programming Language :: Python :: 3.13
20
19
  Classifier: Programming Language :: Python :: 3.14
21
20
  Classifier: Topic :: Scientific/Engineering :: Visualization
22
- Requires-Python: >=3.10
21
+ Requires-Python: >=3.11
23
22
  Description-Content-Type: text/markdown
24
23
  License-File: LICENSE
25
24
  Requires-Dist: packaging>=22
@@ -100,7 +99,9 @@ sphinx_examples_as_code_conf = {
100
99
  offered in that order regardless of how the list is written. Without marimo 0.14 or
101
100
  newer installed, the default skips the marimo download, and a `formats` that names
102
101
  `'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.
102
+ the old `'py'` and `'ipynb'` names say what replaced them. `<name>` is the documented
103
+ object's full name without its top-level package, e.g. `Plotter_show` for
104
+ `pyvista.Plotter.show`, or the gallery example's file name.
104
105
  - `link_labels`: the text of the download link(s) themselves, per format. Set only the
105
106
  format(s) you want to change; any left unset keep reading their own default shown above.
106
107
  - `gallery_downloads`: opt-in takeover of
@@ -158,7 +159,10 @@ Imports are read from the code, not run. Each imported top-level module maps to
158
159
  project that provides it in the environment building the docs; standard-library
159
160
  modules, relative imports and modules no single installed project provides are left
160
161
  out. Projects the code needs but never imports, such as an optional backend, go in
161
- `dependencies`.
162
+ `dependencies`. In Python scripts and Jupyter notebooks, a dependency with no version or extras of
163
+ its own is left out when another listed one already installs it on every platform, e.g. `numpy`
164
+ next to `pyvista`. marimo notebooks list every dependency, since marimo's sandbox adds back any
165
+ it does not find by name.
162
166
 
163
167
  The project named by Sphinx's own `project` setting (e.g. `project = 'PyVista'`) gets
164
168
  extra treatment when an example imports it and it is installed: its dependency becomes
@@ -169,8 +173,8 @@ Unless `requires_python` is set, the block's `requires-python` combines:
169
173
 
170
174
  - that project's own `Requires-Python`, e.g. `>=3.10`, when it is listed;
171
175
  - `<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.
176
+ Python :: 3.X` classifiers of that project and of every dependency, listed or left out,
177
+ installed from a platform-specific wheel, as installed in the environment building the docs.
174
178
  Pure-Python dependencies and dependencies without such classifiers don't count, and
175
179
  the bound is dropped when it would exclude every Python the project's own
176
180
  `Requires-Python` allows.
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.8.3",
3
+ "distance": 0,
4
+ "node": "g1289062e019a00bf142aa7e7389f8839220c7b39",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-11-02"
8
+ }
@@ -34,7 +34,7 @@ def test_download_named_after_the_function_not_the_docname(tmp_path: Path):
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
  # ---------------------------------------------------------------------------
@@ -3052,6 +3083,31 @@ 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(('fmt', 'listed'), [('jupyter', False), ('marimo', True)])
3090
+ def test_script_metadata_drops_covered_except_marimo(
3091
+ tmp_path: Path, fake_requires, fmt: str, listed: bool
3092
+ ):
3093
+ fake_requires({'ipywidgets': ['numpy'], 'marimo': ['numpy']})
3094
+ _name, source = _write_with_metadata(tmp_path, [fmt])
3095
+ header = source.split('///', 2)[1] if fmt == 'marimo' else json.loads(source)['cells'][0]
3096
+ assert ('numpy' in str(header)) is listed
3097
+
3098
+
3099
+ @pytest.mark.parametrize(
3100
+ ('codes', 'expected'),
3101
+ [
3102
+ (['import marimo as mo', 'mo.md("# T")', 'x = 1'], [1, 0, 2]),
3103
+ (['import marimo as mo', 'x = 1'], [0, 1]),
3104
+ (['mo.md("# T")', 'import marimo as mo'], [0, 1]),
3105
+ (['import marimo as mo'], [0]),
3106
+ ],
3107
+ )
3108
+ def test_title_first(codes: list[str], expected: list[int]):
3109
+ cells = [SimpleNamespace(code=code) for code in codes]
3110
+ assert seac._title_first(cells) == [cells[i] for i in expected]
3055
3111
 
3056
3112
 
3057
3113
  def test_process_doctree_script_metadata_enabled(tmp_path: Path):
@@ -3203,3 +3259,48 @@ def test_script_metadata_documented_project_not_imported(
3203
3259
  source = _python_source_for(tmp_path, 'NumPy', '.. rubric:: Examples\n\n>>> import docutils')
3204
3260
  assert '# requires-python = "<3.15"\n' in source
3205
3261
  assert 'numpy' not in source
3262
+
3263
+
3264
+ @pytest.fixture
3265
+ def fake_requires(monkeypatch: pytest.MonkeyPatch):
3266
+ """Serve installed requirements from a dict."""
3267
+
3268
+ def install(requires: dict[str, list[str]]) -> None:
3269
+ def fake(name: str) -> list[str]:
3270
+ if name not in requires:
3271
+ raise seac.importlib.metadata.PackageNotFoundError(name)
3272
+ return requires[name]
3273
+
3274
+ monkeypatch.setattr(seac.importlib.metadata, 'requires', fake)
3275
+
3276
+ seac._installed_requirements.cache_clear()
3277
+ yield install
3278
+ seac._installed_requirements.cache_clear()
3279
+
3280
+
3281
+ def test_drop_covered_follows_extras_transitively(fake_requires):
3282
+ fake_requires(
3283
+ {
3284
+ 'pyvista': ['numpy>=1.21', 'trame-pyvista[marimo]>=0.3; extra == "marimo"'],
3285
+ 'trame-pyvista': ['marimo; extra == "marimo"'],
3286
+ }
3287
+ )
3288
+ deps = ['marimo', 'numpy', 'pyvista[marimo]', 'scipy']
3289
+ assert seac._drop_covered(deps) == ['pyvista[marimo]', 'scipy']
3290
+ assert seac._drop_covered(['marimo', 'numpy', 'pyvista']) == ['marimo', 'pyvista']
3291
+
3292
+
3293
+ def test_drop_covered_keeps_pinned_platform_specific_and_cycles(fake_requires):
3294
+ fake_requires(
3295
+ {
3296
+ 'pyvista': ['numpy', 'pooch; sys_platform == "linux"', 'cycle'],
3297
+ 'cycle': ['pyvista'],
3298
+ }
3299
+ )
3300
+ deps = ['cycle', 'numpy>=2', 'pooch', 'pyvista']
3301
+ assert seac._drop_covered(deps) == deps
3302
+
3303
+
3304
+ def test_drop_covered_without_metadata_keeps_everything(fake_requires):
3305
+ fake_requires({})
3306
+ assert seac._drop_covered(['numpy', 'pyvista']) == ['numpy', 'pyvista']
@@ -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 = [
@@ -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
@@ -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
 
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.8.1",
3
- "distance": 0,
4
- "node": "g6e63b87faff4aa6f3530f1bbe7a85fad508570f9",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-11-01"
8
- }