sphinx-examples-as-code 0.7.0__tar.gz → 0.8.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.7.0 → sphinx_examples_as_code-0.8.0}/PKG-INFO +56 -1
  2. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/README.md +54 -0
  3. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/pyproject.toml +1 -1
  4. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/sphinx_examples_as_code/__init__.py +314 -15
  5. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/sphinx_examples_as_code/_version.py +3 -3
  6. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/sphinx_examples_as_code.egg-info/PKG-INFO +56 -1
  7. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/sphinx_examples_as_code.egg-info/requires.txt +1 -0
  8. sphinx_examples_as_code-0.8.0/sphinx_examples_as_code.egg-info/scm_version.json +8 -0
  9. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/test_sphinx_examples_as_code.py +446 -0
  10. sphinx_examples_as_code-0.7.0/sphinx_examples_as_code.egg-info/scm_version.json +0 -8
  11. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/.github/dependabot.yml +0 -0
  12. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/.github/release.yml +0 -0
  13. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/.github/workflows/ci.yml +0 -0
  14. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/.gitignore +0 -0
  15. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/.pre-commit-config.yaml +0 -0
  16. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/LICENSE +0 -0
  17. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/setup.cfg +0 -0
  18. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/sphinx_examples_as_code.egg-info/SOURCES.txt +0 -0
  19. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/sphinx_examples_as_code.egg-info/dependency_links.txt +0 -0
  20. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/sphinx_examples_as_code.egg-info/scm_file_list.json +0 -0
  21. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/sphinx_examples_as_code.egg-info/top_level.txt +0 -0
  22. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/gallery_fixture/conf.py +0 -0
  23. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/gallery_fixture/examples/GALLERY_HEADER.rst +0 -0
  24. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/gallery_fixture/examples/plot_minimal.py +0 -0
  25. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/gallery_fixture/index.rst +0 -0
  26. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/heading_level_fixture/conf.py +0 -0
  27. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/heading_level_fixture/examples/GALLERY_HEADER.rst +0 -0
  28. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/heading_level_fixture/examples/plot_reused_level.py +0 -0
  29. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/heading_level_fixture/examples/plot_three_levels.py +0 -0
  30. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/heading_level_fixture/index.rst +0 -0
  31. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/single_function_fixture/conf.py +0 -0
  32. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/single_function_fixture/index.rst +0 -0
  33. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/single_function_fixture/mymodule.py +0 -0
  34. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/test_gallery_downloads.py +0 -0
  35. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/test_heading_level_reuse.py +0 -0
  36. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/test_single_function_page.py +0 -0
  37. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/test_tinypages.py +0 -0
  38. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/tinypages/conf.py +0 -0
  39. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/tinypages/docstring_cases.py +0 -0
  40. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.0}/tests/tinypages/docstring_cases.rst +0 -0
  41. {sphinx_examples_as_code-0.7.0 → sphinx_examples_as_code-0.8.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.7.0
3
+ Version: 0.8.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
@@ -22,6 +22,7 @@ Classifier: Topic :: Scientific/Engineering :: Visualization
22
22
  Requires-Python: >=3.10
23
23
  Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
+ Requires-Dist: packaging>=22
25
26
  Requires-Dist: pyvista>=0.48
26
27
  Provides-Extra: gallery
27
28
  Requires-Dist: sphinx-gallery<0.23,>=0.22; extra == "gallery"
@@ -85,6 +86,9 @@ sphinx_examples_as_code_conf = {
85
86
  'jupyter': 'Download Jupyter notebook',
86
87
  'marimo': 'Download marimo notebook',
87
88
  },
89
+ 'script_metadata': False,
90
+ 'dependencies': {},
91
+ 'requires_python': None,
88
92
  }
89
93
  ```
90
94
 
@@ -111,6 +115,17 @@ sphinx_examples_as_code_conf = {
111
115
  Markdown link in notebooks, and renders inline as `text url` in Python scripts. Plain
112
116
  text with no markup at all becomes one comment line per line of text, and
113
117
  blank-line-separated paragraphs stay separated.
118
+ - `script_metadata`: `True` adds each download's dependencies and a one-line run
119
+ command. See [Dependencies and run commands](#dependencies-and-run-commands) below.
120
+ `False` (default) adds neither.
121
+ - `dependencies`: extra PEP 508 requirements per format, added to the detected ones, e.g.
122
+ `{'jupyter': ['pyvista[jupyter]']}`. An entry naming a detected project replaces it,
123
+ so `{'python': ['pyvista>=0.46']}` sets its version. An entry without a version keeps
124
+ the automatic pin described below. Only used with `script_metadata` on.
125
+ - `requires_python`: a `requires-python` specifier for the dependency block, e.g.
126
+ `'>=3.10'`. `None` (default) derives one from the dependencies instead; see
127
+ [Dependencies and run commands](#dependencies-and-run-commands). Only used with
128
+ `script_metadata` on.
114
129
 
115
130
  An unrecognized key raises a configuration error at build start.
116
131
 
@@ -122,6 +137,46 @@ Overriding a single key from the command line works via a dotted `-D` flag, e.g.
122
137
  `-D sphinx_examples_as_code_conf.link_position=bottom` -- this only touches that one
123
138
  key, leaving the rest (`conf.py`'s values, or the defaults) alone.
124
139
 
140
+ ## Dependencies and run commands
141
+
142
+ With `script_metadata` on, each download lists the third-party projects its code
143
+ imports, plus any `dependencies` set for its format, and says how to run it:
144
+
145
+ - Python scripts start with a [PEP 723](https://peps.python.org/pep-0723/) `# /// script`
146
+ block, followed by `# Run this script with: uv run <name>.py`.
147
+ - Jupyter notebooks say `` Open this notebook with: `uvx --python "<requires-python>" --from jupyterlab --with <dependency> ... jupyter-lab <name>.ipynb` `` in their first cell,
148
+ with `--python` only when there is a `requires-python`. Arguments are double-quoted where
149
+ needed, so the command works in bash, zsh, PowerShell and cmd. An argument containing
150
+ `"`, `$`, a backtick, `\`, `%` or a `!` not followed by `=` is single-quoted instead,
151
+ 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
+ - Each run command is followed by a line linking to
155
+ [uv's installation guide](https://docs.astral.sh/uv/getting-started/installation/).
156
+
157
+ Imports are read from the code, not run. Each imported top-level module maps to the
158
+ project that provides it in the environment building the docs; standard-library
159
+ modules, relative imports and modules no single installed project provides are left
160
+ out. Projects the code needs but never imports, such as an optional backend, go in
161
+ `dependencies`.
162
+
163
+ The project named by Sphinx's own `project` setting (e.g. `project = 'PyVista'`) gets
164
+ extra treatment when an example imports it and it is installed: its dependency becomes
165
+ `<name>>=X.Y`, the installed release's major and minor version, or plain `<name>` for a
166
+ pre-release or dev build.
167
+
168
+ Unless `requires_python` is set, the block's `requires-python` combines:
169
+
170
+ - that project's own `Requires-Python`, e.g. `>=3.10`, when it is listed;
171
+ - `<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.
174
+ Pure-Python dependencies and dependencies without such classifiers don't count, and
175
+ the bound is dropped when it would exclude every Python the project's own
176
+ `Requires-Python` allows.
177
+
178
+ With neither, the line is left out.
179
+
125
180
  ## Conversion rules
126
181
 
127
182
  What happens to the content of an Examples section:
@@ -54,6 +54,9 @@ sphinx_examples_as_code_conf = {
54
54
  'jupyter': 'Download Jupyter notebook',
55
55
  'marimo': 'Download marimo notebook',
56
56
  },
57
+ 'script_metadata': False,
58
+ 'dependencies': {},
59
+ 'requires_python': None,
57
60
  }
58
61
  ```
59
62
 
@@ -80,6 +83,17 @@ sphinx_examples_as_code_conf = {
80
83
  Markdown link in notebooks, and renders inline as `text url` in Python scripts. Plain
81
84
  text with no markup at all becomes one comment line per line of text, and
82
85
  blank-line-separated paragraphs stay separated.
86
+ - `script_metadata`: `True` adds each download's dependencies and a one-line run
87
+ command. See [Dependencies and run commands](#dependencies-and-run-commands) below.
88
+ `False` (default) adds neither.
89
+ - `dependencies`: extra PEP 508 requirements per format, added to the detected ones, e.g.
90
+ `{'jupyter': ['pyvista[jupyter]']}`. An entry naming a detected project replaces it,
91
+ so `{'python': ['pyvista>=0.46']}` sets its version. An entry without a version keeps
92
+ the automatic pin described below. Only used with `script_metadata` on.
93
+ - `requires_python`: a `requires-python` specifier for the dependency block, e.g.
94
+ `'>=3.10'`. `None` (default) derives one from the dependencies instead; see
95
+ [Dependencies and run commands](#dependencies-and-run-commands). Only used with
96
+ `script_metadata` on.
83
97
 
84
98
  An unrecognized key raises a configuration error at build start.
85
99
 
@@ -91,6 +105,46 @@ Overriding a single key from the command line works via a dotted `-D` flag, e.g.
91
105
  `-D sphinx_examples_as_code_conf.link_position=bottom` -- this only touches that one
92
106
  key, leaving the rest (`conf.py`'s values, or the defaults) alone.
93
107
 
108
+ ## Dependencies and run commands
109
+
110
+ With `script_metadata` on, each download lists the third-party projects its code
111
+ imports, plus any `dependencies` set for its format, and says how to run it:
112
+
113
+ - Python scripts start with a [PEP 723](https://peps.python.org/pep-0723/) `# /// script`
114
+ block, followed by `# Run this script with: uv run <name>.py`.
115
+ - Jupyter notebooks say `` Open this notebook with: `uvx --python "<requires-python>" --from jupyterlab --with <dependency> ... jupyter-lab <name>.ipynb` `` in their first cell,
116
+ with `--python` only when there is a `requires-python`. Arguments are double-quoted where
117
+ needed, so the command works in bash, zsh, PowerShell and cmd. An argument containing
118
+ `"`, `$`, a backtick, `\`, `%` or a `!` not followed by `=` is single-quoted instead,
119
+ 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
+ - Each run command is followed by a line linking to
123
+ [uv's installation guide](https://docs.astral.sh/uv/getting-started/installation/).
124
+
125
+ Imports are read from the code, not run. Each imported top-level module maps to the
126
+ project that provides it in the environment building the docs; standard-library
127
+ modules, relative imports and modules no single installed project provides are left
128
+ out. Projects the code needs but never imports, such as an optional backend, go in
129
+ `dependencies`.
130
+
131
+ The project named by Sphinx's own `project` setting (e.g. `project = 'PyVista'`) gets
132
+ extra treatment when an example imports it and it is installed: its dependency becomes
133
+ `<name>>=X.Y`, the installed release's major and minor version, or plain `<name>` for a
134
+ pre-release or dev build.
135
+
136
+ Unless `requires_python` is set, the block's `requires-python` combines:
137
+
138
+ - that project's own `Requires-Python`, e.g. `>=3.10`, when it is listed;
139
+ - `<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.
142
+ Pure-Python dependencies and dependencies without such classifiers don't count, and
143
+ the bound is dropped when it would exclude every Python the project's own
144
+ `Requires-Python` allows.
145
+
146
+ With neither, the line is left out.
147
+
94
148
  ## Conversion rules
95
149
 
96
150
  What happens to the content of an Examples section:
@@ -32,7 +32,7 @@ classifiers = [
32
32
  'Programming Language :: Python :: 3.14',
33
33
  'Topic :: Scientific/Engineering :: Visualization',
34
34
  ]
35
- dependencies = ['pyvista>=0.48']
35
+ dependencies = ['packaging>=22', 'pyvista>=0.48']
36
36
  description = 'Sphinx extension for converting docstring examples into downloadable code.'
37
37
  dynamic = ['version']
38
38
  keywords = ['download', 'examples', 'sphinx']
@@ -7,17 +7,27 @@ from __future__ import annotations
7
7
 
8
8
  import ast
9
9
  from dataclasses import dataclass
10
+ import functools
10
11
  import hashlib
12
+ import importlib.metadata
11
13
  import importlib.util
12
14
  import json
13
15
  from pathlib import Path
14
16
  import re
17
+ import shlex
18
+ import sys
15
19
  from typing import TYPE_CHECKING
16
20
  from urllib.parse import urljoin
17
21
  from urllib.parse import urlsplit
18
22
 
19
23
  from docutils import nodes
20
24
  from docutils.core import publish_doctree
25
+ from packaging.requirements import Requirement
26
+ from packaging.specifiers import InvalidSpecifier
27
+ from packaging.specifiers import SpecifierSet
28
+ from packaging.utils import canonicalize_name
29
+ from packaging.version import InvalidVersion
30
+ from packaging.version import Version
21
31
  from sphinx import addnodes
22
32
  from sphinx.errors import ConfigError
23
33
  from sphinx.util import logging
@@ -921,6 +931,12 @@ def _build_notebook(cells: list[tuple[str, list[str]]]) -> dict:
921
931
  }
922
932
 
923
933
 
934
+ def _download_filename(name: str, extension: str) -> str:
935
+ """Return the file name a download is written under, e.g. ``pyvista_read.py``."""
936
+ safe_name = name.replace('.', '_') if name else 'example'
937
+ return f'{safe_name}.{extension}'
938
+
939
+
924
940
  def _write_download_file(app: Sphinx, name: str, extension: str, content: str) -> str:
925
941
  """Write generated content directly into the builder's downloads dir.
926
942
 
@@ -932,8 +948,7 @@ def _write_download_file(app: Sphinx, name: str, extension: str, content: str) -
932
948
  """
933
949
  # 32 hex chars, matching Sphinx's own native _downloads/<digest>/... layout.
934
950
  digest = hashlib.sha256(content.encode()).hexdigest()[:32]
935
- safe_name = name.replace('.', '_') if name else 'example'
936
- filename = f'{safe_name}.{extension}'
951
+ filename = _download_filename(name, extension)
937
952
  rel_path = f'{digest}/{filename}'
938
953
 
939
954
  out_path = Path(app.outdir) / '_downloads' / digest / filename
@@ -968,7 +983,7 @@ def _drop_future_imports(source: list[str]) -> list[str]:
968
983
  return [line for i, line in enumerate(source, start=1) if i not in dropped]
969
984
 
970
985
 
971
- def _write_marimo_notebook(app: Sphinx, name: str, notebook: dict) -> str:
986
+ def _write_marimo_notebook(app: Sphinx, name: str, notebook: dict, header: str = '') -> str:
972
987
  """Convert a generated Jupyter notebook with marimo's own converter and write it."""
973
988
  from marimo._convert.converters import MarimoConvert
974
989
 
@@ -980,7 +995,191 @@ def _write_marimo_notebook(app: Sphinx, name: str, notebook: dict) -> str:
980
995
  for cell in notebook['cells']
981
996
  ]
982
997
  source = MarimoConvert.from_ipynb(json.dumps({**notebook, 'cells': cells})).to_py()
983
- return _write_download_file(app, name, 'marimo.py', source)
998
+ return _write_download_file(app, name, 'marimo.py', header + source)
999
+
1000
+
1001
+ @functools.cache
1002
+ def _module_distributions() -> dict[str, list[str]]:
1003
+ """Map each importable top-level module to the distribution(s) providing it."""
1004
+ return importlib.metadata.packages_distributions()
1005
+
1006
+
1007
+ def _canonical_name(requirement: str) -> str:
1008
+ """Return a requirement's normalized name, e.g. ``pyvista`` for ``PyVista[all]>=0.4``."""
1009
+ return canonicalize_name(Requirement(requirement).name)
1010
+
1011
+
1012
+ def _distribution_for(module: str) -> str | None:
1013
+ """Return the one installed distribution providing top-level ``module``, if there is one."""
1014
+ dists = sorted(set(_module_distributions().get(module, [])))
1015
+ if len(dists) == 1:
1016
+ return dists[0]
1017
+ if not dists:
1018
+ # Fall back to a distribution named like the module.
1019
+ try:
1020
+ return importlib.metadata.distribution(module).name
1021
+ except importlib.metadata.PackageNotFoundError:
1022
+ pass
1023
+ logger.debug(
1024
+ 'sphinx_examples_as_code: no single distribution provides %r (%s), not listed.',
1025
+ module,
1026
+ ', '.join(dists) or 'none installed',
1027
+ )
1028
+ return None
1029
+
1030
+
1031
+ def _imported_distributions(source: str) -> list[str]:
1032
+ """Return the third-party distributions whose modules ``source`` imports."""
1033
+ try:
1034
+ tree = ast.parse(source)
1035
+ except SyntaxError:
1036
+ return []
1037
+ modules = set()
1038
+ for node in ast.walk(tree):
1039
+ if isinstance(node, ast.Import):
1040
+ modules.update(alias.name.split('.')[0] for alias in node.names)
1041
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
1042
+ modules.add(node.module.split('.')[0])
1043
+ modules -= {*sys.stdlib_module_names, '__future__'}
1044
+ dists = (_distribution_for(module) for module in sorted(modules))
1045
+ return [dist for dist in dists if dist is not None]
1046
+
1047
+
1048
+ def _script_dependencies(
1049
+ source: str, extra: list[str], pins: dict[str, str] | None = None
1050
+ ) -> list[str]:
1051
+ """Return ``source``'s imported distributions, pinned per ``pins``, then ``extra`` on top."""
1052
+ pins = pins or {}
1053
+ deps = {_canonical_name(dist): dist for dist in _imported_distributions(source)}
1054
+ deps.update((name, pin) for name, pin in pins.items() if name in deps)
1055
+ for req in extra:
1056
+ name = _canonical_name(req)
1057
+ deps[name] = _keep_pin(req, pins.get(name))
1058
+ return [deps[key] for key in sorted(deps)]
1059
+
1060
+
1061
+ def _keep_pin(requirement: str, pin: str | None) -> str:
1062
+ """Give ``requirement`` the version specifier of ``pin`` when it has none of its own."""
1063
+ parsed = Requirement(requirement)
1064
+ if pin is None or parsed.specifier or parsed.url:
1065
+ return requirement
1066
+ parsed.specifier = Requirement(pin).specifier
1067
+ return str(parsed)
1068
+
1069
+
1070
+ @dataclass(frozen=True)
1071
+ class _DocumentedProject:
1072
+ """The installed distribution named by Sphinx's ``project``."""
1073
+
1074
+ name: str # normalized, as _canonical_name returns it
1075
+ pin: str | None # e.g. 'pyvista>=0.49'; None for a pre-release, dev or unparsable version
1076
+ requires_python: str | None
1077
+
1078
+
1079
+ @functools.cache
1080
+ def _documented_project(project: str | None) -> _DocumentedProject | None:
1081
+ """Look up the installed distribution named like Sphinx's ``project``, if there is one."""
1082
+ if not project:
1083
+ return None
1084
+ name = canonicalize_name(re.sub(r'\s+', '-', project.strip()))
1085
+ try:
1086
+ dist = importlib.metadata.distribution(name)
1087
+ except importlib.metadata.PackageNotFoundError:
1088
+ return None
1089
+ try:
1090
+ version = Version(dist.version)
1091
+ except InvalidVersion:
1092
+ version = None
1093
+ pin = None
1094
+ if version is not None and not version.is_prerelease:
1095
+ pin = f'{dist.name}>={version.major}.{version.minor}'
1096
+ return _DocumentedProject(name, pin, dist.metadata['Requires-Python'])
1097
+
1098
+
1099
+ _UV_INSTALL_LINE = 'Install uv first: https://docs.astral.sh/uv/getting-started/installation/'
1100
+
1101
+ _PYTHON_CLASSIFIER = re.compile(r'Programming Language :: Python :: 3\.(\d+)$')
1102
+
1103
+
1104
+ @functools.cache
1105
+ def _newest_python_minor(name: str) -> int | None:
1106
+ """Return the newest ``3.N`` minor an installed distribution's classifiers name, if any."""
1107
+ try:
1108
+ classifiers = importlib.metadata.metadata(name).get_all('Classifier')
1109
+ except importlib.metadata.PackageNotFoundError:
1110
+ return None
1111
+ minors = [int(m[1]) for c in classifiers or [] if (m := _PYTHON_CLASSIFIER.match(c))]
1112
+ return max(minors, default=None)
1113
+
1114
+
1115
+ @functools.cache
1116
+ def _ships_compiled_wheel(name: str) -> bool:
1117
+ """Check whether an installed distribution's wheel is tagged for a platform or ABI."""
1118
+ try:
1119
+ wheel = importlib.metadata.distribution(name).read_text('WHEEL') or ''
1120
+ except importlib.metadata.PackageNotFoundError:
1121
+ return False
1122
+ tags = [
1123
+ line.partition(':')[2].strip() for line in wheel.splitlines() if line.startswith('Tag:')
1124
+ ]
1125
+ return any(not tag.endswith('-none-any') for tag in tags)
1126
+
1127
+
1128
+ def _python_upper_bound(deps: list[str], project: str | None = None) -> str | None:
1129
+ """Return ``<3.N`` just above the newest Python ``project`` and every compiled dep name."""
1130
+ names = [_canonical_name(dep) for dep in deps]
1131
+ newest = [_newest_python_minor(n) for n in names if n == project or _ships_compiled_wheel(n)]
1132
+ known = [minor for minor in newest if minor is not None]
1133
+ return f'<3.{min(known) + 1}' if known else None
1134
+
1135
+
1136
+ def _python_range(floor: str | None, cap: str | None) -> str | None:
1137
+ """Join ``floor`` and ``cap``, dropping ``cap`` when no ``3.N`` Python would be left."""
1138
+ if floor and cap:
1139
+ allowed = SpecifierSet(f'{floor},{cap}')
1140
+ if not any(f'3.{minor}' in allowed for minor in range(100)):
1141
+ cap = None
1142
+ return ','.join(filter(None, (floor, cap))) or None
1143
+
1144
+
1145
+ def _script_metadata_header(deps: list[str], requires_python: str | None, run_line: str) -> str:
1146
+ """Build a PEP 723 ``# /// script`` block followed by a one-line run instruction."""
1147
+ lines = ['# /// script']
1148
+ if requires_python:
1149
+ lines.append(f'# requires-python = {json.dumps(requires_python)}')
1150
+ if deps:
1151
+ lines.append('# dependencies = [')
1152
+ lines.extend(f'# {json.dumps(dep)},' for dep in deps)
1153
+ lines.append('# ]')
1154
+ else:
1155
+ lines.append('# dependencies = []')
1156
+ lines.extend(['# ///', f'# {run_line}', f'# {_UV_INSTALL_LINE}', ''])
1157
+ return '\n'.join(lines) + '\n'
1158
+
1159
+
1160
+ _BARE_SHELL_ARG = re.compile(r'[\w./-][\w./:@+=-]*')
1161
+ #: Characters some shell still expands inside double quotes; ``!=`` is safe everywhere.
1162
+ _DOUBLE_QUOTE_UNSAFE = re.compile(r'["$`\\%]|!(?!=)')
1163
+
1164
+
1165
+ def _quote_shell_arg(arg: str) -> str:
1166
+ """Quote a command argument for bash, zsh, PowerShell and cmd alike, where possible."""
1167
+ if _BARE_SHELL_ARG.fullmatch(arg):
1168
+ return arg
1169
+ if not _DOUBLE_QUOTE_UNSAFE.search(arg):
1170
+ return f'"{arg}"'
1171
+ return shlex.quote(arg)
1172
+
1173
+
1174
+ def _jupyter_run_line(deps: list[str], requires_python: str | None, filename: str) -> str:
1175
+ """Build the one-line command that opens a generated notebook with its dependencies."""
1176
+ python_arg = f' --python {_quote_shell_arg(requires_python)}' if requires_python else ''
1177
+ # Swap marker double quotes for single quotes.
1178
+ dep_args = (_quote_shell_arg(dep.replace('"', "'")) for dep in deps)
1179
+ with_args = ''.join(f' --with {arg}' for arg in dep_args)
1180
+ notebook = _quote_shell_arg(filename)
1181
+ command = f'uvx{python_arg} --from jupyterlab{with_args} jupyter-lab {notebook}'
1182
+ return f'Open this notebook with: `{command}`'
984
1183
 
985
1184
 
986
1185
  #: Default download link text per format -- see ``sphinx_examples_as_code_conf['link_labels']``.
@@ -1033,6 +1232,7 @@ def _build_download_entries(
1033
1232
  formats: list[str],
1034
1233
  footer: str | None,
1035
1234
  link_labels: dict[str, str],
1235
+ script_metadata: dict | None = None,
1036
1236
  ) -> list[tuple[str, str]]:
1037
1237
  """Convert a span of nodes into written download files, per ``formats``.
1038
1238
 
@@ -1062,17 +1262,42 @@ def _build_download_entries(
1062
1262
  if not _has_real_code(source):
1063
1263
  return []
1064
1264
 
1065
- notebook = None
1066
- if _FMT_JUPYTER in formats or _FMT_MARIMO in formats:
1265
+ deps: dict[str, list[str]] = {}
1266
+ project = None
1267
+ if script_metadata is not None:
1268
+ implied = {_FMT_MARIMO: ['marimo']}
1269
+ extra = script_metadata['dependencies']
1270
+ project = _documented_project(script_metadata['project'])
1271
+ pins = {project.name: project.pin} if project and project.pin else {}
1272
+ deps = {
1273
+ fmt: _script_dependencies(source, [*implied.get(fmt, []), *extra.get(fmt, [])], pins)
1274
+ for fmt in formats
1275
+ }
1276
+
1277
+ def notebook_for(run_line: str | None) -> dict:
1067
1278
  jupyter_ctx = _RenderContext(app=app, docname=docname, fmt=_FMT_JUPYTER)
1068
1279
  jupyter_segments = _build_segments(nodes_in_span, jupyter_ctx)
1069
1280
  jupyter_header = _title_underline_segment(header_title, 1, _FMT_JUPYTER)
1281
+ run_segments = [('text', [f'# {run_line}', f'# {_UV_INSTALL_LINE}'])] if run_line else []
1070
1282
  # Converted separately from the footer and concatenated, rather
1071
1283
  # than joined into one segment list and converted together --
1072
1284
  # the footer always gets its own dedicated cell this way.
1073
- cells = _segments_to_cells([jupyter_header, *jupyter_segments])
1285
+ cells = _segments_to_cells([jupyter_header, *run_segments, *jupyter_segments])
1074
1286
  cells.extend(_segments_to_cells(_footer_segment(footer, _FMT_JUPYTER, app, docname)))
1075
- notebook = _build_notebook(cells)
1287
+ return _build_notebook(cells)
1288
+
1289
+ def requires_python_for(fmt: str) -> str | None:
1290
+ if script_metadata['requires_python']:
1291
+ return script_metadata['requires_python']
1292
+ listed = {_canonical_name(dep) for dep in deps[fmt]}
1293
+ floor = project.requires_python if project and project.name in listed else None
1294
+ cap = _python_upper_bound(deps[fmt], project.name if project else None)
1295
+ return _python_range(floor, cap)
1296
+
1297
+ def header_for(fmt: str, run_line: str) -> str:
1298
+ if not deps:
1299
+ return ''
1300
+ return _script_metadata_header(deps[fmt], requires_python_for(fmt), run_line)
1076
1301
 
1077
1302
  entries = []
1078
1303
  for fmt in _FORMAT_ORDER:
@@ -1080,11 +1305,21 @@ def _build_download_entries(
1080
1305
  continue
1081
1306
  label = link_labels[fmt]
1082
1307
  if fmt == _FMT_PYTHON:
1083
- rel_path = _write_python_script(app, name, source)
1308
+ filename = _download_filename(name, 'py')
1309
+ header = header_for(fmt, f'Run this script with: uv run {filename}')
1310
+ rel_path = _write_python_script(app, name, header + source)
1084
1311
  elif fmt == _FMT_JUPYTER:
1085
- rel_path = _write_jupyter_notebook(app, name, notebook)
1312
+ filename = _download_filename(name, 'ipynb')
1313
+ run_line = (
1314
+ _jupyter_run_line(deps[fmt], requires_python_for(fmt), filename) if deps else None
1315
+ )
1316
+ rel_path = _write_jupyter_notebook(app, name, notebook_for(run_line))
1086
1317
  else:
1087
- rel_path = _write_marimo_notebook(app, name, notebook)
1318
+ filename = _download_filename(name, 'marimo.py')
1319
+ run_line = f'Open this notebook with: uvx marimo edit --sandbox {filename}'
1320
+ rel_path = _write_marimo_notebook(
1321
+ app, name, notebook_for(None), header_for(fmt, run_line)
1322
+ )
1088
1323
  entries.append((label, rel_path))
1089
1324
 
1090
1325
  return entries
@@ -1102,6 +1337,7 @@ def _process_span(
1102
1337
  formats: list[str],
1103
1338
  footer: str | None,
1104
1339
  link_labels: dict[str, str],
1340
+ script_metadata: dict | None = None,
1105
1341
  ) -> None:
1106
1342
  """Convert one Examples span and insert download link(s) if it has real code."""
1107
1343
  nodes_in_span = list(parent.children[start:end])
@@ -1116,6 +1352,7 @@ def _process_span(
1116
1352
  formats,
1117
1353
  footer,
1118
1354
  link_labels,
1355
+ script_metadata,
1119
1356
  )
1120
1357
  if not entries:
1121
1358
  return
@@ -1196,6 +1433,7 @@ def _process_gallery_page(
1196
1433
  formats: list[str],
1197
1434
  footer: str | None,
1198
1435
  link_labels: dict[str, str],
1436
+ script_metadata: dict | None = None,
1199
1437
  ) -> None:
1200
1438
  """Replace a sphinx-gallery page's own download footer with converted downloads.
1201
1439
 
@@ -1238,6 +1476,7 @@ def _process_gallery_page(
1238
1476
  formats,
1239
1477
  footer,
1240
1478
  link_labels,
1479
+ script_metadata,
1241
1480
  )
1242
1481
  if not entries:
1243
1482
  return
@@ -1263,9 +1502,20 @@ def _process_doctree(app: Sphinx, doctree: nodes.document, docname: str) -> None
1263
1502
  formats = conf['formats']
1264
1503
  footer = conf['footer']
1265
1504
  link_labels = conf['link_labels']
1505
+ script_metadata = (
1506
+ {
1507
+ 'dependencies': conf['dependencies'],
1508
+ 'requires_python': conf['requires_python'],
1509
+ 'project': app.config.project,
1510
+ }
1511
+ if conf['script_metadata']
1512
+ else None
1513
+ )
1266
1514
 
1267
1515
  if conf['gallery_downloads']:
1268
- _process_gallery_page(app, docname, doctree, position, formats, footer, link_labels)
1516
+ _process_gallery_page(
1517
+ app, docname, doctree, position, formats, footer, link_labels, script_metadata
1518
+ )
1269
1519
 
1270
1520
  # Process spans per shared parent, last to first.
1271
1521
  spans = _examples_spans(doctree)
@@ -1285,6 +1535,7 @@ def _process_doctree(app: Sphinx, doctree: nodes.document, docname: str) -> None
1285
1535
  formats,
1286
1536
  footer,
1287
1537
  link_labels,
1538
+ script_metadata,
1288
1539
  )
1289
1540
 
1290
1541
 
@@ -1302,6 +1553,9 @@ _CONF_DEFAULTS: dict[str, object] = {
1302
1553
  'gallery_downloads': False,
1303
1554
  'footer': _DEFAULT_FOOTER,
1304
1555
  'link_labels': _DEFAULT_LINK_LABELS,
1556
+ 'script_metadata': False,
1557
+ 'dependencies': {},
1558
+ 'requires_python': None,
1305
1559
  }
1306
1560
 
1307
1561
 
@@ -1313,20 +1567,20 @@ def _coerce_conf_value(key: str, value: object) -> object:
1313
1567
  command line always arrives as a plain string -- Sphinx's own type
1314
1568
  coercion for ``-D`` overrides (bool ``0``/``1``, comma-split lists, ...)
1315
1569
  only applies to a config value's top level, never to a dict's individual
1316
- keys, so it's redone here for the two keys that need it.
1570
+ keys, so it's redone here for the keys that need it.
1317
1571
  """
1318
1572
  if not isinstance(value, str):
1319
1573
  return value
1320
1574
  if key == 'formats':
1321
1575
  return value.split(',')
1322
- if key == 'gallery_downloads':
1576
+ if key in ('gallery_downloads', 'script_metadata'):
1323
1577
  if value == '0':
1324
1578
  return False
1325
1579
  if value == '1':
1326
1580
  return True
1327
1581
  msg = f"sphinx_examples_as_code_conf[{key!r}] must be '0' or '1', got {value!r}."
1328
1582
  raise ConfigError(msg)
1329
- return value # link_position, footer: a string is already the real type
1583
+ return value # link_position, footer, requires_python: a string is already the real type
1330
1584
 
1331
1585
 
1332
1586
  def _merged_link_labels(value: object) -> dict[str, str]:
@@ -1373,6 +1627,49 @@ def _validate_formats(formats: list[str]) -> None:
1373
1627
  raise ConfigError(msg + _renamed_hint(unknown))
1374
1628
 
1375
1629
 
1630
+ def _validate_dependencies(dependencies: object) -> None:
1631
+ """Raise a ``ConfigError`` unless ``dependencies`` maps known formats to lists of strings."""
1632
+ key = "sphinx_examples_as_code_conf['dependencies']"
1633
+ if not isinstance(dependencies, dict):
1634
+ msg = f'{key} must be a dict, got {dependencies!r}.'
1635
+ raise ConfigError(msg)
1636
+ unknown = sorted(set(dependencies) - set(_FORMAT_ORDER))
1637
+ if unknown:
1638
+ msg = f'{key} has unknown format(s) {unknown} (valid: {", ".join(_FORMAT_ORDER)}).'
1639
+ raise ConfigError(msg + _renamed_hint(unknown))
1640
+ for fmt, reqs in dependencies.items():
1641
+ if not isinstance(reqs, list) or not all(_is_requirement(req) for req in reqs):
1642
+ msg = f'{key}[{fmt!r}] must be a list of requirement strings, got {reqs!r}.'
1643
+ raise ConfigError(msg)
1644
+
1645
+
1646
+ def _is_requirement(value: object) -> bool:
1647
+ """Check whether ``value`` is a valid PEP 508 requirement string."""
1648
+ try:
1649
+ Requirement(value)
1650
+ except (TypeError, ValueError):
1651
+ return False
1652
+ return True
1653
+
1654
+
1655
+ def _validate_requires_python(requires_python: object) -> None:
1656
+ """Raise a ``ConfigError`` unless ``requires_python`` is ``None`` or a non-empty string."""
1657
+ if requires_python is None:
1658
+ return
1659
+ if isinstance(requires_python, str) and requires_python.strip():
1660
+ try:
1661
+ SpecifierSet(requires_python)
1662
+ except InvalidSpecifier:
1663
+ pass
1664
+ else:
1665
+ return
1666
+ msg = (
1667
+ "sphinx_examples_as_code_conf['requires_python'] must be a specifier string such as "
1668
+ f"'>=3.10', or None, got {requires_python!r}."
1669
+ )
1670
+ raise ConfigError(msg)
1671
+
1672
+
1376
1673
  def _marimo_converter_available() -> bool:
1377
1674
  """Check whether marimo's notebook converter (marimo 0.14 or newer) is importable."""
1378
1675
  try:
@@ -1399,6 +1696,8 @@ def _finalize_conf(_app: Sphinx, config: Config) -> None:
1399
1696
  merged = dict(_CONF_DEFAULTS)
1400
1697
  merged.update((key, _coerce_conf_value(key, value)) for key, value in user_conf.items())
1401
1698
  merged['link_labels'] = _merged_link_labels(merged['link_labels'])
1699
+ _validate_dependencies(merged['dependencies'])
1700
+ _validate_requires_python(merged['requires_python'])
1402
1701
  _validate_formats(merged['formats'])
1403
1702
  if _FMT_MARIMO in merged['formats'] and not _marimo_converter_available():
1404
1703
  if 'formats' in user_conf:
@@ -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.7.0'
22
- __version_tuple__ = version_tuple = (0, 7, 0)
21
+ __version__ = version = '0.8.0'
22
+ __version_tuple__ = version_tuple = (0, 8, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g5c3a4173e'
24
+ __commit_id__ = commit_id = 'g35e7c7f07'