Skip to content

Commit 8d72aae

Browse files
committed
Notebook testing
1 parent b35999e commit 8d72aae

70 files changed

Lines changed: 964 additions & 17 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/pr.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ jobs:
7676
pip install -r dev_tools/requirements/envs/pytest.env.txt
7777
pip install --no-deps -e .
7878
- run: |
79-
python dev_tools/execute-notebooks.py --n-workers=8
79+
check/pytest-notebook
8080
env:
8181
NUMBA_NUM_THREADS: 4
8282

check/pytest-notebook

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env bash
2+
3+
################################################################################
4+
# Runs notebook tests via jupytext-based execution.
5+
################################################################################
6+
7+
# Get the working directory to the repo root.
8+
thisdir="$(dirname "${BASH_SOURCE[0]}")" || exit $?
9+
topdir="$(git -C "${thisdir}" rev-parse --show-toplevel)" || exit $?
10+
cd "${topdir}" || exit $?
11+
12+
set -e
13+
14+
python dev_tools/check-notebook-tests.py
15+
16+
# Use non-interactive backend so plt.show() doesn't open GUI windows.
17+
export MPLBACKEND=agg
18+
19+
pytest -v qualtran/ tutorials/ \
20+
-m notebook

dev_tools/check-notebook-tests.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Verify that every committed .ipynb has a corresponding pytest notebook test.
16+
17+
For each committed notebook, checks that:
18+
1. A *_test.py file exists containing an execute_notebook('name') call
19+
2. That test function is decorated with @pytest.mark.notebook
20+
21+
Usage:
22+
python dev_tools/check-notebook-tests.py
23+
"""
24+
25+
import ast
26+
import subprocess
27+
import sys
28+
from pathlib import Path
29+
from typing import Dict, Tuple
30+
31+
from qualtran_dev_tools.git_tools import get_git_root
32+
33+
_EXCLUDED_DIRS = {'dev_tools'}
34+
35+
36+
def get_committed_notebooks(reporoot: Path) -> Dict[str, Path]:
37+
"""Return {stem: relative_path} for all committed .ipynb files under reporoot.
38+
39+
Excludes notebooks in dev_tools/ since those are developer utilities,
40+
not user-facing documentation.
41+
"""
42+
result = subprocess.run(
43+
['git', 'ls-files', '*.ipynb'], capture_output=True, text=True, check=True, cwd=reporoot
44+
)
45+
return {
46+
Path(f).stem: Path(f)
47+
for f in result.stdout.strip().split('\n')
48+
if f and not any(Path(f).parts[0] == d for d in _EXCLUDED_DIRS)
49+
}
50+
51+
52+
def _is_notebook_marker(decorator: ast.expr) -> bool:
53+
"""Check if a decorator is @pytest.mark.notebook."""
54+
# Handle pytest.mark.notebook (attr chain)
55+
if isinstance(decorator, ast.Attribute) and decorator.attr == 'notebook':
56+
return True
57+
return False
58+
59+
60+
def find_notebook_tests(reporoot: Path) -> Dict[str, Tuple[Path, bool]]:
61+
"""Find all execute_notebook() calls in test files.
62+
63+
Searches all *_test.py files under the repo root (including qualtran/
64+
and tutorials/).
65+
66+
Returns {notebook_name: (test_file_path_relative, has_notebook_marker)}.
67+
"""
68+
results: Dict[str, Tuple[Path, bool]] = {}
69+
for test_file in reporoot.rglob('*_test.py'):
70+
try:
71+
tree = ast.parse(test_file.read_text())
72+
except SyntaxError:
73+
continue
74+
75+
for node in ast.walk(tree):
76+
if not isinstance(node, ast.FunctionDef):
77+
continue
78+
# Check if function body contains execute_notebook('xxx')
79+
for child in ast.walk(node):
80+
if (
81+
isinstance(child, ast.Call)
82+
and _is_execute_notebook_call(child)
83+
and child.args
84+
and isinstance(child.args[0], ast.Constant)
85+
):
86+
nb_name = child.args[0].value
87+
# Check for @pytest.mark.notebook decorator
88+
has_marker = any(_is_notebook_marker(dec) for dec in node.decorator_list)
89+
results[nb_name] = (test_file.relative_to(reporoot), has_marker)
90+
return results
91+
92+
93+
def _is_execute_notebook_call(node: ast.Call) -> bool:
94+
"""Check if a Call node is a call to execute_notebook (with or without module prefix)."""
95+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'execute_notebook':
96+
return True
97+
if isinstance(node.func, ast.Name) and node.func.id == 'execute_notebook':
98+
return True
99+
return False
100+
101+
102+
def main():
103+
reporoot = get_git_root()
104+
105+
committed = get_committed_notebooks(reporoot)
106+
tested = find_notebook_tests(reporoot)
107+
108+
errors = []
109+
110+
for stem, nb_rel_path in sorted(committed.items()):
111+
if stem not in tested:
112+
# Suggest the likely test file location
113+
nb_dir = nb_rel_path.parent
114+
test_file = nb_dir / f'{stem}_test.py'
115+
errors.append(
116+
f" MISSING TEST: {nb_rel_path}\n"
117+
f" Add to {test_file}:\n"
118+
f"\n"
119+
f" @pytest.mark.notebook\n"
120+
f" def test_{stem}_notebook():\n"
121+
f" qlt_testing.execute_notebook('{stem}')\n"
122+
)
123+
else:
124+
test_file, has_marker = tested[stem]
125+
if not has_marker:
126+
errors.append(
127+
f" MISSING MARKER: {nb_rel_path}\n"
128+
f" The test in {test_file} calls execute_notebook('{stem}')\n"
129+
f" but is not decorated with @pytest.mark.notebook.\n"
130+
f" Add the decorator so this test runs in the notebooks CI job:\n"
131+
f"\n"
132+
f" @pytest.mark.notebook\n"
133+
f" def test_...():\n"
134+
)
135+
136+
if errors:
137+
print(f"ERROR: {len(errors)} notebook(s) have issues:\n")
138+
print("\n".join(errors))
139+
sys.exit(1)
140+
141+
print(f"OK: All {len(committed)} notebooks have properly marked tests.")
142+
143+
144+
if __name__ == '__main__':
145+
main()

dev_tools/conf/mypy.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ follow_imports = silent
2121
ignore_missing_imports = true
2222

2323
# Non-Google
24-
[mypy-sympy.*,matplotlib.*,proto.*,pandas.*,scipy.*,freezegun.*,mpl_toolkits.*,networkx.*,ply.*,astroid.*,pytest.*,_pytest.*,pylint.*,setuptools.*,qiskit.*,quimb.*,pylatex.*,filelock.*,sortedcontainers.*,tqdm.*,plotly.*,dash.*,tensorflow_docs.*,fxpmath.*,ipywidgets.*,cachetools.*,pydot.*,nbformat.*,nbconvert.*,openfermion.*,pennylane.*,mpmath.*]
24+
[mypy-sympy.*,matplotlib.*,proto.*,pandas.*,scipy.*,freezegun.*,mpl_toolkits.*,networkx.*,ply.*,astroid.*,pytest.*,_pytest.*,pylint.*,setuptools.*,qiskit.*,quimb.*,pylatex.*,filelock.*,sortedcontainers.*,tqdm.*,plotly.*,dash.*,tensorflow_docs.*,fxpmath.*,ipywidgets.*,cachetools.*,pydot.*,nbformat.*,nbconvert.*,openfermion.*,pennylane.*,mpmath.*,jupytext.*]
2525
follow_imports = silent
2626
ignore_missing_imports = true
2727

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ test = [
7979
"pytest-xdist",
8080

8181
# test executing notebooks
82+
"jupytext",
8283
"ipykernel",
8384
"filelock",
8485

qualtran/Adjoint_test.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
17+
from qualtran import testing as qlt_testing
18+
19+
20+
@pytest.mark.notebook
21+
def test_Adjoint_notebook():
22+
qlt_testing.execute_notebook('Adjoint')

qualtran/Autodoc_test.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
17+
from qualtran import testing as qlt_testing
18+
19+
20+
@pytest.mark.notebook
21+
def test_Autodoc_notebook():
22+
qlt_testing.execute_notebook('Autodoc')

qualtran/Controlled_test.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
17+
from qualtran import testing as qlt_testing
18+
19+
20+
@pytest.mark.notebook
21+
def test_Controlled_notebook():
22+
qlt_testing.execute_notebook('Controlled')

qualtran/DataTypes_test.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
17+
from qualtran import testing as qlt_testing
18+
19+
20+
@pytest.mark.notebook
21+
def test_DataTypes_notebook():
22+
qlt_testing.execute_notebook('DataTypes')

qualtran/Protocols_test.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
17+
from qualtran import testing as qlt_testing
18+
19+
20+
@pytest.mark.notebook
21+
def test_Protocols_notebook():
22+
qlt_testing.execute_notebook('Protocols')

0 commit comments

Comments
 (0)