Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ jobs:
pip install -r dev_tools/requirements/envs/pytest.env.txt
pip install --no-deps -e .
- run: |
python dev_tools/execute-notebooks.py --n-workers=8
check/pytest-notebook
env:
NUMBA_NUM_THREADS: 4

Expand Down
20 changes: 20 additions & 0 deletions check/pytest-notebook
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash

################################################################################
# Runs notebook tests via jupytext-based execution.
################################################################################

# Get the working directory to the repo root.
thisdir="$(dirname "${BASH_SOURCE[0]}")" || exit $?
topdir="$(git -C "${thisdir}" rev-parse --show-toplevel)" || exit $?
cd "${topdir}" || exit $?

set -e

python dev_tools/check-notebook-tests.py

# Use non-interactive backend so plt.show() doesn't open GUI windows.
export MPLBACKEND=agg

pytest -v qualtran/ tutorials/ \
-m notebook
149 changes: 149 additions & 0 deletions dev_tools/check-notebook-tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Verify that every committed .ipynb has a corresponding pytest notebook test.

For each committed notebook, checks that:
1. A *_test.py file exists containing an execute_notebook('name') call
2. That test function is decorated with @pytest.mark.notebook

Usage:
python dev_tools/check-notebook-tests.py
"""

import ast
import subprocess
import sys
from pathlib import Path
from typing import cast, Dict, Tuple

from qualtran_dev_tools.git_tools import get_git_root

_EXCLUDED_DIRS = {'dev_tools'}


def get_committed_notebooks(reporoot: Path) -> Dict[str, Path]:
"""Return {stem: relative_path} for all committed .ipynb files under reporoot.

Excludes notebooks in dev_tools/ since those are developer utilities,
not user-facing documentation.
"""
result = subprocess.run(
['git', 'ls-files', '--', '**/*.ipynb'],
capture_output=True,
text=True,
check=True,
cwd=reporoot,
)
return {
Path(f).stem: Path(f)
for f in result.stdout.strip().split('\n')
if f and not any(Path(f).parts[0] == d for d in _EXCLUDED_DIRS)
}


def _is_notebook_marker(decorator: ast.expr) -> bool:
"""Check if a decorator is @pytest.mark.notebook."""
# Handle pytest.mark.notebook (attr chain)
if isinstance(decorator, ast.Attribute) and decorator.attr == 'notebook':
return True
return False


def find_notebook_tests(reporoot: Path) -> Dict[str, Tuple[Path, bool]]:
"""Find all execute_notebook() calls in test files.

Searches all *_test.py files under the repo root (including qualtran/
and tutorials/).

Returns {notebook_name: (test_file_path_relative, has_notebook_marker)}.
"""
results: Dict[str, Tuple[Path, bool]] = {}
for test_file in reporoot.rglob('*_test.py'):
try:
tree = ast.parse(test_file.read_text())
except SyntaxError:
continue

for node in ast.walk(tree):
if not isinstance(node, ast.FunctionDef):
continue
# Check if function body contains execute_notebook('xxx')
for child in ast.walk(node):
if (
isinstance(child, ast.Call)
and _is_execute_notebook_call(child)
and child.args
and isinstance(child.args[0], ast.Constant)
):
nb_name: str = cast(str, child.args[0].value)
# Check for @pytest.mark.notebook decorator
has_marker = any(_is_notebook_marker(dec) for dec in node.decorator_list)
results[nb_name] = (test_file.relative_to(reporoot), has_marker)
return results


def _is_execute_notebook_call(node: ast.Call) -> bool:
"""Check if a Call node is a call to execute_notebook (with or without module prefix)."""
if isinstance(node.func, ast.Attribute) and node.func.attr == 'execute_notebook':
return True
if isinstance(node.func, ast.Name) and node.func.id == 'execute_notebook':
return True
return False


def main():
reporoot = get_git_root()

committed = get_committed_notebooks(reporoot)
tested = find_notebook_tests(reporoot)

errors = []

for stem, nb_rel_path in sorted(committed.items()):
if stem not in tested:
# Suggest the likely test file location
nb_dir = nb_rel_path.parent
test_file = nb_dir / f'{stem}_test.py'
errors.append(
f" MISSING TEST: {nb_rel_path}\n"
f" Add to {test_file}:\n"
f"\n"
f" @pytest.mark.notebook\n"
f" def test_{stem}_notebook():\n"
f" qlt_testing.execute_notebook('{stem}')\n"
)
else:
test_file, has_marker = tested[stem]
if not has_marker:
errors.append(
f" MISSING MARKER: {nb_rel_path}\n"
f" The test in {test_file} calls execute_notebook('{stem}')\n"
f" but is not decorated with @pytest.mark.notebook.\n"
f" Add the decorator so this test runs in the notebooks CI job:\n"
f"\n"
f" @pytest.mark.notebook\n"
f" def test_...():\n"
)

if errors:
print(f"ERROR: {len(errors)} notebook(s) have issues:\n")
print("\n".join(errors))
sys.exit(1)

print(f"OK: All {len(committed)} notebooks have properly marked tests.")


if __name__ == '__main__':
main()
2 changes: 1 addition & 1 deletion dev_tools/conf/mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ follow_imports = silent
ignore_missing_imports = true

# Non-Google
[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.*]
[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.*]
follow_imports = silent
ignore_missing_imports = true

Expand Down
9 changes: 8 additions & 1 deletion dev_tools/requirements/envs/dev.env.txt
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ jupyterlab-server==2.28.0
# notebook
jupyterlab-widgets==3.0.16
# via ipywidgets
jupytext==1.19.1
kiwisolver==1.5.0
# via matplotlib
lark==1.3.1
Expand All @@ -306,6 +307,7 @@ llvmlite==0.47.0
# via numba
markdown-it-py==4.0.0
# via
# jupytext
# mdit-py-plugins
# myst-parser
markupsafe==3.0.3
Expand All @@ -326,7 +328,9 @@ matplotlib-inline==0.2.1
mccabe==0.7.0
# via pylint
mdit-py-plugins==0.5.0
# via myst-parser
# via
# jupytext
# myst-parser
mdurl==0.1.2
# via markdown-it-py
mistune==3.2.0
Expand Down Expand Up @@ -363,6 +367,7 @@ nbformat==5.10.4
# via
# jupyter-cache
# jupyter-server
# jupytext
# myst-nb
# nbclient
# nbconvert
Expand Down Expand Up @@ -425,6 +430,7 @@ packaging==26.0
# jupyter-server
# jupyterlab
# jupyterlab-server
# jupytext
# matplotlib
# nbconvert
# pennylane
Expand Down Expand Up @@ -544,6 +550,7 @@ pyyaml==6.0.3
# via
# jupyter-cache
# jupyter-events
# jupytext
# myst-nb
# myst-parser
# tensorflow-docs
Expand Down
9 changes: 8 additions & 1 deletion dev_tools/requirements/envs/pylint.env.txt
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ jupyterlab-server==2.28.0
# notebook
jupyterlab-widgets==3.0.16
# via ipywidgets
jupytext==1.19.1
kiwisolver==1.5.0
# via matplotlib
lark==1.3.1
Expand All @@ -265,7 +266,9 @@ lark==1.3.1
llvmlite==0.47.0
# via numba
markdown-it-py==4.0.0
# via mdit-py-plugins
# via
# jupytext
# mdit-py-plugins
markupsafe==3.0.3
# via
# flask
Expand All @@ -284,6 +287,7 @@ matplotlib-inline==0.2.1
mccabe==0.7.0
# via pylint
mdit-py-plugins==0.5.0
# via jupytext
mdurl==0.1.2
# via markdown-it-py
mistune==3.2.0
Expand All @@ -307,6 +311,7 @@ nbconvert==7.17.0
nbformat==5.10.4
# via
# jupyter-server
# jupytext
# nbclient
# nbconvert
# qualtran
Expand Down Expand Up @@ -367,6 +372,7 @@ packaging==26.0
# jupyter-server
# jupyterlab
# jupyterlab-server
# jupytext
# matplotlib
# nbconvert
# pennylane
Expand Down Expand Up @@ -467,6 +473,7 @@ pywinpty==3.0.3 ; os_name == 'nt'
pyyaml==6.0.3
# via
# jupyter-events
# jupytext
# tensorflow-docs
pyzmq==27.1.0
# via
Expand Down
15 changes: 14 additions & 1 deletion dev_tools/requirements/envs/pytest.env.txt
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ jupyterlab-server==2.28.0
# notebook
jupyterlab-widgets==3.0.16
# via ipywidgets
jupytext==1.19.1
kiwisolver==1.5.0
# via matplotlib
lark==1.3.1
Expand All @@ -244,6 +245,10 @@ lark==1.3.1
# rfc3987-syntax
llvmlite==0.47.0
# via numba
markdown-it-py==4.0.0
# via
# jupytext
# mdit-py-plugins
markupsafe==3.0.3
# via
# flask
Expand All @@ -259,6 +264,10 @@ matplotlib-inline==0.2.1
# via
# ipykernel
# ipython
mdit-py-plugins==0.5.0
# via jupytext
mdurl==0.1.2
# via markdown-it-py
mistune==3.2.0
# via nbconvert
ml-dtypes==0.5.4
Expand All @@ -280,6 +289,7 @@ nbconvert==7.17.0
nbformat==5.10.4
# via
# jupyter-server
# jupytext
# nbclient
# nbconvert
# qualtran
Expand Down Expand Up @@ -339,6 +349,7 @@ packaging==26.0
# jupyter-server
# jupyterlab
# jupyterlab-server
# jupytext
# matplotlib
# nbconvert
# pennylane
Expand Down Expand Up @@ -439,7 +450,9 @@ pywinpty==3.0.3 ; os_name == 'nt'
# jupyter-server-terminals
# terminado
pyyaml==6.0.3
# via jupyter-events
# via
# jupyter-events
# jupytext
pyzmq==27.1.0
# via
# ipykernel
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ test = [
"pytest-xdist",

# test executing notebooks
"jupytext",
"ipykernel",
"filelock",

Expand All @@ -99,6 +100,7 @@ lint = [
# for checking _test.py files
"pytest",
"openfermion[resources]",
"jupytext",

# dev tools
"tensorflow-docs",
Expand Down
22 changes: 22 additions & 0 deletions qualtran/Adjoint_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import pytest

from qualtran import testing as qlt_testing


@pytest.mark.notebook
def test_Adjoint_notebook():
qlt_testing.execute_notebook('Adjoint')
Loading
Loading