sphinx-examples-as-code 0.8.3__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 (40) hide show
  1. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/PKG-INFO +10 -2
  2. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/README.md +9 -1
  3. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code/__init__.py +68 -5
  4. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code/_version.py +3 -3
  5. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code.egg-info/PKG-INFO +10 -2
  6. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code.egg-info/scm_version.json +2 -2
  7. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/test_gallery_downloads.py +49 -0
  8. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/test_sphinx_examples_as_code.py +59 -0
  9. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/.github/dependabot.yml +0 -0
  10. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/.github/release.yml +0 -0
  11. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/.github/workflows/ci.yml +0 -0
  12. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/.gitignore +0 -0
  13. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/.pre-commit-config.yaml +0 -0
  14. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/LICENSE +0 -0
  15. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/pyproject.toml +0 -0
  16. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/setup.cfg +0 -0
  17. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code.egg-info/SOURCES.txt +0 -0
  18. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code.egg-info/dependency_links.txt +0 -0
  19. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code.egg-info/requires.txt +0 -0
  20. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code.egg-info/scm_file_list.json +0 -0
  21. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/sphinx_examples_as_code.egg-info/top_level.txt +0 -0
  22. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/gallery_fixture/conf.py +0 -0
  23. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/gallery_fixture/examples/GALLERY_HEADER.rst +0 -0
  24. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/gallery_fixture/examples/plot_minimal.py +0 -0
  25. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/gallery_fixture/index.rst +0 -0
  26. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/heading_level_fixture/conf.py +0 -0
  27. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/heading_level_fixture/examples/GALLERY_HEADER.rst +0 -0
  28. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/heading_level_fixture/examples/plot_reused_level.py +0 -0
  29. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/heading_level_fixture/examples/plot_three_levels.py +0 -0
  30. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/heading_level_fixture/index.rst +0 -0
  31. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/single_function_fixture/conf.py +0 -0
  32. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/single_function_fixture/index.rst +0 -0
  33. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/single_function_fixture/mymodule.py +0 -0
  34. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/test_heading_level_reuse.py +0 -0
  35. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/test_single_function_page.py +0 -0
  36. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/test_tinypages.py +0 -0
  37. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/tinypages/conf.py +0 -0
  38. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/tinypages/docstring_cases.py +0 -0
  39. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/tests/tinypages/docstring_cases.rst +0 -0
  40. {sphinx_examples_as_code-0.8.3 → sphinx_examples_as_code-0.9.0}/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.3
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
@@ -159,7 +159,15 @@ Imports are read from the code, not run. Each imported top-level module maps to
159
159
  project that provides it in the environment building the docs; standard-library
160
160
  modules, relative imports and modules no single installed project provides are left
161
161
  out. Projects the code needs but never imports, such as an optional backend, go in
162
- `dependencies`. In Python scripts and Jupyter notebooks, a dependency with no version or extras of
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
163
171
  its own is left out when another listed one already installs it on every platform, e.g. `numpy`
164
172
  next to `pyvista`. marimo notebooks list every dependency, since marimo's sandbox adds back any
165
173
  it does not find by name.
@@ -128,7 +128,15 @@ Imports are read from the code, not run. Each imported top-level module maps to
128
128
  project that provides it in the environment building the docs; standard-library
129
129
  modules, relative imports and modules no single installed project provides are left
130
130
  out. Projects the code needs but never imports, such as an optional backend, go in
131
- `dependencies`. In Python scripts and Jupyter notebooks, a dependency with no version or extras of
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
132
140
  its own is left out when another listed one already installs it on every platform, e.g. `numpy`
133
141
  next to `pyvista`. marimo notebooks list every dependency, since marimo's sandbox adds back any
134
142
  it does not find by name.
@@ -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
 
@@ -1595,6 +1617,7 @@ def _process_doctree(app: Sphinx, doctree: nodes.document, docname: str) -> None
1595
1617
  'dependencies': conf['dependencies'],
1596
1618
  'requires_python': conf['requires_python'],
1597
1619
  'project': app.config.project,
1620
+ 'page_dependencies': _page_dependencies(app.env).get(docname, []),
1598
1621
  }
1599
1622
  if conf['script_metadata']
1600
1623
  else None
@@ -1837,8 +1860,48 @@ def _disable_gallery_notebooks(_app: Sphinx, config: Config) -> None:
1837
1860
  config.sphinx_gallery_conf = {**gallery_conf, **overrides}
1838
1861
 
1839
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
+
1840
1900
  def setup(app: Sphinx) -> dict: # numpydoc ignore=RT01
1841
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)
1842
1905
  app.connect('doctree-resolved', _process_doctree)
1843
1906
  app.connect('config-inited', _finalize_conf)
1844
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.3'
22
- __version_tuple__ = version_tuple = (0, 8, 3)
21
+ __version__ = version = '0.9.0'
22
+ __version_tuple__ = version_tuple = (0, 9, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g1289062e0'
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.3
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
@@ -159,7 +159,15 @@ Imports are read from the code, not run. Each imported top-level module maps to
159
159
  project that provides it in the environment building the docs; standard-library
160
160
  modules, relative imports and modules no single installed project provides are left
161
161
  out. Projects the code needs but never imports, such as an optional backend, go in
162
- `dependencies`. In Python scripts and Jupyter notebooks, a dependency with no version or extras of
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
163
171
  its own is left out when another listed one already installs it on every platform, e.g. `numpy`
164
172
  next to `pyvista`. marimo notebooks list every dependency, since marimo's sandbox adds back any
165
173
  it does not find by name.
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "0.8.3",
2
+ "tag": "0.9.0",
3
3
  "distance": 0,
4
- "node": "g1289062e019a00bf142aa7e7389f8839220c7b39",
4
+ "node": "g71df6bcfbe9de4cdd1532fe1c93bdb15c9f402fb",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-11-02"
@@ -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
@@ -3096,6 +3104,21 @@ def test_script_metadata_drops_covered_except_marimo(
3096
3104
  assert ('numpy' in str(header)) is listed
3097
3105
 
3098
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
+
3099
3122
  @pytest.mark.parametrize(
3100
3123
  ('codes', 'expected'),
3101
3124
  [
@@ -3304,3 +3327,39 @@ def test_drop_covered_keeps_pinned_platform_specific_and_cycles(fake_requires):
3304
3327
  def test_drop_covered_without_metadata_keeps_everything(fake_requires):
3305
3328
  fake_requires({})
3306
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) == {}