sphinx-examples-as-code 0.8.2__tar.gz → 0.9.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 (41) hide show
  1. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/.github/workflows/ci.yml +1 -1
  2. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/PKG-INFO +15 -8
  3. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/README.md +13 -5
  4. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/pyproject.toml +1 -2
  5. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code/__init__.py +73 -6
  6. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code/_version.py +3 -3
  7. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code.egg-info/PKG-INFO +15 -8
  8. sphinx_examples_as_code-0.9.0/sphinx_examples_as_code.egg-info/scm_version.json +8 -0
  9. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/test_gallery_downloads.py +49 -0
  10. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/test_sphinx_examples_as_code.py +69 -0
  11. sphinx_examples_as_code-0.8.2/sphinx_examples_as_code.egg-info/scm_version.json +0 -8
  12. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/.github/dependabot.yml +0 -0
  13. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/.github/release.yml +0 -0
  14. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/.gitignore +0 -0
  15. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/.pre-commit-config.yaml +0 -0
  16. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/LICENSE +0 -0
  17. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/setup.cfg +0 -0
  18. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code.egg-info/SOURCES.txt +0 -0
  19. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code.egg-info/dependency_links.txt +0 -0
  20. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code.egg-info/requires.txt +0 -0
  21. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code.egg-info/scm_file_list.json +0 -0
  22. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code.egg-info/top_level.txt +0 -0
  23. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/gallery_fixture/conf.py +0 -0
  24. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/gallery_fixture/examples/GALLERY_HEADER.rst +0 -0
  25. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/gallery_fixture/examples/plot_minimal.py +0 -0
  26. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/gallery_fixture/index.rst +0 -0
  27. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/heading_level_fixture/conf.py +0 -0
  28. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/heading_level_fixture/examples/GALLERY_HEADER.rst +0 -0
  29. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/heading_level_fixture/examples/plot_reused_level.py +0 -0
  30. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/heading_level_fixture/examples/plot_three_levels.py +0 -0
  31. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/heading_level_fixture/index.rst +0 -0
  32. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/single_function_fixture/conf.py +0 -0
  33. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/single_function_fixture/index.rst +0 -0
  34. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/single_function_fixture/mymodule.py +0 -0
  35. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/test_heading_level_reuse.py +0 -0
  36. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/test_single_function_page.py +0 -0
  37. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/test_tinypages.py +0 -0
  38. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/tinypages/conf.py +0 -0
  39. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/tinypages/docstring_cases.py +0 -0
  40. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/tests/tinypages/docstring_cases.rst +0 -0
  41. {sphinx_examples_as_code-0.8.2 → sphinx_examples_as_code-0.9.0}/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.2
3
+ Version: 0.9.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
@@ -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
@@ -151,8 +150,7 @@ imports, plus any `dependencies` set for its format, and says how to run it:
151
150
  needed, so the command works in bash, zsh, PowerShell and cmd. An argument containing
152
151
  `"`, `$`, a backtick, `\`, `%` or a `!` not followed by `=` is single-quoted instead,
153
152
  which cmd does not understand.
154
- - marimo notebooks start with the same `# /// script` block, which also lists `marimo`
155
- unless another dependency installs it,
153
+ - marimo notebooks start with the same `# /// script` block, which also lists `marimo`,
156
154
  followed by `# Open this notebook with: uvx marimo edit --sandbox <name>_marimo.py`.
157
155
  - Each run command is followed by a line linking to
158
156
  [uv's installation guide](https://docs.astral.sh/uv/getting-started/installation/).
@@ -161,9 +159,18 @@ Imports are read from the code, not run. Each imported top-level module maps to
161
159
  project that provides it in the environment building the docs; standard-library
162
160
  modules, relative imports and modules no single installed project provides are left
163
161
  out. Projects the code needs but never imports, such as an optional backend, go in
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
+ `dependencies`, or for a single page in an `examples-as-code-dependencies` directive anywhere on it.
163
+ It takes PEP 508 requirements separated by spaces or commas, adds them to every download from
164
+ that page, and renders nothing:
165
+
166
+ ```rst
167
+ .. examples-as-code-dependencies:: sympy imageio-ffmpeg>=0.4
168
+ ```
169
+
170
+ In a Sphinx-Gallery example, put it in the example's docstring. In Python scripts and Jupyter notebooks, a dependency with no version or extras of
171
+ its own is left out when another listed one already installs it on every platform, e.g. `numpy`
172
+ next to `pyvista`. marimo notebooks list every dependency, since marimo's sandbox adds back any
173
+ it does not find by name.
167
174
 
168
175
  The project named by Sphinx's own `project` setting (e.g. `project = 'PyVista'`) gets
169
176
  extra treatment when an example imports it and it is installed: its dependency becomes
@@ -119,8 +119,7 @@ imports, plus any `dependencies` set for its format, and says how to run it:
119
119
  needed, so the command works in bash, zsh, PowerShell and cmd. An argument containing
120
120
  `"`, `$`, a backtick, `\`, `%` or a `!` not followed by `=` is single-quoted instead,
121
121
  which cmd does not understand.
122
- - marimo notebooks start with the same `# /// script` block, which also lists `marimo`
123
- unless another dependency installs it,
122
+ - marimo notebooks start with the same `# /// script` block, which also lists `marimo`,
124
123
  followed by `# Open this notebook with: uvx marimo edit --sandbox <name>_marimo.py`.
125
124
  - Each run command is followed by a line linking to
126
125
  [uv's installation guide](https://docs.astral.sh/uv/getting-started/installation/).
@@ -129,9 +128,18 @@ Imports are read from the code, not run. Each imported top-level module maps to
129
128
  project that provides it in the environment building the docs; standard-library
130
129
  modules, relative imports and modules no single installed project provides are left
131
130
  out. Projects the code needs but never imports, such as an optional backend, go in
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]`.
131
+ `dependencies`, or for a single page in an `examples-as-code-dependencies` directive anywhere on it.
132
+ It takes PEP 508 requirements separated by spaces or commas, adds them to every download from
133
+ that page, and renders nothing:
134
+
135
+ ```rst
136
+ .. examples-as-code-dependencies:: sympy imageio-ffmpeg>=0.4
137
+ ```
138
+
139
+ In a Sphinx-Gallery example, put it in the example's docstring. In Python scripts and Jupyter notebooks, a dependency with no version or extras of
140
+ its own is left out when another listed one already installs it on every platform, e.g. `numpy`
141
+ next to `pyvista`. marimo notebooks list every dependency, since marimo's sandbox adds back any
142
+ it does not find by name.
135
143
 
136
144
  The project named by Sphinx's own `project` setting (e.g. `project = 'PyVista'`) gets
137
145
  extra treatment when an example imports it and it is installed: its dependency becomes
@@ -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']
@@ -23,6 +23,7 @@ from urllib.parse import urlsplit
23
23
 
24
24
  from docutils import nodes
25
25
  from docutils.core import publish_doctree
26
+ from packaging.requirements import InvalidRequirement
26
27
  from packaging.requirements import Requirement
27
28
  from packaging.specifiers import InvalidSpecifier
28
29
  from packaging.specifiers import SpecifierSet
@@ -32,6 +33,7 @@ from packaging.version import Version
32
33
  from sphinx import addnodes
33
34
  from sphinx.errors import ConfigError
34
35
  from sphinx.util import logging
36
+ from sphinx.util.docutils import SphinxDirective
35
37
 
36
38
  from ._version import __version__
37
39
 
@@ -42,6 +44,7 @@ if TYPE_CHECKING:
42
44
 
43
45
  from sphinx.application import Sphinx
44
46
  from sphinx.config import Config
47
+ from sphinx.environment import BuildEnvironment
45
48
 
46
49
  # Node types marking the start of another section - bound the end of an
47
50
  # Examples span. Includes desc/index -- numpydoc renders a class's members
@@ -336,14 +339,19 @@ def _join_segments(segments: list[Segment]) -> list[str]:
336
339
 
337
340
 
338
341
  def _convert_doctest_block(node: nodes.doctest_block) -> list[Segment]:
339
- """Convert a doctest block, stripping ``>>> ``/``... `` prompts.
342
+ """Convert a doctest block; see ``_convert_doctest_source``."""
343
+ return _convert_doctest_source(node.astext())
344
+
345
+
346
+ def _convert_doctest_source(source: str) -> list[Segment]:
347
+ """Convert doctest text, stripping ``>>> ``/``... `` prompts.
340
348
 
341
349
  Non-prompted, non-blank lines are doctest *output* -- dropped entirely,
342
350
  not kept as comments.
343
351
  """
344
352
  lines: list[str] = []
345
353
  has_code = False
346
- for line in node.astext().splitlines():
354
+ for line in source.splitlines():
347
355
  if line.startswith('>>> ') or line == '>>>':
348
356
  lines.append(_clean_code_comment(line[4:]))
349
357
  has_code = True
@@ -382,6 +390,8 @@ def _convert_literal_block(node: nodes.Element) -> list[Segment]:
382
390
  language, source = node.get('language', ''), node.astext()
383
391
  # Case-insensitive: sphinx-gallery emits ``.. code-block:: Python`` (capitalized).
384
392
  if language.lower() in _PYTHON_LANGUAGES:
393
+ if source.lstrip().startswith('>>>'):
394
+ return _convert_doctest_source(source)
385
395
  lines = [_clean_code_comment(line) for line in source.splitlines()]
386
396
  while lines and not lines[-1].strip():
387
397
  lines.pop()
@@ -1006,20 +1016,29 @@ def _drop_future_imports(source: list[str]) -> list[str]:
1006
1016
  return [line for i, line in enumerate(source, start=1) if i not in dropped]
1007
1017
 
1008
1018
 
1019
+ # Stands in for a line's leading % so marimo does not read it as an IPython magic.
1020
+ _PERCENT_MARK = '\ue000'
1021
+
1022
+
1023
+ def _mark_percent_lines(source: list[str]) -> list[str]:
1024
+ """Replace the ``%`` that starts a line with ``_PERCENT_MARK``."""
1025
+ return [_PERCENT_MARK + line[1:] if line.startswith('%') else line for line in source]
1026
+
1027
+
1009
1028
  def _write_marimo_notebook(app: Sphinx, name: str, notebook: dict, header: str = '') -> str:
1010
1029
  """Convert a generated Jupyter notebook with marimo's own converter and write it."""
1011
1030
  from marimo._convert.converters import MarimoConvert
1012
1031
 
1013
1032
  # A __future__ import inside a marimo cell is a SyntaxError when run as a script.
1014
1033
  cells = [
1015
- {**cell, 'source': _drop_future_imports(cell['source'])}
1034
+ {**cell, 'source': _mark_percent_lines(_drop_future_imports(cell['source']))}
1016
1035
  if cell['cell_type'] == 'code'
1017
1036
  else cell
1018
1037
  for cell in notebook['cells']
1019
1038
  ]
1020
1039
  ir = MarimoConvert.from_ipynb(json.dumps({**notebook, 'cells': cells})).to_ir()
1021
1040
  source = MarimoConvert.from_ir(replace(ir, cells=_title_first(ir.cells))).to_py()
1022
- return _write_download_file(app, name, _FMT_MARIMO, header + source)
1041
+ return _write_download_file(app, name, _FMT_MARIMO, header + source.replace(_PERCENT_MARK, '%'))
1023
1042
 
1024
1043
 
1025
1044
  @functools.cache
@@ -1351,10 +1370,13 @@ def _build_download_entries(
1351
1370
  if script_metadata is not None:
1352
1371
  implied = {_FMT_MARIMO: ['marimo']}
1353
1372
  extra = script_metadata['dependencies']
1373
+ page = script_metadata.get('page_dependencies', [])
1354
1374
  project = _documented_project(script_metadata['project'])
1355
1375
  pins = {project.name: project.pin} if project and project.pin else {}
1356
1376
  deps = {
1357
- fmt: _script_dependencies(source, [*implied.get(fmt, []), *extra.get(fmt, [])], pins)
1377
+ fmt: _script_dependencies(
1378
+ source, [*implied.get(fmt, []), *extra.get(fmt, []), *page], pins
1379
+ )
1358
1380
  for fmt in formats
1359
1381
  }
1360
1382
 
@@ -1378,7 +1400,11 @@ def _build_download_entries(
1378
1400
  cap = _python_upper_bound(deps[fmt], project.name if project else None)
1379
1401
  return _python_range(floor, cap)
1380
1402
 
1381
- shown = {fmt: _drop_covered(fmt_deps) for fmt, fmt_deps in deps.items()}
1403
+ # marimo's sandbox adds back any import or marimo itself that its header does not name.
1404
+ shown = {
1405
+ fmt: fmt_deps if fmt == _FMT_MARIMO else _drop_covered(fmt_deps)
1406
+ for fmt, fmt_deps in deps.items()
1407
+ }
1382
1408
 
1383
1409
  def header_for(fmt: str, run_line: str) -> str:
1384
1410
  if not deps:
@@ -1591,6 +1617,7 @@ def _process_doctree(app: Sphinx, doctree: nodes.document, docname: str) -> None
1591
1617
  'dependencies': conf['dependencies'],
1592
1618
  'requires_python': conf['requires_python'],
1593
1619
  'project': app.config.project,
1620
+ 'page_dependencies': _page_dependencies(app.env).get(docname, []),
1594
1621
  }
1595
1622
  if conf['script_metadata']
1596
1623
  else None
@@ -1833,8 +1860,48 @@ def _disable_gallery_notebooks(_app: Sphinx, config: Config) -> None:
1833
1860
  config.sphinx_gallery_conf = {**gallery_conf, **overrides}
1834
1861
 
1835
1862
 
1863
+ def _page_dependencies(env: BuildEnvironment) -> dict[str, list[str]]:
1864
+ """Return the requirements each page added with ``examples-as-code-dependencies``."""
1865
+ return vars(env).setdefault('_examples_as_code_dependencies', {})
1866
+
1867
+
1868
+ class _DependenciesDirective(SphinxDirective):
1869
+ """Add requirements to every download from this page; renders nothing."""
1870
+
1871
+ required_arguments = 1
1872
+ final_argument_whitespace = True
1873
+
1874
+ def run(self) -> list[nodes.Node]:
1875
+ """Record the requirements for the current page."""
1876
+ requirements = self.arguments[0].replace(',', ' ').split()
1877
+ for requirement in requirements:
1878
+ try:
1879
+ Requirement(requirement)
1880
+ except InvalidRequirement as exc:
1881
+ msg = f'Invalid requirement {requirement!r}: {exc}'
1882
+ raise self.error(msg) from None
1883
+ _page_dependencies(self.env).setdefault(self.env.docname, []).extend(requirements)
1884
+ return []
1885
+
1886
+
1887
+ def _purge_page_dependencies(_app: Sphinx, env: BuildEnvironment, docname: str) -> None:
1888
+ """Forget a page's requirements before it is read again."""
1889
+ _page_dependencies(env).pop(docname, None)
1890
+
1891
+
1892
+ def _merge_page_dependencies(
1893
+ _app: Sphinx, env: BuildEnvironment, docnames: set[str], other: BuildEnvironment
1894
+ ) -> None:
1895
+ """Copy the requirements a parallel reader recorded into the main environment."""
1896
+ theirs = _page_dependencies(other)
1897
+ _page_dependencies(env).update((name, theirs[name]) for name in docnames if name in theirs)
1898
+
1899
+
1836
1900
  def setup(app: Sphinx) -> dict: # numpydoc ignore=RT01
1837
1901
  """Register the extension."""
1902
+ app.add_directive('examples-as-code-dependencies', _DependenciesDirective)
1903
+ app.connect('env-purge-doc', _purge_page_dependencies)
1904
+ app.connect('env-merge-info', _merge_page_dependencies)
1838
1905
  app.connect('doctree-resolved', _process_doctree)
1839
1906
  app.connect('config-inited', _finalize_conf)
1840
1907
  # sphinx-gallery fills its own defaults at priority 10.
@@ -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.2'
22
- __version_tuple__ = version_tuple = (0, 8, 2)
21
+ __version__ = version = '0.9.0'
22
+ __version_tuple__ = version_tuple = (0, 9, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g69288337b'
24
+ __commit_id__ = commit_id = 'g71df6bcfb'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sphinx-examples-as-code
3
- Version: 0.8.2
3
+ Version: 0.9.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
@@ -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
@@ -151,8 +150,7 @@ imports, plus any `dependencies` set for its format, and says how to run it:
151
150
  needed, so the command works in bash, zsh, PowerShell and cmd. An argument containing
152
151
  `"`, `$`, a backtick, `\`, `%` or a `!` not followed by `=` is single-quoted instead,
153
152
  which cmd does not understand.
154
- - marimo notebooks start with the same `# /// script` block, which also lists `marimo`
155
- unless another dependency installs it,
153
+ - marimo notebooks start with the same `# /// script` block, which also lists `marimo`,
156
154
  followed by `# Open this notebook with: uvx marimo edit --sandbox <name>_marimo.py`.
157
155
  - Each run command is followed by a line linking to
158
156
  [uv's installation guide](https://docs.astral.sh/uv/getting-started/installation/).
@@ -161,9 +159,18 @@ Imports are read from the code, not run. Each imported top-level module maps to
161
159
  project that provides it in the environment building the docs; standard-library
162
160
  modules, relative imports and modules no single installed project provides are left
163
161
  out. Projects the code needs but never imports, such as an optional backend, go in
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
+ `dependencies`, or for a single page in an `examples-as-code-dependencies` directive anywhere on it.
163
+ It takes PEP 508 requirements separated by spaces or commas, adds them to every download from
164
+ that page, and renders nothing:
165
+
166
+ ```rst
167
+ .. examples-as-code-dependencies:: sympy imageio-ffmpeg>=0.4
168
+ ```
169
+
170
+ In a Sphinx-Gallery example, put it in the example's docstring. In Python scripts and Jupyter notebooks, a dependency with no version or extras of
171
+ its own is left out when another listed one already installs it on every platform, e.g. `numpy`
172
+ next to `pyvista`. marimo notebooks list every dependency, since marimo's sandbox adds back any
173
+ it does not find by name.
167
174
 
168
175
  The project named by Sphinx's own `project` setting (e.g. `project = 'PyVista'`) gets
169
176
  extra treatment when an example imports it and it is installed: its dependency becomes
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.9.0",
3
+ "distance": 0,
4
+ "node": "g71df6bcfbe9de4cdd1532fe1c93bdb15c9f402fb",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-11-02"
8
+ }
@@ -334,3 +334,52 @@ def test_gallery_downloads_enabled_warns_about_notebook_settings(tmp_path: Path)
334
334
  assert "sphinx_gallery_conf key(s) 'pypandoc', 'first_notebook_cell' have no effect" in err
335
335
  assert "'pypandoc' not available" not in err
336
336
  assert not list((src_dir / 'auto_examples').rglob('*.ipynb'))
337
+
338
+
339
+ def _build_with_dependencies(tmp_path: Path, requirements: str) -> tuple[int, str, str, Path]:
340
+ """Build the fixture with an ``examples-as-code-dependencies`` directive in its example."""
341
+ src_dir = tmp_path / 'src'
342
+ shutil.copytree(FIXTURE_DIR, src_dir)
343
+ example = src_dir / 'examples' / 'plot_minimal.py'
344
+ source = example.read_text(encoding='utf-8')
345
+ marker = '==========================\n'
346
+ directive = f'{marker}\n.. examples-as-code-dependencies:: {requirements}\n'
347
+ example.write_text(source.replace(marker, directive, 1), encoding='utf-8')
348
+ html_dir = tmp_path / 'html'
349
+ sphinx_args = (
350
+ '-D',
351
+ 'sphinx_examples_as_code_conf.gallery_downloads=1',
352
+ '-D',
353
+ 'sphinx_examples_as_code_conf.script_metadata=1',
354
+ '-D',
355
+ 'sphinx_examples_as_code_conf.formats=python,jupyter,marimo',
356
+ )
357
+ returncode, out, err = _run_sphinx_build(
358
+ _sphinx_build_cmd(src_dir, html_dir, tmp_path / 'doctrees', sphinx_args)
359
+ )
360
+ return returncode, out, err, html_dir
361
+
362
+
363
+ def test_dependencies_directive_adds_to_every_download(tmp_path: Path):
364
+ """``examples-as-code-dependencies`` lists its requirements in each download's header."""
365
+ returncode, out, err, html_dir = _build_with_dependencies(tmp_path, 'sympy tqdm>=4')
366
+ assert returncode == 0, f'sphinx build failed with stdout:\n{out}\nstderr:\n{err}\n'
367
+ downloads = [
368
+ path
369
+ for path in (html_dir / '_downloads').rglob('plot_minimal*')
370
+ if path.suffix == '.ipynb'
371
+ or (path.suffix == '.py' and path.read_text(encoding='utf-8').startswith('# /// script'))
372
+ ]
373
+ assert sorted(path.suffix for path in downloads) == ['.ipynb', '.py', '.py']
374
+ for path in downloads:
375
+ text = path.read_text(encoding='utf-8')
376
+ assert 'sympy' in text
377
+ assert 'tqdm>=4' in text
378
+ assert 'examples-as-code-dependencies' not in text
379
+ html = (html_dir / 'auto_examples' / 'plot_minimal.html').read_text(encoding='utf-8')
380
+ assert 'examples-as-code-dependencies' not in html
381
+
382
+
383
+ def test_dependencies_directive_rejects_invalid_requirement(tmp_path: Path):
384
+ _returncode, _out, err, _html_dir = _build_with_dependencies(tmp_path, 'not!valid')
385
+ assert "Invalid requirement 'not!valid'" in err
@@ -15,6 +15,7 @@ from unittest.mock import Mock
15
15
 
16
16
  from docutils import nodes
17
17
  from docutils.core import publish_doctree
18
+ from docutils.parsers.rst import DirectiveError
18
19
  import pytest
19
20
  from sphinx import addnodes
20
21
  from sphinx.errors import ConfigError
@@ -352,6 +353,13 @@ def test_convert_doctest_block_trailing_blank_trimmed():
352
353
  # ---------------------------------------------------------------------------
353
354
 
354
355
 
356
+ @pytest.mark.parametrize('language', ['python', 'py'])
357
+ def test_convert_literal_block_python_doctest_style(language: str):
358
+ node = nodes.literal_block('', '>>> import math\n>>> math.floor(8.5)\n8')
359
+ node['language'] = language
360
+ assert seac._convert_literal_block(node) == [('code', ['import math', 'math.floor(8.5)'])]
361
+
362
+
355
363
  def test_convert_literal_block_python():
356
364
  # Sphinx's own code-block directive sets a ``language`` attribute
357
365
  # (unlike plain docutils', which uses CSS classes instead), so this
@@ -3086,6 +3094,31 @@ def test_script_metadata_marimo(tmp_path: Path):
3086
3094
  assert source.index('mo.md(') < source.index('import marimo as mo')
3087
3095
 
3088
3096
 
3097
+ @pytest.mark.parametrize(('fmt', 'listed'), [('jupyter', False), ('marimo', True)])
3098
+ def test_script_metadata_drops_covered_except_marimo(
3099
+ tmp_path: Path, fake_requires, fmt: str, listed: bool
3100
+ ):
3101
+ fake_requires({'ipywidgets': ['numpy'], 'marimo': ['numpy']})
3102
+ _name, source = _write_with_metadata(tmp_path, [fmt])
3103
+ header = source.split('///', 2)[1] if fmt == 'marimo' else json.loads(source)['cells'][0]
3104
+ assert ('numpy' in str(header)) is listed
3105
+
3106
+
3107
+ def test_write_marimo_notebook_keeps_percent_lines(tmp_path: Path):
3108
+ code = ['art = """\n', '% %\n', '%% %\n', '"""\n', 'x = 5 % 3']
3109
+ notebook = {
3110
+ 'cells': [{'cell_type': 'code', 'metadata': {}, 'source': code}],
3111
+ 'metadata': {},
3112
+ 'nbformat': 4,
3113
+ 'nbformat_minor': 5,
3114
+ }
3115
+ rel_path = seac._write_marimo_notebook(Mock(outdir=str(tmp_path)), 'art', notebook)
3116
+ source = (tmp_path / '_downloads' / rel_path).read_text(encoding='utf-8')
3117
+ assert 'magic command' not in source
3118
+ assert seac._PERCENT_MARK not in source
3119
+ assert ' art = """\n % %\n %% %\n """\n x = 5 % 3\n' in source
3120
+
3121
+
3089
3122
  @pytest.mark.parametrize(
3090
3123
  ('codes', 'expected'),
3091
3124
  [
@@ -3294,3 +3327,39 @@ def test_drop_covered_keeps_pinned_platform_specific_and_cycles(fake_requires):
3294
3327
  def test_drop_covered_without_metadata_keeps_everything(fake_requires):
3295
3328
  fake_requires({})
3296
3329
  assert seac._drop_covered(['numpy', 'pyvista']) == ['numpy', 'pyvista']
3330
+
3331
+
3332
+ def test_page_dependencies_purge_and_merge():
3333
+ env, other = SimpleNamespace(), SimpleNamespace()
3334
+ seac._page_dependencies(env)['a'] = ['sympy']
3335
+ seac._page_dependencies(other).update({'b': ['tqdm'], 'c': ['rich']})
3336
+ seac._merge_page_dependencies(None, env, {'b'}, other)
3337
+ assert seac._page_dependencies(env) == {'a': ['sympy'], 'b': ['tqdm']}
3338
+ seac._purge_page_dependencies(None, env, 'a')
3339
+ seac._purge_page_dependencies(None, env, 'missing')
3340
+ assert seac._page_dependencies(env) == {'b': ['tqdm']}
3341
+
3342
+
3343
+ def _run_dependencies_directive(argument: str, env: SimpleNamespace) -> list[nodes.Node]:
3344
+ """Run ``examples-as-code-dependencies`` with ``argument`` on page ``page``."""
3345
+ state = Mock()
3346
+ state.document.settings.env = env
3347
+ directive = seac._DependenciesDirective(
3348
+ 'examples-as-code-dependencies', [argument], {}, [], 1, 0, '', state, Mock()
3349
+ )
3350
+ return directive.run()
3351
+
3352
+
3353
+ def test_dependencies_directive_records_requirements():
3354
+ env = SimpleNamespace(docname='page')
3355
+ assert _run_dependencies_directive('sympy, tqdm>=4', env) == []
3356
+ _run_dependencies_directive('rich', env)
3357
+ assert seac._page_dependencies(env) == {'page': ['sympy', 'tqdm>=4', 'rich']}
3358
+
3359
+
3360
+ def test_dependencies_directive_invalid_requirement():
3361
+ env = SimpleNamespace(docname='page')
3362
+ with pytest.raises(DirectiveError) as excinfo:
3363
+ _run_dependencies_directive('sympy not!valid', env)
3364
+ assert excinfo.value.msg.startswith("Invalid requirement 'not!valid'")
3365
+ assert seac._page_dependencies(env) == {}
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.8.2",
3
- "distance": 0,
4
- "node": "g69288337b5a4907e168725fbd4dbc5e1ea9450f8",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-11-01"
8
- }