From a081c61a4a90df441045fcf764f3e25cfaa30b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Sat, 20 Dec 2025 10:44:03 +0100 Subject: [PATCH 01/22] create hook checking if notebook executed and without errors --- .pre-commit-config.yaml | 6 +++++ .pre-commit-hooks.yaml | 8 ++++++ .../pre_commit_hooks/notebooks_output.py | 27 +++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 devops_tests/pre_commit_hooks/notebooks_output.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6c29f34..6eef80f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,3 +38,9 @@ repos: args: [--fix-header, --repo-name=devops_tests] language: python types: [jupyter] + + - id: notebooks-output + name: notebooks output + entry: notebooks_output + language: python + types: [jupyter] diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 922a4c2..9a644d7 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -13,3 +13,11 @@ language: python stages: [pre-commit] types: [jupyter] + +- id: notebooks-output + name: notebooks output + description: check if notebooks are executed and without errors and warnings + entry: notebooks_output + language: python + stages: [pre-commit] + types: [jupyter] diff --git a/devops_tests/pre_commit_hooks/notebooks_output.py b/devops_tests/pre_commit_hooks/notebooks_output.py new file mode 100644 index 0000000..0614bdf --- /dev/null +++ b/devops_tests/pre_commit_hooks/notebooks_output.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + + +def test_cell_contains_output(notebook): + """checks if all notebook cells have an output present""" + for cell in notebook.cells: + if cell.cell_type == "code" and cell.source != "": + if cell.execution_count is None: + raise Exception("Cell does not contain output!") + + +def test_no_errors_or_warnings_in_output(notebook): + """checks if all example Jupyter notebooks have clear std-err output + (i.e., no errors or warnings) visible; except acceptable + diagnostics from the joblib package""" + for cell in notebook.cells: + if cell.cell_type == "code": + for output in cell.outputs: + if "name" in output and output["name"] == "stderr": + if not output["text"].startswith("[Parallel(n_jobs="): + raise Exception(output["text"]) + + +if __name__ == "__main__": + raise SystemExit(main()) From 43926da03d662a78fcf9867bd65c11dacce3900a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Sat, 20 Dec 2025 10:48:20 +0100 Subject: [PATCH 02/22] add executable;move to correct dir --- {devops_tests/pre_commit_hooks => hooks}/notebooks_output.py | 0 pyproject.toml | 1 + 2 files changed, 1 insertion(+) rename {devops_tests/pre_commit_hooks => hooks}/notebooks_output.py (100%) diff --git a/devops_tests/pre_commit_hooks/notebooks_output.py b/hooks/notebooks_output.py similarity index 100% rename from devops_tests/pre_commit_hooks/notebooks_output.py rename to hooks/notebooks_output.py diff --git a/pyproject.toml b/pyproject.toml index e3dfb25..d76fbb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,3 +29,4 @@ dynamic = ['version'] [project.scripts] check_notebooks = "hooks.check_notebooks:main" check_badges = "hooks.check_badges:main" +notebooks_output = "hooks.notebooks_output:main" From 7a11f25f405a75848c0709629ce2e7749a2830fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Sat, 20 Dec 2025 19:37:07 +0100 Subject: [PATCH 03/22] add main --- .pre-commit-config.yaml | 2 +- hooks/notebooks_output.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) mode change 100644 => 100755 hooks/notebooks_output.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6eef80f..9a63afc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ default_stages: [pre-commit] repos: - repo: https://github.com/psf/black-pre-commit-mirror - rev: 25.11.0 + rev: 25.12.0 hooks: - id: black diff --git a/hooks/notebooks_output.py b/hooks/notebooks_output.py old mode 100644 new mode 100755 index 0614bdf..2806f88 --- a/hooks/notebooks_output.py +++ b/hooks/notebooks_output.py @@ -23,5 +23,28 @@ def test_no_errors_or_warnings_in_output(notebook): raise Exception(output["text"]) +def main(argv: Sequence[str] | None = None) -> int: + """test all notebooks""" + parser = argparse.ArgumentParser() + parser.add_argument("filenames", nargs="*", help="Filenames to check.") + args = parser.parse_args(argv) + + retval = 0 + test_functions = [ + test_cell_contains_output, + test_no_errors_or_warnings_in_output, + ] + for filename in args.filenames: + with open(filename, encoding="utf8") as notebook_file: + notebook = nbformat.read(notebook_file, nbformat.NO_CONVERT) + for func in test_functions: + try: + func(notebook) + except NotebookTestError as e: + print(f"{filename} : {e}") + retval = 1 + return retval + + if __name__ == "__main__": raise SystemExit(main()) From c3124b0d95e3d2613d0429b34c05a932f94f3be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Sun, 21 Dec 2025 07:44:50 +0100 Subject: [PATCH 04/22] add imports an dependencies; remove doubled code; specify exceptions --- .pre-commit-config.yaml | 10 ++++++---- hooks/check_notebooks.py | 22 ---------------------- hooks/notebooks_output.py | 20 +++++++++++++++----- 3 files changed, 21 insertions(+), 31 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9a63afc..cefde86 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,10 +13,10 @@ repos: - id: end-of-file-fixer - id: debug-statements - - repo: https://github.com/christopher-hacker/enforce-notebook-run-order - rev: 2.1.1 - hooks: - - id: enforce-notebook-run-order +# - repo: https://github.com/christopher-hacker/enforce-notebook-run-order +# rev: 2.1.1 +# hooks: +# - id: enforce-notebook-run-order - repo: local hooks: @@ -42,5 +42,7 @@ repos: - id: notebooks-output name: notebooks output entry: notebooks_output + additional_dependencies: + - nbformat language: python types: [jupyter] diff --git a/hooks/check_notebooks.py b/hooks/check_notebooks.py index 870a124..4f8ef60 100755 --- a/hooks/check_notebooks.py +++ b/hooks/check_notebooks.py @@ -13,26 +13,6 @@ class NotebookTestError(Exception): """Raised when a notebook validation test fails.""" -def test_cell_contains_output(notebook): - """checks if all notebook cells have an output present""" - for cell in notebook.cells: - if cell.cell_type == "code" and cell.source != "": - if cell.execution_count is None: - raise ValueError("Cell does not contain output!") - - -def test_no_errors_or_warnings_in_output(notebook): - """checks if all example Jupyter notebooks have clear std-err output - (i.e., no errors or warnings) visible; except acceptable - diagnostics from the joblib package""" - for cell in notebook.cells: - if cell.cell_type == "code": - for output in cell.outputs: - if "name" in output and output["name"] == "stderr": - if not output["text"].startswith("[Parallel(n_jobs="): - raise ValueError(output["text"]) - - def test_show_plot_used_instead_of_matplotlib(notebook): """checks if plotting is done with open_atmos_jupyter_utils show_plot()""" matplot_used = False @@ -90,8 +70,6 @@ def main(argv: Sequence[str] | None = None) -> int: retval = 0 test_functions = [ - test_cell_contains_output, - test_no_errors_or_warnings_in_output, test_jetbrains_bug_py_66491, test_show_anim_used_instead_of_matplotlib, test_show_plot_used_instead_of_matplotlib, diff --git a/hooks/notebooks_output.py b/hooks/notebooks_output.py index 2806f88..7b58389 100755 --- a/hooks/notebooks_output.py +++ b/hooks/notebooks_output.py @@ -1,14 +1,24 @@ #!/usr/bin/env python3 +"""checks if notebook is executed and do not contain 'stderr""" from __future__ import annotations +import argparse +from collections.abc import Sequence + +import nbformat + + +class NotebookTestError(Exception): + """Raised when a notebook validation test fails.""" + def test_cell_contains_output(notebook): """checks if all notebook cells have an output present""" for cell in notebook.cells: if cell.cell_type == "code" and cell.source != "": if cell.execution_count is None: - raise Exception("Cell does not contain output!") + raise ValueError("Cell does not contain output") def test_no_errors_or_warnings_in_output(notebook): @@ -20,7 +30,7 @@ def test_no_errors_or_warnings_in_output(notebook): for output in cell.outputs: if "name" in output and output["name"] == "stderr": if not output["text"].startswith("[Parallel(n_jobs="): - raise Exception(output["text"]) + raise ValueError(output["text"]) def main(argv: Sequence[str] | None = None) -> int: @@ -31,7 +41,7 @@ def main(argv: Sequence[str] | None = None) -> int: retval = 0 test_functions = [ - test_cell_contains_output, + # test_cell_contains_output, test_no_errors_or_warnings_in_output, ] for filename in args.filenames: @@ -40,8 +50,8 @@ def main(argv: Sequence[str] | None = None) -> int: for func in test_functions: try: func(notebook) - except NotebookTestError as e: - print(f"{filename} : {e}") + except NotebookTestError as err: + print(f"{filename} : {err}") retval = 1 return retval From 11c09115b55b85510127c813576737a7abfbf334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Sun, 21 Dec 2025 08:17:58 +0100 Subject: [PATCH 05/22] update tests; error msg --- hooks/notebooks_output.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/hooks/notebooks_output.py b/hooks/notebooks_output.py index 7b58389..0c3eb01 100755 --- a/hooks/notebooks_output.py +++ b/hooks/notebooks_output.py @@ -15,22 +15,29 @@ class NotebookTestError(Exception): def test_cell_contains_output(notebook): """checks if all notebook cells have an output present""" - for cell in notebook.cells: + for cell_idx, cell in enumerate(notebook.cells): if cell.cell_type == "code" and cell.source != "": if cell.execution_count is None: - raise ValueError("Cell does not contain output") + raise ValueError(f"Cell {cell_idx} does not contain output") def test_no_errors_or_warnings_in_output(notebook): """checks if all example Jupyter notebooks have clear std-err output (i.e., no errors or warnings) visible; except acceptable diagnostics from the joblib package""" - for cell in notebook.cells: + for cell_idx, cell in enumerate(notebook.cells): if cell.cell_type == "code": for output in cell.outputs: - if "name" in output and output["name"] == "stderr": - if not output["text"].startswith("[Parallel(n_jobs="): - raise ValueError(output["text"]) + ot = output.get("output_type") + if ot == "error": + raise ValueError( + f"Cell [{cell_idx}] contain error or warning. \n\n" + f"Cell [{cell_idx}] output:\n{output}\n" + ) + if ot == "stream" and output.get("name") == "stderr": + out_text = output.get("text") + if out_text and not out_text.startswith("[Parallel(n_jobs="): + raise ValueError(f" Cell [{cell_idx}]: {out_text}") def main(argv: Sequence[str] | None = None) -> int: @@ -41,7 +48,7 @@ def main(argv: Sequence[str] | None = None) -> int: retval = 0 test_functions = [ - # test_cell_contains_output, + test_cell_contains_output, test_no_errors_or_warnings_in_output, ] for filename in args.filenames: From cd9fc793da39b6f6c8a0f14c20861a8befff5312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Sun, 21 Dec 2025 08:41:22 +0100 Subject: [PATCH 06/22] define func possible to move to utils; add todo flag --- hooks/check_notebooks.py | 21 ++++++++++++++------- hooks/notebooks_output.py | 23 +++++++++++++++-------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/hooks/check_notebooks.py b/hooks/check_notebooks.py index 4f8ef60..06bddfe 100755 --- a/hooks/check_notebooks.py +++ b/hooks/check_notebooks.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# pylint: disable=duplicate-code #TODO #62 """ Checks notebook execution status for Jupyter notebooks""" from __future__ import annotations @@ -62,18 +63,12 @@ def test_jetbrains_bug_py_66491(notebook): ) -def main(argv: Sequence[str] | None = None) -> int: - """test all notebooks""" +def open_and_test_notebooks(argv, test_functions): parser = argparse.ArgumentParser() parser.add_argument("filenames", nargs="*", help="Filenames to check.") args = parser.parse_args(argv) retval = 0 - test_functions = [ - test_jetbrains_bug_py_66491, - test_show_anim_used_instead_of_matplotlib, - test_show_plot_used_instead_of_matplotlib, - ] for filename in args.filenames: with open(filename, encoding="utf8") as notebook_file: notebook = nbformat.read(notebook_file, nbformat.NO_CONVERT) @@ -86,5 +81,17 @@ def main(argv: Sequence[str] | None = None) -> int: return retval +def main(argv: Sequence[str] | None = None) -> int: + """test all notebooks""" + return open_and_test_notebooks( + argv=argv, + test_functions=[ + test_jetbrains_bug_py_66491, + test_show_anim_used_instead_of_matplotlib, + test_show_plot_used_instead_of_matplotlib, + ], + ) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/hooks/notebooks_output.py b/hooks/notebooks_output.py index 0c3eb01..4a99551 100755 --- a/hooks/notebooks_output.py +++ b/hooks/notebooks_output.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# pylint: disable=duplicate-code #TODO #62 """checks if notebook is executed and do not contain 'stderr""" from __future__ import annotations @@ -40,28 +41,34 @@ def test_no_errors_or_warnings_in_output(notebook): raise ValueError(f" Cell [{cell_idx}]: {out_text}") -def main(argv: Sequence[str] | None = None) -> int: - """test all notebooks""" +def open_and_test_notebooks(argv, test_functions): parser = argparse.ArgumentParser() parser.add_argument("filenames", nargs="*", help="Filenames to check.") args = parser.parse_args(argv) retval = 0 - test_functions = [ - test_cell_contains_output, - test_no_errors_or_warnings_in_output, - ] for filename in args.filenames: with open(filename, encoding="utf8") as notebook_file: notebook = nbformat.read(notebook_file, nbformat.NO_CONVERT) for func in test_functions: try: func(notebook) - except NotebookTestError as err: - print(f"{filename} : {err}") + except NotebookTestError as e: + print(f"{filename} : {e}") retval = 1 return retval +def main(argv: Sequence[str] | None = None) -> int: + """test all notebooks""" + return open_and_test_notebooks( + argv=argv, + test_functions=[ + test_cell_contains_output, + test_no_errors_or_warnings_in_output, + ], + ) + + if __name__ == "__main__": raise SystemExit(main()) From 38c2da18a04a3a8c27bddcd556a367fcf703d9be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Sun, 21 Dec 2025 09:49:37 +0100 Subject: [PATCH 07/22] add docstrings --- hooks/check_notebooks.py | 1 + hooks/notebooks_output.py | 1 + 2 files changed, 2 insertions(+) diff --git a/hooks/check_notebooks.py b/hooks/check_notebooks.py index 06bddfe..bf3f2e1 100755 --- a/hooks/check_notebooks.py +++ b/hooks/check_notebooks.py @@ -64,6 +64,7 @@ def test_jetbrains_bug_py_66491(notebook): def open_and_test_notebooks(argv, test_functions): + """Create argparser and run notebook tests""" parser = argparse.ArgumentParser() parser.add_argument("filenames", nargs="*", help="Filenames to check.") args = parser.parse_args(argv) diff --git a/hooks/notebooks_output.py b/hooks/notebooks_output.py index 4a99551..c2245b9 100755 --- a/hooks/notebooks_output.py +++ b/hooks/notebooks_output.py @@ -42,6 +42,7 @@ def test_no_errors_or_warnings_in_output(notebook): def open_and_test_notebooks(argv, test_functions): + """Create argparser and run notebook tests""" parser = argparse.ArgumentParser() parser.add_argument("filenames", nargs="*", help="Filenames to check.") args = parser.parse_args(argv) From ce985c6e6a881f401d0fad75d1d1df632e0d23db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Sat, 10 Jan 2026 01:52:48 +0100 Subject: [PATCH 08/22] run check-badges only on good examples --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cefde86..00ad8a8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,6 +38,7 @@ repos: args: [--fix-header, --repo-name=devops_tests] language: python types: [jupyter] + files: ^tests/examples/good/ - id: notebooks-output name: notebooks output From 77b8c260c3f5c5a2216a3ca3bd4d28999cafc87a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Sat, 10 Jan 2026 01:55:10 +0100 Subject: [PATCH 09/22] new structure for pre-commit hooks --- .pre-commit-config.yaml | 2 +- test_files/template.ipynb | 61 ---------------- {test_files => tests}/empty.pdf | Bin tests/examples/bad/template.ipynb | 97 +++++++++++++++++++++++++ tests/examples/good/template.ipynb | 113 +++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 62 deletions(-) delete mode 100644 test_files/template.ipynb rename {test_files => tests}/empty.pdf (100%) create mode 100644 tests/examples/bad/template.ipynb create mode 100644 tests/examples/good/template.ipynb diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 00ad8a8..78ff7a7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,7 +38,7 @@ repos: args: [--fix-header, --repo-name=devops_tests] language: python types: [jupyter] - files: ^tests/examples/good/ + files: /tests/examples/good* - id: notebooks-output name: notebooks output diff --git a/test_files/template.ipynb b/test_files/template.ipynb deleted file mode 100644 index be45e76..0000000 --- a/test_files/template.ipynb +++ /dev/null @@ -1,61 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "29186ad9e7311ae0", - "metadata": {}, - "source": [ - "[![preview notebook](https://img.shields.io/static/v1?label=render%20on&logo=github&color=87ce3e&message=GitHub)](https://github.com/open-atmos/devops_tests/blob/main/test_files/template.ipynb)\n", - "[![launch on mybinder.org](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/open-atmos/devops_tests.git/main?urlpath=lab/tree/test_files/template.ipynb)\n", - "[![launch on Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/open-atmos/devops_tests/blob/main/test_files/template.ipynb)" - ] - }, - { - "cell_type": "markdown", - "id": "7a729b2624b70eae", - "metadata": {}, - "source": [] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "72ccd23c0ab9f08e", - "metadata": { - "ExecuteTime": { - "end_time": "2024-10-26T12:29:32.925592Z", - "start_time": "2024-10-26T12:29:32.919920Z" - } - }, - "outputs": [], - "source": [ - "import os, sys\n", - "os.environ['NUMBA_THREADING_LAYER'] = 'workqueue' # PySDM & PyMPDATA don't work with TBB; OpenMP has extra dependencies on macOS\n", - "if 'google.colab' in sys.modules:\n", - " !pip --quiet install open-atmos-jupyter-utils\n", - " from open_atmos_jupyter_utils import pip_install_on_colab\n", - " pip_install_on_colab('devops_tests-examples', 'devops_tests')" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.6" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/test_files/empty.pdf b/tests/empty.pdf similarity index 100% rename from test_files/empty.pdf rename to tests/empty.pdf diff --git a/tests/examples/bad/template.ipynb b/tests/examples/bad/template.ipynb new file mode 100644 index 0000000..a776b5e --- /dev/null +++ b/tests/examples/bad/template.ipynb @@ -0,0 +1,97 @@ +{ + "cells": [ + { + "metadata": {}, + "cell_type": "raw", + "outputs": [], + "execution_count": null, + "source": [ + "{\n", + " \"cells\": [\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"29186ad9e7311ae0\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"[![preview notebook](https://img.shields.io/static/v1?label=render%20on&logo=github&color=87ce3e&message=GitHub)](https://github.com/open-atmos/devops_tests/blob/main/test_files/template.ipynb)\\n\",\n", + " \"[![launch on mybinder.org](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/open-atmos/devops_tests.git/main?urlpath=lab/tree/test_files/template.ipynb)\\n\",\n", + " \"[![launch on Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/open-atmos/devops_tests/blob/main/test_files/template.ipynb)\"\n", + " ]\n", + " },\n", + " {\n", + " \"cell_type\": \"code\",\n", + " \"id\": \"e68c862e7c5918a8\",\n", + " \"metadata\": {\n", + " \"ExecuteTime\": {\n", + " \"end_time\": \"2026-01-08T15:59:58.564837Z\",\n", + " \"start_time\": \"2026-01-08T15:59:58.558338Z\"\n", + " }\n", + " },\n", + " \"source\": [],\n", + " \"outputs\": [],\n", + " \"execution_count\": null\n", + " },\n", + " {\n", + " \"cell_type\": \"code\",\n", + " \"id\": \"cc05fb4d\",\n", + " \"metadata\": {\n", + " \"ExecuteTime\": {\n", + " \"end_time\": \"2026-01-08T15:59:58.581467Z\",\n", + " \"start_time\": \"2026-01-08T15:59:58.577719Z\"\n", + " }\n", + " },\n", + " \"source\": [\n", + " \"import os, sys\\n\",\n", + " \"os.environ['NUMBA_THREADING_LAYER'] = 'workqueue' # PySDM & PyMPDATA don't work with TBB; OpenMP has extra dependencies on macOS\\n\",\n", + " \"if 'google.colab' in sys.modules:\\n\",\n", + " \" !pip --quiet install open-atmos-jupyter-utils\\n\",\n", + " \" from open_atmos_jupyter_utils import pip_install_on_colab\\n\",\n", + " \" pip_install_on_colab('devops_tests-examples', 'devops_tests')\"\n", + " ],\n", + " \"outputs\": [],\n", + " \"execution_count\": 2\n", + " },\n", + " {\n", + " \"metadata\": {\n", + " \"ExecuteTime\": {\n", + " \"end_time\": \"2026-01-08T15:59:58.589848Z\",\n", + " \"start_time\": \"2026-01-08T15:59:58.588360Z\"\n", + " }\n", + " },\n", + " \"cell_type\": \"code\",\n", + " \"source\": \"\",\n", + " \"id\": \"405d8abe602ec659\",\n", + " \"outputs\": [],\n", + " \"execution_count\": null\n", + " }\n", + " ],\n", + " \"metadata\": {\n", + " \"kernelspec\": {\n", + " \"display_name\": \"Python 3 (ipykernel)\",\n", + " \"language\": \"python\",\n", + " \"name\": \"python3\"\n", + " },\n", + " \"language_info\": {\n", + " \"codemirror_mode\": {\n", + " \"name\": \"ipython\",\n", + " \"version\": 3\n", + " },\n", + " \"file_extension\": \".py\",\n", + " \"mimetype\": \"text/x-python\",\n", + " \"name\": \"python\",\n", + " \"nbconvert_exporter\": \"python\",\n", + " \"pygments_lexer\": \"ipython3\",\n", + " \"version\": \"3.10.6\"\n", + " }\n", + " },\n", + " \"nbformat\": 4,\n", + " \"nbformat_minor\": 5\n", + "}\n" + ], + "id": "568b5b03774dfca5" + } + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/examples/good/template.ipynb b/tests/examples/good/template.ipynb new file mode 100644 index 0000000..8307808 --- /dev/null +++ b/tests/examples/good/template.ipynb @@ -0,0 +1,113 @@ +{ + "cells": [ + { + "cell_type": "raw", + "id": "29186ad9e7311ae0", + "metadata": {}, + "source": [ + "{\n", + " \"cells\": [\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"29186ad9e7311ae0\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"[![preview notebook](https://img.shields.io/static/v1?label=render%20on&logo=github&color=87ce3e&message=GitHub)](https://github.com/open-atmos/devops_tests/blob/main/tests/examples/good/template.ipynb)\\n\",\n", + " \"[![launch on mybinder.org](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/open-atmos/devops_tests.git/main?urlpath=lab/tree/tests/examples/good/template.ipynb)\\n\",\n", + " \"[![launch on Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/open-atmos/devops_tests/blob/main/tests/examples/good/template.ipynb)\"\n", + " ]\n", + " },\n", + " {\n", + " \"metadata\": {},\n", + " \"cell_type\": \"markdown\",\n", + " \"source\": \"\",\n", + " \"id\": \"7a729b2624b70eae\"\n", + " },\n", + " {\n", + " \"cell_type\": \"code\",\n", + " \"execution_count\": 1,\n", + " \"id\": \"72ccd23c0ab9f08e\",\n", + " \"metadata\": {\n", + " \"ExecuteTime\": {\n", + " \"end_time\": \"2024-10-26T12:29:32.925592Z\",\n", + " \"start_time\": \"2024-10-26T12:29:32.919920Z\"\n", + " }\n", + " },\n", + " \"outputs\": [],\n", + " \"source\": [\n", + " \"import os, sys\\n\",\n", + " \"os.environ['NUMBA_THREADING_LAYER'] = 'workqueue' # PySDM & PyMPDATA don't work with TBB; OpenMP has extra dependencies on macOS\\n\",\n", + " \"if 'google.colab' in sys.modules:\\n\",\n", + " \" !pip --quiet install open-atmos-jupyter-utils\\n\",\n", + " \" from open_atmos_jupyter_utils import pip_install_on_colab\\n\",\n", + " \" pip_install_on_colab('devops_tests-examples', 'devops_tests')\"\n", + " ]\n", + " }\n", + " ],\n", + " \"metadata\": {\n", + " \"kernelspec\": {\n", + " \"display_name\": \"Python 3 (ipykernel)\",\n", + " \"language\": \"python\",\n", + " \"name\": \"python3\"\n", + " },\n", + " \"language_info\": {\n", + " \"codemirror_mode\": {\n", + " \"name\": \"ipython\",\n", + " \"version\": 3\n", + " },\n", + " \"file_extension\": \".py\",\n", + " \"mimetype\": \"text/x-python\",\n", + " \"name\": \"python\",\n", + " \"nbconvert_exporter\": \"python\",\n", + " \"pygments_lexer\": \"ipython3\",\n", + " \"version\": \"3.10.6\"\n", + " }\n", + " },\n", + " \"nbformat\": 4,\n", + " \"nbformat_minor\": 5\n", + "}\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "72ccd23c0ab9f08e", + "metadata": { + "ExecuteTime": { + "end_time": "2024-10-26T12:29:32.925592Z", + "start_time": "2024-10-26T12:29:32.919920Z" + } + }, + "outputs": [], + "source": [ + "import os, sys\n", + "os.environ['NUMBA_THREADING_LAYER'] = 'workqueue' # PySDM & PyMPDATA don't work with TBB; OpenMP has extra dependencies on macOS\n", + "if 'google.colab' in sys.modules:\n", + " !pip --quiet install open-atmos-jupyter-utils\n", + " from open_atmos_jupyter_utils import pip_install_on_colab\n", + " pip_install_on_colab('devops_tests-examples', 'devops_tests')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 3f0c2276f45d968170b539773d11ad618c656e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Sat, 10 Jan 2026 02:04:23 +0100 Subject: [PATCH 10/22] fix notebooks --- .pre-commit-config.yaml | 2 +- tests/examples/bad/template.ipynb | 154 +++++++++++++---------------- tests/examples/good/template.ipynb | 97 ++++++------------ 3 files changed, 99 insertions(+), 154 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 78ff7a7..136163c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,7 +38,7 @@ repos: args: [--fix-header, --repo-name=devops_tests] language: python types: [jupyter] - files: /tests/examples/good* + files: tests/examples/good/ - id: notebooks-output name: notebooks output diff --git a/tests/examples/bad/template.ipynb b/tests/examples/bad/template.ipynb index a776b5e..e8f0758 100644 --- a/tests/examples/bad/template.ipynb +++ b/tests/examples/bad/template.ipynb @@ -1,97 +1,81 @@ { "cells": [ { + "cell_type": "markdown", + "id": "29186ad9e7311ae0", "metadata": {}, - "cell_type": "raw", + "source": [ + "[![preview notebook](https://img.shields.io/static/v1?label=render%20on&logo=github&color=87ce3e&message=GitHub)](https://github.com/open-atmos/devops_tests/blob/main/test_files/template.ipynb)\n", + "[![launch on mybinder.org](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/open-atmos/devops_tests.git/main?urlpath=lab/tree/test_files/template.ipynb)\n", + "[![launch on Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/open-atmos/devops_tests/blob/main/test_files/template.ipynb)" + ] + }, + { + "cell_type": "code", + "id": "e68c862e7c5918a8", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-10T01:02:24.169040Z", + "start_time": "2026-01-10T01:02:24.163175Z" + } + }, + "source": [], "outputs": [], - "execution_count": null, + "execution_count": null + }, + { + "cell_type": "code", + "id": "cc05fb4d", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-10T01:02:24.187099Z", + "start_time": "2026-01-10T01:02:24.179982Z" + } + }, "source": [ - "{\n", - " \"cells\": [\n", - " {\n", - " \"cell_type\": \"markdown\",\n", - " \"id\": \"29186ad9e7311ae0\",\n", - " \"metadata\": {},\n", - " \"source\": [\n", - " \"[![preview notebook](https://img.shields.io/static/v1?label=render%20on&logo=github&color=87ce3e&message=GitHub)](https://github.com/open-atmos/devops_tests/blob/main/test_files/template.ipynb)\\n\",\n", - " \"[![launch on mybinder.org](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/open-atmos/devops_tests.git/main?urlpath=lab/tree/test_files/template.ipynb)\\n\",\n", - " \"[![launch on Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/open-atmos/devops_tests/blob/main/test_files/template.ipynb)\"\n", - " ]\n", - " },\n", - " {\n", - " \"cell_type\": \"code\",\n", - " \"id\": \"e68c862e7c5918a8\",\n", - " \"metadata\": {\n", - " \"ExecuteTime\": {\n", - " \"end_time\": \"2026-01-08T15:59:58.564837Z\",\n", - " \"start_time\": \"2026-01-08T15:59:58.558338Z\"\n", - " }\n", - " },\n", - " \"source\": [],\n", - " \"outputs\": [],\n", - " \"execution_count\": null\n", - " },\n", - " {\n", - " \"cell_type\": \"code\",\n", - " \"id\": \"cc05fb4d\",\n", - " \"metadata\": {\n", - " \"ExecuteTime\": {\n", - " \"end_time\": \"2026-01-08T15:59:58.581467Z\",\n", - " \"start_time\": \"2026-01-08T15:59:58.577719Z\"\n", - " }\n", - " },\n", - " \"source\": [\n", - " \"import os, sys\\n\",\n", - " \"os.environ['NUMBA_THREADING_LAYER'] = 'workqueue' # PySDM & PyMPDATA don't work with TBB; OpenMP has extra dependencies on macOS\\n\",\n", - " \"if 'google.colab' in sys.modules:\\n\",\n", - " \" !pip --quiet install open-atmos-jupyter-utils\\n\",\n", - " \" from open_atmos_jupyter_utils import pip_install_on_colab\\n\",\n", - " \" pip_install_on_colab('devops_tests-examples', 'devops_tests')\"\n", - " ],\n", - " \"outputs\": [],\n", - " \"execution_count\": 2\n", - " },\n", - " {\n", - " \"metadata\": {\n", - " \"ExecuteTime\": {\n", - " \"end_time\": \"2026-01-08T15:59:58.589848Z\",\n", - " \"start_time\": \"2026-01-08T15:59:58.588360Z\"\n", - " }\n", - " },\n", - " \"cell_type\": \"code\",\n", - " \"source\": \"\",\n", - " \"id\": \"405d8abe602ec659\",\n", - " \"outputs\": [],\n", - " \"execution_count\": null\n", - " }\n", - " ],\n", - " \"metadata\": {\n", - " \"kernelspec\": {\n", - " \"display_name\": \"Python 3 (ipykernel)\",\n", - " \"language\": \"python\",\n", - " \"name\": \"python3\"\n", - " },\n", - " \"language_info\": {\n", - " \"codemirror_mode\": {\n", - " \"name\": \"ipython\",\n", - " \"version\": 3\n", - " },\n", - " \"file_extension\": \".py\",\n", - " \"mimetype\": \"text/x-python\",\n", - " \"name\": \"python\",\n", - " \"nbconvert_exporter\": \"python\",\n", - " \"pygments_lexer\": \"ipython3\",\n", - " \"version\": \"3.10.6\"\n", - " }\n", - " },\n", - " \"nbformat\": 4,\n", - " \"nbformat_minor\": 5\n", - "}\n" + "import os, sys\n", + "os.environ['NUMBA_THREADING_LAYER'] = 'workqueue' # PySDM & PyMPDATA don't work with TBB; OpenMP has extra dependencies on macOS\n", + "if 'google.colab' in sys.modules:\n", + " !pip --quiet install open-atmos-jupyter-utils\n", + " from open_atmos_jupyter_utils import pip_install_on_colab\n", + " pip_install_on_colab('devops_tests-examples', 'devops_tests')" ], - "id": "568b5b03774dfca5" + "outputs": [], + "execution_count": 3 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-10T01:02:24.193663Z", + "start_time": "2026-01-10T01:02:24.192050Z" + } + }, + "cell_type": "code", + "source": "", + "id": "405d8abe602ec659", + "outputs": [], + "execution_count": null } ], - "metadata": {}, + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.6" + } + }, "nbformat": 4, "nbformat_minor": 5 } diff --git a/tests/examples/good/template.ipynb b/tests/examples/good/template.ipynb index 8307808..f268d56 100644 --- a/tests/examples/good/template.ipynb +++ b/tests/examples/good/template.ipynb @@ -1,84 +1,30 @@ { "cells": [ { - "cell_type": "raw", + "cell_type": "markdown", "id": "29186ad9e7311ae0", "metadata": {}, "source": [ - "{\n", - " \"cells\": [\n", - " {\n", - " \"cell_type\": \"markdown\",\n", - " \"id\": \"29186ad9e7311ae0\",\n", - " \"metadata\": {},\n", - " \"source\": [\n", - " \"[![preview notebook](https://img.shields.io/static/v1?label=render%20on&logo=github&color=87ce3e&message=GitHub)](https://github.com/open-atmos/devops_tests/blob/main/tests/examples/good/template.ipynb)\\n\",\n", - " \"[![launch on mybinder.org](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/open-atmos/devops_tests.git/main?urlpath=lab/tree/tests/examples/good/template.ipynb)\\n\",\n", - " \"[![launch on Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/open-atmos/devops_tests/blob/main/tests/examples/good/template.ipynb)\"\n", - " ]\n", - " },\n", - " {\n", - " \"metadata\": {},\n", - " \"cell_type\": \"markdown\",\n", - " \"source\": \"\",\n", - " \"id\": \"7a729b2624b70eae\"\n", - " },\n", - " {\n", - " \"cell_type\": \"code\",\n", - " \"execution_count\": 1,\n", - " \"id\": \"72ccd23c0ab9f08e\",\n", - " \"metadata\": {\n", - " \"ExecuteTime\": {\n", - " \"end_time\": \"2024-10-26T12:29:32.925592Z\",\n", - " \"start_time\": \"2024-10-26T12:29:32.919920Z\"\n", - " }\n", - " },\n", - " \"outputs\": [],\n", - " \"source\": [\n", - " \"import os, sys\\n\",\n", - " \"os.environ['NUMBA_THREADING_LAYER'] = 'workqueue' # PySDM & PyMPDATA don't work with TBB; OpenMP has extra dependencies on macOS\\n\",\n", - " \"if 'google.colab' in sys.modules:\\n\",\n", - " \" !pip --quiet install open-atmos-jupyter-utils\\n\",\n", - " \" from open_atmos_jupyter_utils import pip_install_on_colab\\n\",\n", - " \" pip_install_on_colab('devops_tests-examples', 'devops_tests')\"\n", - " ]\n", - " }\n", - " ],\n", - " \"metadata\": {\n", - " \"kernelspec\": {\n", - " \"display_name\": \"Python 3 (ipykernel)\",\n", - " \"language\": \"python\",\n", - " \"name\": \"python3\"\n", - " },\n", - " \"language_info\": {\n", - " \"codemirror_mode\": {\n", - " \"name\": \"ipython\",\n", - " \"version\": 3\n", - " },\n", - " \"file_extension\": \".py\",\n", - " \"mimetype\": \"text/x-python\",\n", - " \"name\": \"python\",\n", - " \"nbconvert_exporter\": \"python\",\n", - " \"pygments_lexer\": \"ipython3\",\n", - " \"version\": \"3.10.6\"\n", - " }\n", - " },\n", - " \"nbformat\": 4,\n", - " \"nbformat_minor\": 5\n", - "}\n" + "[![preview notebook](https://img.shields.io/static/v1?label=render%20on&logo=github&color=87ce3e&message=GitHub)](https://github.com/open-atmos/devops_tests/blob/main/tests/examples/good/template.ipynb)\n", + "[![launch on mybinder.org](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/open-atmos/devops_tests.git/main?urlpath=lab/tree/tests/examples/good/template.ipynb)\n", + "[![launch on Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/open-atmos/devops_tests/blob/main/tests/examples/good/template.ipynb)" ] }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Notebook description", + "id": "59bad8ebb84ac4ce" + }, { "cell_type": "code", - "execution_count": 1, - "id": "72ccd23c0ab9f08e", + "id": "cc05fb4d", "metadata": { "ExecuteTime": { - "end_time": "2024-10-26T12:29:32.925592Z", - "start_time": "2024-10-26T12:29:32.919920Z" + "end_time": "2026-01-10T01:02:24.187099Z", + "start_time": "2026-01-10T01:02:24.179982Z" } }, - "outputs": [], "source": [ "import os, sys\n", "os.environ['NUMBA_THREADING_LAYER'] = 'workqueue' # PySDM & PyMPDATA don't work with TBB; OpenMP has extra dependencies on macOS\n", @@ -86,7 +32,22 @@ " !pip --quiet install open-atmos-jupyter-utils\n", " from open_atmos_jupyter_utils import pip_install_on_colab\n", " pip_install_on_colab('devops_tests-examples', 'devops_tests')" - ] + ], + "outputs": [], + "execution_count": 3 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-10T01:02:24.193663Z", + "start_time": "2026-01-10T01:02:24.192050Z" + } + }, + "cell_type": "code", + "source": "", + "id": "405d8abe602ec659", + "outputs": [], + "execution_count": null } ], "metadata": { From a474342c3e75f2dbf088a66ebf45071262c6ccdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Sat, 10 Jan 2026 10:10:06 +0100 Subject: [PATCH 11/22] change logic of checked files --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 136163c..66e6d28 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,7 +38,7 @@ repos: args: [--fix-header, --repo-name=devops_tests] language: python types: [jupyter] - files: tests/examples/good/ + exclude: tests/examples/bad/ - id: notebooks-output name: notebooks output From 0cdcbb5261c77319728a970f96f7a926a386fe5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Thu, 15 Jan 2026 16:56:07 +0100 Subject: [PATCH 12/22] refactor hooks using copilot --- .pre-commit-config.yaml | 2 +- hooks/check_badges.py | 287 +++++++++++++------------ hooks/check_notebooks.py | 65 +----- hooks/notebooks_output.py | 28 +-- hooks/notebooks_using_jupyter_utils.py | 98 +++++++++ hooks/utils.py | 26 ++- 6 files changed, 276 insertions(+), 230 deletions(-) create mode 100644 hooks/notebooks_using_jupyter_utils.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 66e6d28..34f4a6b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,7 +38,7 @@ repos: args: [--fix-header, --repo-name=devops_tests] language: python types: [jupyter] - exclude: tests/examples/bad/ + exclude: tests/examples/bad.ipynb - id: notebooks-output name: notebooks output diff --git a/hooks/check_badges.py b/hooks/check_badges.py index dc6f19a..870aa34 100755 --- a/hooks/check_badges.py +++ b/hooks/check_badges.py @@ -1,185 +1,198 @@ #!/usr/bin/env python3 -# pylint: disable=missing-function-docstring """ -Checks whether notebooks contain badges.""" +Checks/repairs notebook badge headers. + +This module validates that a notebook's first cell contains the three canonical +badges (GitHub preview, MyBinder, Colab). It tolerates whitespace differences and +badge order, and can optionally fix the header in-place. + +Usage: + check_badges --repo-name=devops_tests [--repo-owner=open-atmos] [--fix-header] FILES... + +The functions are written to be easily unit-tested. +""" from __future__ import annotations import argparse from collections.abc import Sequence +from pathlib import Path +from typing import Iterable, List, Tuple import nbformat +from nbformat import NotebookNode +REPO_OWNER_DEFAULT = "open-atmos" -def _header_cell_text(repo_name, version): - if version is None: - version = "" - return f"""import os, sys -os.environ['NUMBA_THREADING_LAYER'] = 'workqueue' # PySDM & PyMPDATA don't work with TBB; OpenMP has extra dependencies on macOS -if 'google.colab' in sys.modules: - !pip --quiet install open-atmos-jupyter-utils - from open_atmos_jupyter_utils import pip_install_on_colab - pip_install_on_colab('{repo_name}-examples{version}', '{repo_name}{version}')""" - - -HEADER_KEY_PATTERNS = [ - "install open-atmos-jupyter-utils", - "google.colab", - "pip_install_on_colab", -] - - -def is_colab_header(cell_source: str) -> bool: - """Return True if the cell looks like a Colab header.""" - return all(pat in cell_source for pat in HEADER_KEY_PATTERNS) - - -def check_colab_header(notebook_path, repo_name, fix, version): - """Check Colab-magic cell and fix if is misspelled, in wrong position or not exists""" - nb = nbformat.read(notebook_path, as_version=nbformat.NO_CONVERT) - - header_index = None - correct_header = _header_cell_text(repo_name, version) - modified = False - - if not fix: - if nb.cells[2].cell_type != "code" or nb.cells[2].source != correct_header: - raise ValueError("Third cell does not contain correct header") - return modified - for idx, cell in enumerate(nb.cells): - if cell.cell_type == "code" and is_colab_header(cell.source): - header_index = idx - break - - if header_index is not None: - if nb.cells[header_index].source != correct_header: - nb.cells[header_index].source = correct_header - modified = True - if header_index != 2: - nb.cells.insert(2, nb.cells.pop(header_index)) - modified = True - else: - new_cell = nbformat.v4.new_code_cell(correct_header) - nb.cells.insert(2, new_cell) - modified = True - if modified: - nbformat.write(nb, notebook_path) - return modified - - -def print_hook_summary(reformatted_files, unchanged_files): - """Print a Black-style summary.""" - for f in reformatted_files: - print(f"\nreformatted {f}") - - total_ref = len(reformatted_files) - total_unchanged = len(unchanged_files) - if total_ref > 0: - print("\nAll done! ✨ 🍰 ✨") - print( - f"{total_ref} file{'s' if total_ref != 1 else ''} reformatted, " - f"{total_unchanged} file{'s' if total_unchanged != 1 else ''} left unchanged." - ) - - -def _preview_badge_markdown(absolute_path, repo_name): +def _preview_badge_markdown(absolute_path: str, repo_name: str, repo_owner: str) -> str: svg_badge_url = ( "https://img.shields.io/static/v1?" + "label=render%20on&logo=github&color=87ce3e&message=GitHub" ) - link = f"https://github.com/open-atmos/{repo_name}/blob/main/" + f"{absolute_path}" + link = f"https://github.com/{repo_owner}/{repo_name}/blob/main/{absolute_path}" return f"[![preview notebook]({svg_badge_url})]({link})" -def _mybinder_badge_markdown(absolute_path, repo_name): +def _mybinder_badge_markdown( + absolute_path: str, repo_name: str, repo_owner: str +) -> str: svg_badge_url = "https://mybinder.org/badge_logo.svg" link = ( - f"https://mybinder.org/v2/gh/open-atmos/{repo_name}.git/main?urlpath=lab/tree/" + f"https://mybinder.org/v2/gh/{repo_owner}/{repo_name}.git/main?urlpath=lab/tree/" + f"{absolute_path}" ) return f"[![launch on mybinder.org]({svg_badge_url})]({link})" -def _colab_badge_markdown(absolute_path, repo_name): +def _colab_badge_markdown(absolute_path: str, repo_name: str, repo_owner: str) -> str: svg_badge_url = "https://colab.research.google.com/assets/colab-badge.svg" link = ( - f"https://colab.research.google.com/github/open-atmos/{repo_name}/blob/main/" + f"https://colab.research.google.com/github/{repo_owner}/{repo_name}/blob/main/" + f"{absolute_path}" ) return f"[![launch on Colab]({svg_badge_url})]({link})" -def test_notebook_has_at_least_three_cells(notebook_filename): +def expected_badges_for( + notebook_path: Path, repo_name: str, repo_owner: str +) -> List[str]: + """ + Return the canonical badge lines expected for notebook_path. + The notebook_path is used to build the URL path; we convert it to a + repo-relative posix path (best-effort). + """ + # Build a repo-relative path: try to strip cwd if notebook is inside repo + try: + rel = notebook_path.relative_to(Path.cwd()) + except Exception: + rel = notebook_path + absolute_path = rel.as_posix() + return [ + _preview_badge_markdown(absolute_path, repo_name, repo_owner), + _mybinder_badge_markdown(absolute_path, repo_name, repo_owner), + _colab_badge_markdown(absolute_path, repo_name, repo_owner), + ] + + +def read_notebook(path: Path) -> NotebookNode: + with path.open(encoding="utf8") as fp: + return nbformat.read(fp, nbformat.NO_CONVERT) + + +def write_notebook(path: Path, nb: NotebookNode) -> None: + with path.open("w", encoding="utf8") as fp: + nbformat.write(nb, fp) + + +def first_cell_lines(nb: NotebookNode) -> List[str]: + """Return list of stripped lines from the first cell if it's markdown, else []""" + if not nb.cells: + return [] + first = nb.cells[0] + if first.cell_type != "markdown": + return [] + # split preserving order, strip each line + return [ln.strip() for ln in str(first.source).splitlines() if ln.strip() != ""] + + +def badges_match( + actual_lines: Iterable[str], expected_lines: Iterable[str] +) -> Tuple[bool, str]: + """ + Check whether the expected badge lines are present in actual_lines. + Tolerant: ignores order, strips whitespace. + Returns (matches, message). Message empty on match else explains which badges missing. + """ + actual_set = {ln.strip() for ln in actual_lines} + expected_list = list(expected_lines) + missing = [exp for exp in expected_list if exp.strip() not in actual_set] + if not missing: + return True, "" + return False, f"Missing badges: {missing}" + + +def test_notebook_has_at_least_three_cells(notebook_filename: str) -> None: """checks if all notebooks have at least three cells""" - with open(notebook_filename, encoding="utf8") as fp: - nb = nbformat.read(fp, nbformat.NO_CONVERT) - if len(nb.cells) < 3: - raise ValueError("Notebook should have at least 4 cells") - - -def test_first_cell_contains_three_badges(notebook_filename, repo_name): - """checks if all notebooks feature three badges in the first cell""" - with open(notebook_filename, encoding="utf8") as fp: - nb = nbformat.read(fp, nbformat.NO_CONVERT) - if nb.cells[0].cell_type != "markdown": - raise ValueError("First cell is not a markdown cell") - lines = nb.cells[0].source.split("\n") - if len(lines) != 3: - raise ValueError("First cell does not contain exactly 3 lines (badges)") - if lines[0] != _preview_badge_markdown(notebook_filename, repo_name): - raise ValueError("First badge does not match Github preview badge") - if lines[1] != _mybinder_badge_markdown(notebook_filename, repo_name): - raise ValueError("Second badge does not match MyBinder badge") - if lines[2] != _colab_badge_markdown(notebook_filename, repo_name): - raise ValueError("Third badge does not match Colab badge") - - -def test_second_cell_is_a_markdown_cell(notebook_filename): - """checks if all notebooks have their second cell with some markdown - (hopefully clarifying what the example is about)""" - with open(notebook_filename, encoding="utf8") as fp: - nb = nbformat.read(fp, nbformat.NO_CONVERT) + nb = read_notebook(Path(notebook_filename)) + if len(nb.cells) < 3: + raise ValueError("Notebook should have at least 3 cells") + + +def test_first_cell_contains_three_badges( + notebook_filename: str, repo_name: str, repo_owner: str = REPO_OWNER_DEFAULT +) -> None: + """ + checks if the notebook's first cell contains the three badges. + Raises ValueError on failure. + """ + nb = read_notebook(Path(notebook_filename)) + lines = first_cell_lines(nb) + expected = expected_badges_for(Path(notebook_filename), repo_name, repo_owner) + ok, msg = badges_match(lines, expected) + if not ok: + raise ValueError(msg) + + +def test_second_cell_is_a_markdown_cell(notebook_filename: str) -> None: + """checks if all notebooks have their second cell as markdown""" + nb = read_notebook(Path(notebook_filename)) + if len(nb.cells) < 2: + raise ValueError("Notebook has no second cell") if nb.cells[1].cell_type != "markdown": raise ValueError("Second cell is not a markdown cell") +def fix_header_inplace( + path: Path, repo_name: str, repo_owner: str = REPO_OWNER_DEFAULT +) -> None: + """ + Replace the first cell with the canonical 3-badge header if the header is missing + or malformed. If the notebook has no cells, a new markdown cell is inserted. + """ + nb = read_notebook(path) + expected = expected_badges_for(path, repo_name, repo_owner) + # Build markdown with one badge per line + new_first = {"cell_type": "markdown", "metadata": {}, "source": "\n".join(expected)} + if not nb.cells: + nb.cells.insert(0, nbformat.from_dict(new_first)) + else: + nb.cells[0] = nbformat.from_dict(new_first) + write_notebook(path, nb) + + def main(argv: Sequence[str] | None = None) -> int: - """collect failed notebook checks""" parser = argparse.ArgumentParser() - parser.add_argument("--repo-name") - parser.add_argument("--fix-header", action="store_true") - parser.add_argument("--pip-install-on-colab-version") + parser.add_argument("--repo-name", required=True) + parser.add_argument("--repo-owner", default=REPO_OWNER_DEFAULT) + parser.add_argument( + "--fix-header", + action="store_true", + help="If set, attempt to fix notebooks missing the header.", + ) parser.add_argument("filenames", nargs="*", help="Filenames to check.") args = parser.parse_args(argv) - failed_files = False - reformatted_files = [] - unchanged_files = [] + + retval = 0 for filename in args.filenames: - try: - modified = check_colab_header( - filename, - repo_name=args.repo_name, - fix=args.fix_header, - version=args.pip_install_on_colab_version, - ) - if modified: - reformatted_files.append(str(filename)) - else: - unchanged_files.append(str(filename)) - except ValueError as exc: - print(f"[ERROR] {filename}: {exc}") - failed_files = True + path = Path(filename) try: test_notebook_has_at_least_three_cells(filename) - test_first_cell_contains_three_badges(filename, repo_name=args.repo_name) + test_first_cell_contains_three_badges( + filename, args.repo_name, args.repo_owner + ) test_second_cell_is_a_markdown_cell(filename) - - except ValueError as exc: - print(f"[ERROR] {filename}: {exc}") - failed_files = True - - print_hook_summary(reformatted_files, unchanged_files) - return 1 if (reformatted_files or failed_files) else 0 + except Exception as exc: + print(f"{filename}: {exc}") + retval = 1 + if args.fix_header: + try: + fix_header_inplace(path, args.repo_name, args.repo_owner) + print(f"{filename}: header fixed") + retval = 0 + except Exception as fix_exc: + print(f"{filename}: failed to fix header: {fix_exc}") + retval = 2 + return retval if __name__ == "__main__": diff --git a/hooks/check_notebooks.py b/hooks/check_notebooks.py index bf3f2e1..f7c76b3 100755 --- a/hooks/check_notebooks.py +++ b/hooks/check_notebooks.py @@ -1,56 +1,14 @@ #!/usr/bin/env python3 -# pylint: disable=duplicate-code #TODO #62 -""" -Checks notebook execution status for Jupyter notebooks""" from __future__ import annotations -import argparse from collections.abc import Sequence - -import nbformat +from .utils import open_and_test_notebooks class NotebookTestError(Exception): """Raised when a notebook validation test fails.""" -def test_show_plot_used_instead_of_matplotlib(notebook): - """checks if plotting is done with open_atmos_jupyter_utils show_plot()""" - matplot_used = False - show_plot_used = False - for cell in notebook.cells: - if cell.cell_type == "code": - if "pyplot.show(" in cell.source or "plt.show(" in cell.source: - matplot_used = True - if "show_plot(" in cell.source: - show_plot_used = True - if matplot_used and not show_plot_used: - raise ValueError( - "if using matplotlib, please use open_atmos_jupyter_utils.show_plot()" - ) - - -def test_show_anim_used_instead_of_matplotlib(notebook): - """checks if animation generation is done with open_atmos_jupyter_utils show_anim()""" - matplot_used = False - show_anim_used = False - for cell in notebook.cells: - if cell.cell_type == "code": - if ( - "funcAnimation" in cell.source - or "matplotlib.animation" in cell.source - or "from matplotlib import animation" in cell.source - ): - matplot_used = True - if "show_anim(" in cell.source: - show_anim_used = True - if matplot_used and not show_anim_used: - raise AssertionError( - """if using matplotlib for animations, - please use open_atmos_jupyter_utils.show_anim()""" - ) - - def test_jetbrains_bug_py_66491(notebook): """checks if all notebook have the execution_count key for each cell in JSON, which is required by GitHub renderer and may not be generated by some buggy PyCharm versions: @@ -63,33 +21,12 @@ def test_jetbrains_bug_py_66491(notebook): ) -def open_and_test_notebooks(argv, test_functions): - """Create argparser and run notebook tests""" - parser = argparse.ArgumentParser() - parser.add_argument("filenames", nargs="*", help="Filenames to check.") - args = parser.parse_args(argv) - - retval = 0 - for filename in args.filenames: - with open(filename, encoding="utf8") as notebook_file: - notebook = nbformat.read(notebook_file, nbformat.NO_CONVERT) - for func in test_functions: - try: - func(notebook) - except NotebookTestError as e: - print(f"{filename} : {e}") - retval = 1 - return retval - - def main(argv: Sequence[str] | None = None) -> int: """test all notebooks""" return open_and_test_notebooks( argv=argv, test_functions=[ test_jetbrains_bug_py_66491, - test_show_anim_used_instead_of_matplotlib, - test_show_plot_used_instead_of_matplotlib, ], ) diff --git a/hooks/notebooks_output.py b/hooks/notebooks_output.py index c2245b9..9a75274 100755 --- a/hooks/notebooks_output.py +++ b/hooks/notebooks_output.py @@ -1,17 +1,10 @@ #!/usr/bin/env python3 -# pylint: disable=duplicate-code #TODO #62 """checks if notebook is executed and do not contain 'stderr""" from __future__ import annotations -import argparse from collections.abc import Sequence - -import nbformat - - -class NotebookTestError(Exception): - """Raised when a notebook validation test fails.""" +from .utils import open_and_test_notebooks, NotebookTestError def test_cell_contains_output(notebook): @@ -41,25 +34,6 @@ def test_no_errors_or_warnings_in_output(notebook): raise ValueError(f" Cell [{cell_idx}]: {out_text}") -def open_and_test_notebooks(argv, test_functions): - """Create argparser and run notebook tests""" - parser = argparse.ArgumentParser() - parser.add_argument("filenames", nargs="*", help="Filenames to check.") - args = parser.parse_args(argv) - - retval = 0 - for filename in args.filenames: - with open(filename, encoding="utf8") as notebook_file: - notebook = nbformat.read(notebook_file, nbformat.NO_CONVERT) - for func in test_functions: - try: - func(notebook) - except NotebookTestError as e: - print(f"{filename} : {e}") - retval = 1 - return retval - - def main(argv: Sequence[str] | None = None) -> int: """test all notebooks""" return open_and_test_notebooks( diff --git a/hooks/notebooks_using_jupyter_utils.py b/hooks/notebooks_using_jupyter_utils.py new file mode 100644 index 0000000..bf3f2e1 --- /dev/null +++ b/hooks/notebooks_using_jupyter_utils.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# pylint: disable=duplicate-code #TODO #62 +""" +Checks notebook execution status for Jupyter notebooks""" +from __future__ import annotations + +import argparse +from collections.abc import Sequence + +import nbformat + + +class NotebookTestError(Exception): + """Raised when a notebook validation test fails.""" + + +def test_show_plot_used_instead_of_matplotlib(notebook): + """checks if plotting is done with open_atmos_jupyter_utils show_plot()""" + matplot_used = False + show_plot_used = False + for cell in notebook.cells: + if cell.cell_type == "code": + if "pyplot.show(" in cell.source or "plt.show(" in cell.source: + matplot_used = True + if "show_plot(" in cell.source: + show_plot_used = True + if matplot_used and not show_plot_used: + raise ValueError( + "if using matplotlib, please use open_atmos_jupyter_utils.show_plot()" + ) + + +def test_show_anim_used_instead_of_matplotlib(notebook): + """checks if animation generation is done with open_atmos_jupyter_utils show_anim()""" + matplot_used = False + show_anim_used = False + for cell in notebook.cells: + if cell.cell_type == "code": + if ( + "funcAnimation" in cell.source + or "matplotlib.animation" in cell.source + or "from matplotlib import animation" in cell.source + ): + matplot_used = True + if "show_anim(" in cell.source: + show_anim_used = True + if matplot_used and not show_anim_used: + raise AssertionError( + """if using matplotlib for animations, + please use open_atmos_jupyter_utils.show_anim()""" + ) + + +def test_jetbrains_bug_py_66491(notebook): + """checks if all notebook have the execution_count key for each cell in JSON, + which is required by GitHub renderer and may not be generated by some buggy PyCharm versions: + https://youtrack.jetbrains.com/issue/PY-66491""" + for cell in notebook.cells: + if cell.cell_type == "code" and not hasattr(cell, "execution_count"): + raise ValueError( + "Notebook cell missing execution_count attribute. " + "(May be due to PyCharm bug, see: https://youtrack.jetbrains.com/issue/PY-66491 )" + ) + + +def open_and_test_notebooks(argv, test_functions): + """Create argparser and run notebook tests""" + parser = argparse.ArgumentParser() + parser.add_argument("filenames", nargs="*", help="Filenames to check.") + args = parser.parse_args(argv) + + retval = 0 + for filename in args.filenames: + with open(filename, encoding="utf8") as notebook_file: + notebook = nbformat.read(notebook_file, nbformat.NO_CONVERT) + for func in test_functions: + try: + func(notebook) + except NotebookTestError as e: + print(f"{filename} : {e}") + retval = 1 + return retval + + +def main(argv: Sequence[str] | None = None) -> int: + """test all notebooks""" + return open_and_test_notebooks( + argv=argv, + test_functions=[ + test_jetbrains_bug_py_66491, + test_show_anim_used_instead_of_matplotlib, + test_show_plot_used_instead_of_matplotlib, + ], + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hooks/utils.py b/hooks/utils.py index 54b83c0..f271b92 100644 --- a/hooks/utils.py +++ b/hooks/utils.py @@ -4,8 +4,13 @@ import os import pathlib - +import argparse from git import Git +import nbformat + + +class NotebookTestError(Exception): + """Raised when a notebook validation test fails.""" def find_files(path_to_folder_from_project_root=".", file_extension=None): @@ -43,3 +48,22 @@ def repo_path(): while not (path.is_dir() and Git(path).rev_parse("--git-dir") == ".git"): path = path.parent return path + + +def open_and_test_notebooks(argv, test_functions): + """Create argparser and run notebook tests""" + parser = argparse.ArgumentParser() + parser.add_argument("filenames", nargs="*", help="Filenames to check.") + args = parser.parse_args(argv) + + retval = 0 + for filename in args.filenames: + with open(filename, encoding="utf8") as notebook_file: + notebook = nbformat.read(notebook_file, nbformat.NO_CONVERT) + for func in test_functions: + try: + func(notebook) + except NotebookTestError as e: + print(f"{filename} : {e}") + retval = 1 + return retval From 76baa265e1da59475224cbcbbad6c8845ba697ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Thu, 15 Jan 2026 16:56:19 +0100 Subject: [PATCH 13/22] rename notebooks --- .../{bad/template.ipynb => bad.ipynb} | 0 .../{good/template.ipynb => good.ipynb} | 30 ++++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) rename tests/examples/{bad/template.ipynb => bad.ipynb} (100%) rename tests/examples/{good/template.ipynb => good.ipynb} (82%) diff --git a/tests/examples/bad/template.ipynb b/tests/examples/bad.ipynb similarity index 100% rename from tests/examples/bad/template.ipynb rename to tests/examples/bad.ipynb diff --git a/tests/examples/good/template.ipynb b/tests/examples/good.ipynb similarity index 82% rename from tests/examples/good/template.ipynb rename to tests/examples/good.ipynb index f268d56..b89f34d 100644 --- a/tests/examples/good/template.ipynb +++ b/tests/examples/good.ipynb @@ -2,22 +2,25 @@ "cells": [ { "cell_type": "markdown", - "id": "29186ad9e7311ae0", + "id": "adc05351", "metadata": {}, "source": [ - "[![preview notebook](https://img.shields.io/static/v1?label=render%20on&logo=github&color=87ce3e&message=GitHub)](https://github.com/open-atmos/devops_tests/blob/main/tests/examples/good/template.ipynb)\n", - "[![launch on mybinder.org](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/open-atmos/devops_tests.git/main?urlpath=lab/tree/tests/examples/good/template.ipynb)\n", - "[![launch on Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/open-atmos/devops_tests/blob/main/tests/examples/good/template.ipynb)" + "[![preview notebook](https://img.shields.io/static/v1?label=render%20on&logo=github&color=87ce3e&message=GitHub)](https://github.com/open-atmos/devops_tests/blob/main/tests/examples/good.ipynb)\n", + "[![launch on mybinder.org](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/open-atmos/devops_tests.git/main?urlpath=lab/tree/tests/examples/good.ipynb)\n", + "[![launch on Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/open-atmos/devops_tests/blob/main/tests/examples/good.ipynb)" ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "Notebook description", - "id": "59bad8ebb84ac4ce" + "id": "59bad8ebb84ac4ce", + "metadata": {}, + "source": [ + "Notebook description" + ] }, { "cell_type": "code", + "execution_count": 3, "id": "cc05fb4d", "metadata": { "ExecuteTime": { @@ -25,6 +28,7 @@ "start_time": "2026-01-10T01:02:24.179982Z" } }, + "outputs": [], "source": [ "import os, sys\n", "os.environ['NUMBA_THREADING_LAYER'] = 'workqueue' # PySDM & PyMPDATA don't work with TBB; OpenMP has extra dependencies on macOS\n", @@ -32,22 +36,20 @@ " !pip --quiet install open-atmos-jupyter-utils\n", " from open_atmos_jupyter_utils import pip_install_on_colab\n", " pip_install_on_colab('devops_tests-examples', 'devops_tests')" - ], - "outputs": [], - "execution_count": 3 + ] }, { + "cell_type": "code", + "execution_count": null, + "id": "405d8abe602ec659", "metadata": { "ExecuteTime": { "end_time": "2026-01-10T01:02:24.193663Z", "start_time": "2026-01-10T01:02:24.192050Z" } }, - "cell_type": "code", - "source": "", - "id": "405d8abe602ec659", "outputs": [], - "execution_count": null + "source": [] } ], "metadata": { From 8b86acce2a33908f31063c6e3bf8a563fb10a682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Thu, 15 Jan 2026 16:56:34 +0100 Subject: [PATCH 14/22] add new tests for hooks --- tests/test_check_badges_examples.py | 79 +++++++++++++++++++ tests/test_check_notebooks_examples.py | 103 +++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 tests/test_check_badges_examples.py create mode 100644 tests/test_check_notebooks_examples.py diff --git a/tests/test_check_badges_examples.py b/tests/test_check_badges_examples.py new file mode 100644 index 0000000..68afc2f --- /dev/null +++ b/tests/test_check_badges_examples.py @@ -0,0 +1,79 @@ +""" +Unit tests for hooks/check_badges.py: good and bad notebook examples. + +These tests write small notebooks to temporary files and call the +badge-check functions that expect filenames. +""" + +import nbformat +from nbformat.v4 import new_notebook, new_markdown_cell, new_code_cell + +from hooks import check_badges as cb +import pytest + + +def _write_nb_and_return_path(tmp_path, nb, name="nb.ipynb"): + p = tmp_path / name + nbformat.write(nb, str(p)) + return str(p) + + +def test_good_notebook_header_and_second_cell(tmp_path): + # Create a notebook whose first cell contains exactly the three expected badges. + nb_path = tmp_path / "good.ipynb" + repo_name = "devops_tests" + repo_owner = "open-atmos" + # Use the helper functions from the module to generate the exact expected lines + first_cell = "\n".join( + [ + cb._preview_badge_markdown(str(nb_path), repo_name, repo_owner), + cb._mybinder_badge_markdown(str(nb_path), repo_name, repo_owner), + cb._colab_badge_markdown(str(nb_path), repo_name, repo_owner), + ] + ) + nb = new_notebook( + cells=[ + new_markdown_cell(first_cell), + new_markdown_cell("Some description"), # second cell must be markdown + new_code_cell(source="print('ok')", execution_count=1, outputs=[]), + ] + ) + + path = _write_nb_and_return_path(tmp_path, nb, name="good.ipynb") + # These should not raise + cb.test_notebook_has_at_least_three_cells(path) + cb.test_first_cell_contains_three_badges(path, repo_name) + cb.test_second_cell_is_a_markdown_cell(path) + + +def test_too_few_cells_raises(tmp_path): + nb = new_notebook(cells=[new_markdown_cell("only one cell")]) + path = _write_nb_and_return_path(tmp_path, nb, name="few.ipynb") + with pytest.raises(ValueError): + cb.test_notebook_has_at_least_three_cells(path) + + +def test_first_cell_bad_badges_raises(tmp_path): + nb = new_notebook( + cells=[ + new_markdown_cell("not the right badges\nline2\nline3"), + new_markdown_cell("desc"), + new_code_cell(source="print(1)", execution_count=1, outputs=[]), + ] + ) + path = _write_nb_and_return_path(tmp_path, nb, name="badbadges.ipynb") + with pytest.raises(ValueError): + cb.test_first_cell_contains_three_badges(path, "devops_tests") + + +def test_second_cell_not_markdown_raises(tmp_path): + nb = new_notebook( + cells=[ + new_markdown_cell("badge1\nbadge2\nbadge3"), + new_code_cell(source="print('I am code')", execution_count=1, outputs=[]), + new_code_cell(source="print('more')", execution_count=2, outputs=[]), + ] + ) + path = _write_nb_and_return_path(tmp_path, nb, name="second_not_md.ipynb") + with pytest.raises(ValueError): + cb.test_second_cell_is_a_markdown_cell(path) diff --git a/tests/test_check_notebooks_examples.py b/tests/test_check_notebooks_examples.py new file mode 100644 index 0000000..f6f55a8 --- /dev/null +++ b/tests/test_check_notebooks_examples.py @@ -0,0 +1,103 @@ +""" +Unit tests for hooks/check_notebooks.py: good and bad notebook examples. + +These tests create in-memory notebooks with nbformat and call the +validation functions exported by hooks.check_notebooks to ensure they +accept valid notebooks and raise on invalid ones. +""" + +import pytest +from nbformat.v4 import new_notebook, new_code_cell, new_markdown_cell + +from hooks import notebooks_output as no +from hooks import check_notebooks as cn +from hooks import notebooks_using_jupyter_utils as nuju + + +def test_good_notebook_passes_all_checks(): + # A small, correct notebook: markdown + code cell with execution_count and stdout + nb = new_notebook( + cells=[ + new_markdown_cell("Intro"), + new_code_cell( + source="x = 1\nprint(x)", + execution_count=1, + outputs=[{"output_type": "stream", "name": "stdout", "text": "1\n"}], + ), + ] + ) + + # Should not raise + no.test_cell_contains_output(nb) + no.test_no_errors_or_warnings_in_output(nb) + + cn.test_jetbrains_bug_py_66491(nb) + nuju.test_show_plot_used_instead_of_matplotlib(nb) + nuju.test_show_anim_used_instead_of_matplotlib(nb) + + +def test_cell_missing_execution_count_raises(): + nb = new_notebook( + cells=[ + # code cell with source but execution_count None -> should raise in test_cell_contains_output + new_code_cell(source="print(1)", execution_count=None, outputs=[]) + ] + ) + with pytest.raises(ValueError): + no.test_cell_contains_output(nb) + + +def test_stderr_output_raises(): + nb = new_notebook( + cells=[ + new_code_cell( + source="print('oops')", + execution_count=1, + outputs=[ + {"output_type": "stream", "name": "stderr", "text": "Traceback..."} + ], + ) + ] + ) + with pytest.raises(ValueError): + no.test_no_errors_or_warnings_in_output(nb) + + +def test_using_matplotlib_show_without_show_plot_raises(): + nb = new_notebook( + cells=[ + new_code_cell( + source="import matplotlib.pyplot as plt\nplt.plot([1,2,3])\nplt.show()", + execution_count=1, + outputs=[], + ) + ] + ) + with pytest.raises(ValueError): + nuju.test_show_plot_used_instead_of_matplotlib(nb) + + +def test_animation_without_show_anim_raises(): + nb = new_notebook( + cells=[ + new_code_cell( + source="from matplotlib import animation\nanimation.FuncAnimation(...)", + execution_count=1, + outputs=[], + ) + ] + ) + # test_show_anim_used_instead_of_matplotlib raises AssertionError on bad usage + with pytest.raises(AssertionError): + nuju.test_show_anim_used_instead_of_matplotlib(nb) + + +def test_missing_execution_count_key_raises(): + # Create a cell that lacks the execution_count key entirely (JetBrains bug case) + nb = new_notebook( + cells=[new_code_cell(source="1+1", execution_count=1, outputs=[])] + ) + # remove execution_count key to simulate the broken JSON + del nb.cells[0]["execution_count"] + with pytest.raises(ValueError): + cn.test_jetbrains_bug_py_66491(nb) From bdc59907298a94e0d2e56a7b727e6ec496f03ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Thu, 15 Jan 2026 17:58:03 +0100 Subject: [PATCH 15/22] add test to run notebooks; exclude bad example --- .pre-commit-config.yaml | 2 + tests/test_run_hooks_on_examples.py | 121 ++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 tests/test_run_hooks_on_examples.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 34f4a6b..5db4443 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,7 @@ repos: - nbformat language: python types: [jupyter] + exclude: tests/examples/bad.ipynb - id: check-badges name: check badges @@ -47,3 +48,4 @@ repos: - nbformat language: python types: [jupyter] + exclude: tests/examples/bad.ipynb diff --git a/tests/test_run_hooks_on_examples.py b/tests/test_run_hooks_on_examples.py new file mode 100644 index 0000000..4bcb61c --- /dev/null +++ b/tests/test_run_hooks_on_examples.py @@ -0,0 +1,121 @@ +# pylint: disable=missing-function-docstring + +""" +Run the hooks on good and bad example notebooks and assert behavior + logs. + +This test executes the hook modules the same way pre-commit would: + python -m hooks.check_badges --repo-name=... PATH + python -m hooks.check_notebooks PATH + +It captures stdout/stderr (which is where logging.basicConfig writes), asserts +expected exit codes, and checks stderr for expected failure substrings. + +Adjust the candidate paths in _find_example() if your examples are stored elsewhere. +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + + +def _find_example(basename: str) -> Path | None: + """ + Try a set of common locations for example notebooks and return the first + existing Path or None if not found. + """ + candidates = [ + Path("tests") / "examples" / basename, + Path(basename), + ] + for p in candidates: + if p.exists(): + return p + return None + + +def _run_module(module: str, args: list[str]) -> subprocess.CompletedProcess: + cmd = [sys.executable, "-m", module] + args + return subprocess.run(cmd, capture_output=True, text=True) + + +@pytest.mark.parametrize( + "name, expected_badge_exit, expected_badge_msg", + [ + ("good.ipynb", 0, ""), + ("bad.ipynb", 1, "Missing badges"), + ], +) +def test_check_badges_on_examples( + name: str, expected_badge_exit: int, expected_badge_msg: str +): + nb_path = _find_example(name) + if nb_path is None: + pytest.skip(f"No example notebook found for {name};") + + res = _run_module("hooks.check_badges", ["--repo-name=devops_tests", str(nb_path)]) + combined_msgs = (res.stderr or "") + "\n" + (res.stdout or "") + + # Check exit code first + if expected_badge_exit == 0: + assert ( + res.returncode == 0 + ), f"Expected success; stderr:\n{res.stderr}\nstdout:\n{res.stdout}" + # For success, ensure no obvious error strings are present + assert "Missing badges" not in combined_msgs + assert "ERROR" not in combined_msgs + assert "Traceback" not in combined_msgs + else: + assert ( + res.returncode != 0 + ), f"Expected failure; got exit {res.returncode}\nstdout:\n{res.stdout}" + + assert expected_badge_msg in combined_msgs, ( + f"Expected to find {expected_badge_msg!r} in output" + f"\n---STDERR---\n{res.stderr}" + f"\n---STDOUT---\n{res.stdout}" + ) + + +@pytest.mark.parametrize( + "name, should_fail, expected_msg_substr", + [ + ("good.ipynb", False, ""), + ("bad.ipynb", True, "Cell does not contain output!"), + ("bad.ipynb", True, "Cell does not contain output!"), + ], +) +def test_check_notebooks_on_examples( + name: str, should_fail: bool, expected_msg_substr: str +): + nb_path = _find_example(name) + if nb_path is None: + pytest.skip(f"No example notebook found for {name}") + + res = _run_module("hooks.check_notebooks", [str(nb_path)]) + + if should_fail: + assert ( + res.returncode != 0 + ), f"Expected check_notebooks to fail on {nb_path}, but it succeeded" + combined = (res.stderr or "") + "\n" + (res.stdout or "") + + possible_substrings = [ + expected_msg_substr, + "Notebook cell missing execution_count attribute", + "Cell does not contain output!", + "Traceback", + "stderr", + ] + assert any(s and s in combined for s in possible_substrings), ( + f"Expected one of {possible_substrings!r} in output for failing notebook" + f"\nSTDERR:\n{res.stderr}" + f"\nSTDOUT:\n{res.stdout}" + ) + else: + assert res.returncode == 0, ( + f"Expected check_notebooks to succeed on {nb_path};" + f" stderr:\n{res.stderr}\nstdout:\n{res.stdout}" + ) From d73603263b53cace48336b47a4d298e6e51b1d27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Thu, 15 Jan 2026 17:58:26 +0100 Subject: [PATCH 16/22] update notebooks --- tests/examples/bad.ipynb | 69 +++++++++++---------------------------- tests/examples/good.ipynb | 2 +- 2 files changed, 20 insertions(+), 51 deletions(-) diff --git a/tests/examples/bad.ipynb b/tests/examples/bad.ipynb index e8f0758..1c10614 100644 --- a/tests/examples/bad.ipynb +++ b/tests/examples/bad.ipynb @@ -2,59 +2,36 @@ "cells": [ { "cell_type": "markdown", - "id": "29186ad9e7311ae0", "metadata": {}, "source": [ - "[![preview notebook](https://img.shields.io/static/v1?label=render%20on&logo=github&color=87ce3e&message=GitHub)](https://github.com/open-atmos/devops_tests/blob/main/test_files/template.ipynb)\n", - "[![launch on mybinder.org](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/open-atmos/devops_tests.git/main?urlpath=lab/tree/test_files/template.ipynb)\n", - "[![launch on Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/open-atmos/devops_tests/blob/main/test_files/template.ipynb)" + "This is a deliberately failing notebook for tests: it contains two failing patterns\n", + "- a code cell missing the execution_count key (should trigger `Cell does not contain output!` / jetbrains check)\n", + "- a stderr output (should trigger `test_no_errors_or_warnings_in_output` if reached)\n" ] }, { "cell_type": "code", - "id": "e68c862e7c5918a8", - "metadata": { - "ExecuteTime": { - "end_time": "2026-01-10T01:02:24.169040Z", - "start_time": "2026-01-10T01:02:24.163175Z" - } - }, - "source": [], + "metadata": {}, "outputs": [], - "execution_count": null + "source": [ + "print('this cell lacks the execution_count key')" + ] }, { "cell_type": "code", - "id": "cc05fb4d", - "metadata": { - "ExecuteTime": { - "end_time": "2026-01-10T01:02:24.187099Z", - "start_time": "2026-01-10T01:02:24.179982Z" + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "output_type": "stream", + "name": "stderr", + "text": "Example error on stderr\n" } - }, - "source": [ - "import os, sys\n", - "os.environ['NUMBA_THREADING_LAYER'] = 'workqueue' # PySDM & PyMPDATA don't work with TBB; OpenMP has extra dependencies on macOS\n", - "if 'google.colab' in sys.modules:\n", - " !pip --quiet install open-atmos-jupyter-utils\n", - " from open_atmos_jupyter_utils import pip_install_on_colab\n", - " pip_install_on_colab('devops_tests-examples', 'devops_tests')" ], - "outputs": [], - "execution_count": 3 - }, - { - "metadata": { - "ExecuteTime": { - "end_time": "2026-01-10T01:02:24.193663Z", - "start_time": "2026-01-10T01:02:24.192050Z" - } - }, - "cell_type": "code", - "source": "", - "id": "405d8abe602ec659", - "outputs": [], - "execution_count": null + "source": [ + "import sys\n", + "print('error to stderr', file=sys.stderr)" + ] } ], "metadata": { @@ -64,16 +41,8 @@ "name": "python3" }, "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.6" + "version": "3.10" } }, "nbformat": 4, diff --git a/tests/examples/good.ipynb b/tests/examples/good.ipynb index b89f34d..a4277ca 100644 --- a/tests/examples/good.ipynb +++ b/tests/examples/good.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "adc05351", + "id": "18b1047b", "metadata": {}, "source": [ "[![preview notebook](https://img.shields.io/static/v1?label=render%20on&logo=github&color=87ce3e&message=GitHub)](https://github.com/open-atmos/devops_tests/blob/main/tests/examples/good.ipynb)\n", From 24bde5f5dfe4cce73deac6a2cafe3f13d277c84c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Thu, 15 Jan 2026 17:59:09 +0100 Subject: [PATCH 17/22] pylint hints; remove some unnecessary comments,; cleanup --- hooks/check_badges.py | 146 +++++++++++++++++++------ hooks/check_notebooks.py | 1 + hooks/notebooks_output.py | 2 +- hooks/notebooks_using_jupyter_utils.py | 1 - tests/test_check_badges_examples.py | 21 ++-- tests/test_check_notebooks_examples.py | 12 +- 6 files changed, 128 insertions(+), 55 deletions(-) diff --git a/hooks/check_badges.py b/hooks/check_badges.py index 870aa34..d792e76 100755 --- a/hooks/check_badges.py +++ b/hooks/check_badges.py @@ -1,30 +1,43 @@ #!/usr/bin/env python3 +# pylint: disable=missing-function-docstring """ Checks/repairs notebook badge headers. -This module validates that a notebook's first cell contains the three canonical -badges (GitHub preview, MyBinder, Colab). It tolerates whitespace differences and -badge order, and can optionally fix the header in-place. +This version optionally uses Git to discover the repository root (to build +repo-relative notebook URLs) and uses Python's logging module instead of print() +for structured messages. -Usage: - check_badges --repo-name=devops_tests [--repo-owner=open-atmos] [--fix-header] FILES... +Behavior: +- By default it will attempt to detect the git repo root (if GitPython is + installed and the file is in a git working tree) and fall back to Path.cwd(). +- Use --no-git to force using Path.cwd(). +- Use --repo-root PATH to explicitly set repository root. +- Use --verbose to enable debug logging. -The functions are written to be easily unit-tested. +Usage: + check_badges --repo-name=devops_tests [--repo-owner=open-atmos] [--fix-header] + [--no-git] [--repo-root PATH] [--verbose] FILES... """ from __future__ import annotations import argparse +import logging from collections.abc import Sequence from pathlib import Path -from typing import Iterable, List, Tuple +from typing import Iterable, List, Tuple, Optional import nbformat from nbformat import NotebookNode +from .utils import NotebookTestError + REPO_OWNER_DEFAULT = "open-atmos" -def _preview_badge_markdown(absolute_path: str, repo_name: str, repo_owner: str) -> str: +logger = logging.getLogger(__name__) + + +def preview_badge_markdown(absolute_path: str, repo_name: str, repo_owner: str) -> str: svg_badge_url = ( "https://img.shields.io/static/v1?" + "label=render%20on&logo=github&color=87ce3e&message=GitHub" @@ -33,9 +46,7 @@ def _preview_badge_markdown(absolute_path: str, repo_name: str, repo_owner: str) return f"[![preview notebook]({svg_badge_url})]({link})" -def _mybinder_badge_markdown( - absolute_path: str, repo_name: str, repo_owner: str -) -> str: +def mybinder_badge_markdown(absolute_path: str, repo_name: str, repo_owner: str) -> str: svg_badge_url = "https://mybinder.org/badge_logo.svg" link = ( f"https://mybinder.org/v2/gh/{repo_owner}/{repo_name}.git/main?urlpath=lab/tree/" @@ -44,7 +55,7 @@ def _mybinder_badge_markdown( return f"[![launch on mybinder.org]({svg_badge_url})]({link})" -def _colab_badge_markdown(absolute_path: str, repo_name: str, repo_owner: str) -> str: +def colab_badge_markdown(absolute_path: str, repo_name: str, repo_owner: str) -> str: svg_badge_url = "https://colab.research.google.com/assets/colab-badge.svg" link = ( f"https://colab.research.google.com/github/{repo_owner}/{repo_name}/blob/main/" @@ -53,24 +64,57 @@ def _colab_badge_markdown(absolute_path: str, repo_name: str, repo_owner: str) - return f"[![launch on Colab]({svg_badge_url})]({link})" +def find_repo_root(start_path: Path, prefer_git: bool = True) -> Path: + """ + Find repository root for the given start_path. + + If prefer_git is True, attempt to use GitPython to locate the repository root + (searching parent directories). If that fails, fall back to cwd(). + """ + if prefer_git: + try: + # Import locally so the module doesn't hard-depend on GitPython at import time + from git import Repo # pylint: disable=import-outside-toplevel + + try: + repo = Repo(start_path, search_parent_directories=True) + if repo.working_tree_dir: + root = Path(repo.working_tree_dir) + logger.debug("Discovered git repository root: %s", root) + return root + except Exception as exc: # pylint: disable=broad-exception-caught + logger.debug("Git repo detection failed for %s: %s", start_path, exc) + except ImportError as exc: + logger.debug("GitPython not available or import failed: %s", exc) + + cwd = Path.cwd() + logger.debug("Using current working directory as repo root: %s", cwd) + return cwd + + def expected_badges_for( - notebook_path: Path, repo_name: str, repo_owner: str + notebook_path: Path, + repo_name: str, + repo_owner: str, + repo_root: Optional[Path] = None, ) -> List[str]: """ Return the canonical badge lines expected for notebook_path. - The notebook_path is used to build the URL path; we convert it to a - repo-relative posix path (best-effort). + If repo_root is provided, attempt to build a relative path from it; otherwise + find repository root automatically (using find_repo_root). """ - # Build a repo-relative path: try to strip cwd if notebook is inside repo + if repo_root is None: + repo_root = find_repo_root(notebook_path) try: - rel = notebook_path.relative_to(Path.cwd()) - except Exception: + rel = notebook_path.relative_to(repo_root) + except NotebookTestError(Exception): + # fallback to just the given path rel = notebook_path absolute_path = rel.as_posix() return [ - _preview_badge_markdown(absolute_path, repo_name, repo_owner), - _mybinder_badge_markdown(absolute_path, repo_name, repo_owner), - _colab_badge_markdown(absolute_path, repo_name, repo_owner), + preview_badge_markdown(absolute_path, repo_name, repo_owner), + mybinder_badge_markdown(absolute_path, repo_name, repo_owner), + colab_badge_markdown(absolute_path, repo_name, repo_owner), ] @@ -91,7 +135,6 @@ def first_cell_lines(nb: NotebookNode) -> List[str]: first = nb.cells[0] if first.cell_type != "markdown": return [] - # split preserving order, strip each line return [ln.strip() for ln in str(first.source).splitlines() if ln.strip() != ""] @@ -119,15 +162,24 @@ def test_notebook_has_at_least_three_cells(notebook_filename: str) -> None: def test_first_cell_contains_three_badges( - notebook_filename: str, repo_name: str, repo_owner: str = REPO_OWNER_DEFAULT + notebook_filename: str, + repo_name: str, + repo_owner: str = REPO_OWNER_DEFAULT, + repo_root: Optional[Path] = None, ) -> None: """ checks if the notebook's first cell contains the three badges. Raises ValueError on failure. + + The optional repo_root can be provided to control how the notebook path is + converted into the remote URL. If None, the module will attempt to detect + a git repo root and fall back to cwd(). """ nb = read_notebook(Path(notebook_filename)) lines = first_cell_lines(nb) - expected = expected_badges_for(Path(notebook_filename), repo_name, repo_owner) + expected = expected_badges_for( + Path(notebook_filename), repo_name, repo_owner, repo_root + ) ok, msg = badges_match(lines, expected) if not ok: raise ValueError(msg) @@ -143,15 +195,17 @@ def test_second_cell_is_a_markdown_cell(notebook_filename: str) -> None: def fix_header_inplace( - path: Path, repo_name: str, repo_owner: str = REPO_OWNER_DEFAULT + path: Path, + repo_name: str, + repo_owner: str = REPO_OWNER_DEFAULT, + repo_root: Optional[Path] = None, ) -> None: """ Replace the first cell with the canonical 3-badge header if the header is missing or malformed. If the notebook has no cells, a new markdown cell is inserted. """ nb = read_notebook(path) - expected = expected_badges_for(path, repo_name, repo_owner) - # Build markdown with one badge per line + expected = expected_badges_for(path, repo_name, repo_owner, repo_root) new_first = {"cell_type": "markdown", "metadata": {}, "source": "\n".join(expected)} if not nb.cells: nb.cells.insert(0, nbformat.from_dict(new_first)) @@ -169,28 +223,52 @@ def main(argv: Sequence[str] | None = None) -> int: action="store_true", help="If set, attempt to fix notebooks missing the header.", ) + parser.add_argument( + "--no-git", + action="store_true", + help="Do not attempt to detect git repo root; use cwd()", + ) + parser.add_argument( + "--repo-root", help="Explicit repository root to use when building URLs" + ) + parser.add_argument("--verbose", action="store_true", help="Enable debug logging") parser.add_argument("filenames", nargs="*", help="Filenames to check.") args = parser.parse_args(argv) + # configure logging + level = logging.DEBUG if args.verbose else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s: %(message)s") + + prefer_git = not args.no_git + repo_root_path: Optional[Path] = Path(args.repo_root) if args.repo_root else None retval = 0 for filename in args.filenames: path = Path(filename) try: + effective_repo_root = repo_root_path or ( + find_repo_root(path, prefer_git) if prefer_git else Path.cwd() + ) + test_notebook_has_at_least_three_cells(filename) test_first_cell_contains_three_badges( - filename, args.repo_name, args.repo_owner + filename, args.repo_name, args.repo_owner, effective_repo_root ) test_second_cell_is_a_markdown_cell(filename) - except Exception as exc: - print(f"{filename}: {exc}") + + logger.info("%s: OK", filename) + retval = retval or 0 + except NotebookTestError(Exception) as exc: + logger.error("%s: %s", filename, exc) retval = 1 if args.fix_header: try: - fix_header_inplace(path, args.repo_name, args.repo_owner) - print(f"{filename}: header fixed") + fix_header_inplace( + path, args.repo_name, args.repo_owner, effective_repo_root + ) + logger.info("%s: header fixed", filename) retval = 0 - except Exception as fix_exc: - print(f"{filename}: failed to fix header: {fix_exc}") + except NotebookTestError(Exception) as fix_exc: + logger.exception("%s: failed to fix header: %s", filename, fix_exc) retval = 2 return retval diff --git a/hooks/check_notebooks.py b/hooks/check_notebooks.py index f7c76b3..d84ecb4 100755 --- a/hooks/check_notebooks.py +++ b/hooks/check_notebooks.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# pylint: disable=missing-module-docstring from __future__ import annotations from collections.abc import Sequence diff --git a/hooks/notebooks_output.py b/hooks/notebooks_output.py index 9a75274..20dcdec 100755 --- a/hooks/notebooks_output.py +++ b/hooks/notebooks_output.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Sequence -from .utils import open_and_test_notebooks, NotebookTestError +from .utils import open_and_test_notebooks def test_cell_contains_output(notebook): diff --git a/hooks/notebooks_using_jupyter_utils.py b/hooks/notebooks_using_jupyter_utils.py index bf3f2e1..23bf247 100644 --- a/hooks/notebooks_using_jupyter_utils.py +++ b/hooks/notebooks_using_jupyter_utils.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# pylint: disable=duplicate-code #TODO #62 """ Checks notebook execution status for Jupyter notebooks""" from __future__ import annotations diff --git a/tests/test_check_badges_examples.py b/tests/test_check_badges_examples.py index 68afc2f..068572b 100644 --- a/tests/test_check_badges_examples.py +++ b/tests/test_check_badges_examples.py @@ -1,3 +1,5 @@ +# pylint: disable=missing-function-docstring + """ Unit tests for hooks/check_badges.py: good and bad notebook examples. @@ -7,9 +9,9 @@ import nbformat from nbformat.v4 import new_notebook, new_markdown_cell, new_code_cell +import pytest from hooks import check_badges as cb -import pytest def _write_nb_and_return_path(tmp_path, nb, name="nb.ipynb"): @@ -19,28 +21,29 @@ def _write_nb_and_return_path(tmp_path, nb, name="nb.ipynb"): def test_good_notebook_header_and_second_cell(tmp_path): - # Create a notebook whose first cell contains exactly the three expected badges. + # arrange nb_path = tmp_path / "good.ipynb" repo_name = "devops_tests" repo_owner = "open-atmos" - # Use the helper functions from the module to generate the exact expected lines first_cell = "\n".join( [ - cb._preview_badge_markdown(str(nb_path), repo_name, repo_owner), - cb._mybinder_badge_markdown(str(nb_path), repo_name, repo_owner), - cb._colab_badge_markdown(str(nb_path), repo_name, repo_owner), + cb.preview_badge_markdown(str(nb_path), repo_name, repo_owner), + cb.mybinder_badge_markdown(str(nb_path), repo_name, repo_owner), + cb.colab_badge_markdown(str(nb_path), repo_name, repo_owner), ] ) + + # act nb = new_notebook( cells=[ new_markdown_cell(first_cell), - new_markdown_cell("Some description"), # second cell must be markdown + new_markdown_cell("Some description"), new_code_cell(source="print('ok')", execution_count=1, outputs=[]), ] ) - path = _write_nb_and_return_path(tmp_path, nb, name="good.ipynb") - # These should not raise + + # assert cb.test_notebook_has_at_least_three_cells(path) cb.test_first_cell_contains_three_badges(path, repo_name) cb.test_second_cell_is_a_markdown_cell(path) diff --git a/tests/test_check_notebooks_examples.py b/tests/test_check_notebooks_examples.py index f6f55a8..d32a5fe 100644 --- a/tests/test_check_notebooks_examples.py +++ b/tests/test_check_notebooks_examples.py @@ -1,3 +1,4 @@ +# pylint: disable=missing-function-docstring """ Unit tests for hooks/check_notebooks.py: good and bad notebook examples. @@ -15,7 +16,6 @@ def test_good_notebook_passes_all_checks(): - # A small, correct notebook: markdown + code cell with execution_count and stdout nb = new_notebook( cells=[ new_markdown_cell("Intro"), @@ -27,10 +27,8 @@ def test_good_notebook_passes_all_checks(): ] ) - # Should not raise no.test_cell_contains_output(nb) no.test_no_errors_or_warnings_in_output(nb) - cn.test_jetbrains_bug_py_66491(nb) nuju.test_show_plot_used_instead_of_matplotlib(nb) nuju.test_show_anim_used_instead_of_matplotlib(nb) @@ -38,10 +36,7 @@ def test_good_notebook_passes_all_checks(): def test_cell_missing_execution_count_raises(): nb = new_notebook( - cells=[ - # code cell with source but execution_count None -> should raise in test_cell_contains_output - new_code_cell(source="print(1)", execution_count=None, outputs=[]) - ] + cells=[new_code_cell(source="print(1)", execution_count=None, outputs=[])] ) with pytest.raises(ValueError): no.test_cell_contains_output(nb) @@ -87,17 +82,14 @@ def test_animation_without_show_anim_raises(): ) ] ) - # test_show_anim_used_instead_of_matplotlib raises AssertionError on bad usage with pytest.raises(AssertionError): nuju.test_show_anim_used_instead_of_matplotlib(nb) def test_missing_execution_count_key_raises(): - # Create a cell that lacks the execution_count key entirely (JetBrains bug case) nb = new_notebook( cells=[new_code_cell(source="1+1", execution_count=1, outputs=[])] ) - # remove execution_count key to simulate the broken JSON del nb.cells[0]["execution_count"] with pytest.raises(ValueError): cn.test_jetbrains_bug_py_66491(nb) From ec738ddbe2a4c3d7bdc47dc270085c1348a338d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Thu, 15 Jan 2026 18:10:35 +0100 Subject: [PATCH 18/22] change exceptions --- hooks/check_badges.py | 6 +++--- hooks/check_notebooks.py | 6 +----- hooks/notebooks_using_jupyter_utils.py | 4 +--- hooks/utils.py | 2 +- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/hooks/check_badges.py b/hooks/check_badges.py index d792e76..973b367 100755 --- a/hooks/check_badges.py +++ b/hooks/check_badges.py @@ -107,7 +107,7 @@ def expected_badges_for( repo_root = find_repo_root(notebook_path) try: rel = notebook_path.relative_to(repo_root) - except NotebookTestError(Exception): + except NotebookTestError: # fallback to just the given path rel = notebook_path absolute_path = rel.as_posix() @@ -257,7 +257,7 @@ def main(argv: Sequence[str] | None = None) -> int: logger.info("%s: OK", filename) retval = retval or 0 - except NotebookTestError(Exception) as exc: + except NotebookTestError as exc: logger.error("%s: %s", filename, exc) retval = 1 if args.fix_header: @@ -267,7 +267,7 @@ def main(argv: Sequence[str] | None = None) -> int: ) logger.info("%s: header fixed", filename) retval = 0 - except NotebookTestError(Exception) as fix_exc: + except NotebookTestError as fix_exc: logger.exception("%s: failed to fix header: %s", filename, fix_exc) retval = 2 return retval diff --git a/hooks/check_notebooks.py b/hooks/check_notebooks.py index d84ecb4..ae654c5 100755 --- a/hooks/check_notebooks.py +++ b/hooks/check_notebooks.py @@ -3,11 +3,7 @@ from __future__ import annotations from collections.abc import Sequence -from .utils import open_and_test_notebooks - - -class NotebookTestError(Exception): - """Raised when a notebook validation test fails.""" +from .utils import open_and_test_notebooks, NotebookTestError def test_jetbrains_bug_py_66491(notebook): diff --git a/hooks/notebooks_using_jupyter_utils.py b/hooks/notebooks_using_jupyter_utils.py index 23bf247..22eb6ae 100644 --- a/hooks/notebooks_using_jupyter_utils.py +++ b/hooks/notebooks_using_jupyter_utils.py @@ -8,9 +8,7 @@ import nbformat - -class NotebookTestError(Exception): - """Raised when a notebook validation test fails.""" +from .utils import NotebookTestError def test_show_plot_used_instead_of_matplotlib(notebook): diff --git a/hooks/utils.py b/hooks/utils.py index f271b92..4ffa6aa 100644 --- a/hooks/utils.py +++ b/hooks/utils.py @@ -9,7 +9,7 @@ import nbformat -class NotebookTestError(Exception): +class NotebookTestError(BaseException): """Raised when a notebook validation test fails.""" From 7c9b6dce26d8eb06bf33628992c0b385ca47f1ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Thu, 15 Jan 2026 18:36:04 +0100 Subject: [PATCH 19/22] change exception to ValueError --- hooks/check_badges.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/check_badges.py b/hooks/check_badges.py index 973b367..0d36695 100755 --- a/hooks/check_badges.py +++ b/hooks/check_badges.py @@ -107,7 +107,7 @@ def expected_badges_for( repo_root = find_repo_root(notebook_path) try: rel = notebook_path.relative_to(repo_root) - except NotebookTestError: + except ValueError: # fallback to just the given path rel = notebook_path absolute_path = rel.as_posix() From 74f00480d83ca21164b2d0cc3298764fdf2fd8f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Thu, 15 Jan 2026 18:49:06 +0100 Subject: [PATCH 20/22] remove duplicated code --- hooks/check_notebooks.py | 2 +- hooks/notebooks_using_jupyter_utils.py | 38 +------------------------- 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/hooks/check_notebooks.py b/hooks/check_notebooks.py index ae654c5..c23648b 100755 --- a/hooks/check_notebooks.py +++ b/hooks/check_notebooks.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Sequence -from .utils import open_and_test_notebooks, NotebookTestError +from .utils import open_and_test_notebooks def test_jetbrains_bug_py_66491(notebook): diff --git a/hooks/notebooks_using_jupyter_utils.py b/hooks/notebooks_using_jupyter_utils.py index 22eb6ae..8d69044 100644 --- a/hooks/notebooks_using_jupyter_utils.py +++ b/hooks/notebooks_using_jupyter_utils.py @@ -3,12 +3,8 @@ Checks notebook execution status for Jupyter notebooks""" from __future__ import annotations -import argparse from collections.abc import Sequence - -import nbformat - -from .utils import NotebookTestError +from .utils import open_and_test_notebooks def test_show_plot_used_instead_of_matplotlib(notebook): @@ -48,43 +44,11 @@ def test_show_anim_used_instead_of_matplotlib(notebook): ) -def test_jetbrains_bug_py_66491(notebook): - """checks if all notebook have the execution_count key for each cell in JSON, - which is required by GitHub renderer and may not be generated by some buggy PyCharm versions: - https://youtrack.jetbrains.com/issue/PY-66491""" - for cell in notebook.cells: - if cell.cell_type == "code" and not hasattr(cell, "execution_count"): - raise ValueError( - "Notebook cell missing execution_count attribute. " - "(May be due to PyCharm bug, see: https://youtrack.jetbrains.com/issue/PY-66491 )" - ) - - -def open_and_test_notebooks(argv, test_functions): - """Create argparser and run notebook tests""" - parser = argparse.ArgumentParser() - parser.add_argument("filenames", nargs="*", help="Filenames to check.") - args = parser.parse_args(argv) - - retval = 0 - for filename in args.filenames: - with open(filename, encoding="utf8") as notebook_file: - notebook = nbformat.read(notebook_file, nbformat.NO_CONVERT) - for func in test_functions: - try: - func(notebook) - except NotebookTestError as e: - print(f"{filename} : {e}") - retval = 1 - return retval - - def main(argv: Sequence[str] | None = None) -> int: """test all notebooks""" return open_and_test_notebooks( argv=argv, test_functions=[ - test_jetbrains_bug_py_66491, test_show_anim_used_instead_of_matplotlib, test_show_plot_used_instead_of_matplotlib, ], From b31b7f88576ccfd2c70c8008187f57a90701858d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Thu, 15 Jan 2026 19:02:48 +0100 Subject: [PATCH 21/22] add nu_ju to executables --- .pre-commit-config.yaml | 8 ++++++++ .pre-commit-hooks.yaml | 8 ++++++++ hooks/notebooks_using_jupyter_utils.py | 0 pyproject.toml | 1 + 4 files changed, 17 insertions(+) mode change 100644 => 100755 hooks/notebooks_using_jupyter_utils.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5db4443..01895ac 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,3 +49,11 @@ repos: language: python types: [jupyter] exclude: tests/examples/bad.ipynb + + - id: notebooks-using-jupyter-utils + name: notebooks using open-atmos-jupyter-utils + entry: notebooks_using_jupyter_utils + additional_dependencies: + - nbformat + language: python + types: [ jupyter ] diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 9a644d7..29786dc 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -21,3 +21,11 @@ language: python stages: [pre-commit] types: [jupyter] + +- id: notebooks-using-jupyter-utils + name: notebooks using open-atmos-jupyter-utils + description: check if notebook use show_anim() and show_plot() methods from open-atmos-jupyter-utils + entry: notebooks_using_jupyter_utils + language: python + stages: [pre-commit] + types: [jupyter] diff --git a/hooks/notebooks_using_jupyter_utils.py b/hooks/notebooks_using_jupyter_utils.py old mode 100644 new mode 100755 diff --git a/pyproject.toml b/pyproject.toml index d76fbb5..7fea13a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,3 +30,4 @@ dynamic = ['version'] check_notebooks = "hooks.check_notebooks:main" check_badges = "hooks.check_badges:main" notebooks_output = "hooks.notebooks_output:main" +notebooks_using_jupyter_utils = "hooks.notebooks_using_jupyter_utils:main" From 0de9e63b67d1e2fb7d7236a8f129a58c5e9154d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agnieszka=20=C5=BBaba?= Date: Thu, 15 Jan 2026 19:17:35 +0100 Subject: [PATCH 22/22] add check=True flag --- tests/test_run_hooks_on_examples.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_run_hooks_on_examples.py b/tests/test_run_hooks_on_examples.py index 4bcb61c..cfb11a4 100644 --- a/tests/test_run_hooks_on_examples.py +++ b/tests/test_run_hooks_on_examples.py @@ -38,7 +38,7 @@ def _find_example(basename: str) -> Path | None: def _run_module(module: str, args: list[str]) -> subprocess.CompletedProcess: cmd = [sys.executable, "-m", module] + args - return subprocess.run(cmd, capture_output=True, text=True) + return subprocess.run(cmd, capture_output=True, text=True, check=True) @pytest.mark.parametrize(