Skip to content

Commit 5763683

Browse files
noxfile: Add support for example script discovery and testing
- Implemented utilities to discover example scripts in the `examples` directory, including support for Python-version-specific overrides. - Introduced `_load_script_metadata` to parse and load metadata from example scripts (e.g., required Python version and dependencies). - Added `_scripts_for_python` to filter and select compatible scripts based on their metadata and interpreter version. - Created a new `examples` session in `noxfile` to execute discovered example scripts across supported Python versions. - Introduced a `run-example` session to allow running a single example script with its matching interpreter via `nox`. Assisted-by: Codex
1 parent c7d8e45 commit 5763683

1 file changed

Lines changed: 227 additions & 1 deletion

File tree

noxfile.py

Lines changed: 227 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,163 @@
1+
from __future__ import annotations
2+
13
import argparse
4+
import ast
5+
from collections.abc import Iterable
6+
from dataclasses import dataclass
7+
from functools import cache
8+
from pathlib import Path
29

310
import nox
11+
from packaging.specifiers import SpecifierSet
12+
from packaging.version import Version
413

514
nox.options.default_venv_backend = "uv"
615

7-
python_versions = ["3.10", "3.11", "3.12", "3.13", "3.14"]
16+
python_versions = ("3.10", "3.11", "3.12", "3.13", "3.14")
17+
PROJECT_ROOT = Path(__file__).resolve().parent
18+
DEFAULT_EXAMPLE_PYTHON = "3.11"
19+
EXAMPLES_DIR = PROJECT_ROOT / "examples"
20+
21+
22+
@dataclass(frozen=True)
23+
class ExampleScript:
24+
base: Path
25+
overrides: dict[str, Path]
26+
27+
def select_for_python(self, python_version: str) -> Path:
28+
interpreter = Version(python_version)
29+
for version_str, script_path in sorted(
30+
self.overrides.items(), key=lambda item: Version(item[0])
31+
):
32+
if interpreter <= Version(version_str):
33+
return script_path
34+
return self.base
35+
36+
37+
def _is_override_dir(path: Path) -> bool:
38+
return path.is_dir() and path.name.startswith("python-")
39+
40+
41+
def _discover_example_scripts() -> list[ExampleScript]:
42+
examples: list[ExampleScript] = []
43+
if not EXAMPLES_DIR.exists():
44+
return examples
45+
46+
for script in sorted(EXAMPLES_DIR.rglob("*.py")):
47+
relative_parts = script.relative_to(EXAMPLES_DIR).parts
48+
if not relative_parts:
49+
continue
50+
if relative_parts[0] == "resources":
51+
continue
52+
if any(part.startswith("python-") for part in relative_parts):
53+
continue
54+
55+
parent = script.parent
56+
overrides: dict[str, Path] = {}
57+
for override_dir in parent.iterdir():
58+
if not _is_override_dir(override_dir):
59+
continue
60+
override_script = override_dir / script.name
61+
if override_script.exists():
62+
overrides[override_dir.name.removeprefix("python-")] = override_script
63+
64+
examples.append(ExampleScript(base=script, overrides=overrides))
65+
66+
return examples
67+
68+
69+
EXAMPLE_SCRIPTS = _discover_example_scripts()
70+
71+
72+
@dataclass(frozen=True)
73+
class ScriptMetadata:
74+
requires_python: str | None
75+
dependencies: tuple[str, ...]
76+
77+
78+
@cache
79+
def _load_script_metadata(script: Path) -> ScriptMetadata:
80+
requires_python: str | None = None
81+
dependencies: tuple[str, ...] = ()
82+
if not script.exists():
83+
return ScriptMetadata(requires_python, dependencies)
84+
85+
lines = script.read_text().splitlines()
86+
if not lines or lines[0].strip() != "# /// script":
87+
return ScriptMetadata(requires_python, dependencies)
88+
89+
block: list[str] = []
90+
for line in lines[1:]:
91+
stripped = line.strip()
92+
if stripped == "# ///":
93+
break
94+
if stripped.startswith("# "):
95+
block.append(stripped[2:])
96+
else:
97+
break
98+
99+
metadata: dict[str, str] = {}
100+
for entry in block:
101+
if "=" not in entry:
102+
continue
103+
key, value = entry.split("=", 1)
104+
metadata[key.strip()] = value.strip()
105+
106+
if raw := metadata.get("requires-python"):
107+
requires_python = ast.literal_eval(raw)
108+
if raw := metadata.get("dependencies"):
109+
dependencies = tuple(ast.literal_eval(raw))
110+
111+
return ScriptMetadata(requires_python, dependencies)
112+
113+
114+
def _script_supports_python(script: Path, python_version: str) -> bool:
115+
metadata = _load_script_metadata(script)
116+
if not metadata.requires_python:
117+
return True
118+
spec = SpecifierSet(metadata.requires_python)
119+
return Version(python_version) in spec
120+
121+
122+
def _collect_script_dependencies(scripts: Iterable[Path]) -> list[str]:
123+
deps: set[str] = set()
124+
for script in scripts:
125+
metadata = _load_script_metadata(script)
126+
for dependency in metadata.dependencies:
127+
if dependency.split("[", 1)[0] == "pdfrest":
128+
continue
129+
deps.add(dependency)
130+
return sorted(deps)
131+
132+
133+
def _scripts_for_python(python_version: str) -> list[Path]:
134+
selected: list[Path] = []
135+
for example in EXAMPLE_SCRIPTS:
136+
script = example.select_for_python(python_version)
137+
if _script_supports_python(script, python_version):
138+
selected.append(script)
139+
return sorted(selected)
140+
141+
142+
def _preferred_python_for_script(script: Path) -> str:
143+
metadata = _load_script_metadata(script)
144+
if not metadata.requires_python:
145+
return DEFAULT_EXAMPLE_PYTHON
146+
147+
spec = SpecifierSet(metadata.requires_python)
148+
for version in python_versions:
149+
if Version(version) in spec:
150+
return version
151+
return DEFAULT_EXAMPLE_PYTHON
152+
153+
154+
def _infer_python_version_from_path(script: Path) -> str | None:
155+
for parent in script.parents:
156+
name = parent.name
157+
if name.startswith("python-"):
158+
_, _, version = name.partition("-")
159+
return version
160+
return None
8161

9162

10163
@nox.session(name="tests", python=python_versions, reuse_venv=True)
@@ -42,3 +195,76 @@ def tests(session: nox.Session) -> None:
42195
"--cov-report=term-missing",
43196
*pytest_args,
44197
)
198+
199+
200+
@nox.session(name="examples", python=python_versions, reuse_venv=True)
201+
def run_examples(session: nox.Session) -> None:
202+
"""Execute example scripts across supported interpreters."""
203+
if not session.python:
204+
session.error("Interpreter selection is required for the examples session.")
205+
206+
if type(session.python) is not str:
207+
msg = f"Unexpected type for session.python: {type(session.python)}"
208+
raise TypeError(msg)
209+
scripts = _scripts_for_python(session.python)
210+
if not scripts:
211+
session.skip(f"No example scripts registered for Python {session.python}.")
212+
213+
deps = _collect_script_dependencies(scripts)
214+
if deps:
215+
session.install(*deps)
216+
session.install(".")
217+
218+
for script in scripts:
219+
session.log(f"Running example: {script.relative_to(PROJECT_ROOT)}")
220+
_ = session.run("python", str(script))
221+
222+
223+
@nox.session(name="run-example", python=False, reuse_venv=False, tags=["examples"])
224+
def run_example(session: nox.Session) -> None:
225+
"""Run a single example script with the matching interpreter.
226+
227+
Usage:
228+
nox -s run-example -- path/to/script.py [script args...]
229+
"""
230+
231+
if not session.posargs:
232+
session.error("Provide the path to an example script.")
233+
234+
script_path = Path(session.posargs[0]).resolve()
235+
if not script_path.exists():
236+
session.error(f"Example script not found: {script_path}")
237+
238+
required_python = _infer_python_version_from_path(script_path)
239+
if required_python is None:
240+
required_python = _preferred_python_for_script(script_path)
241+
242+
extra_args = session.posargs[1:]
243+
tmp_root = Path(session.create_tmp())
244+
temp_env = tmp_root / f"uv-env-{required_python.replace('.', '_')}"
245+
temp_env.mkdir(parents=True, exist_ok=True)
246+
247+
cmd = [
248+
"uv",
249+
"run",
250+
"--project",
251+
str(PROJECT_ROOT),
252+
"--python",
253+
required_python,
254+
"python",
255+
str(script_path),
256+
*extra_args,
257+
]
258+
env = session.env.copy()
259+
env.update(
260+
{
261+
"UV_PROJECT_ENVIRONMENT": str(temp_env),
262+
"UV_PYTHON_INSTALL_DIR": str(tmp_root / "uv-python"),
263+
}
264+
)
265+
_ = session.run(
266+
*cmd,
267+
env=env,
268+
external=True,
269+
success_codes=[0],
270+
)

0 commit comments

Comments
 (0)