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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,3 @@ jobs:
- env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pytest -vv -rP -We .

12 changes: 5 additions & 7 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
files: '.py'
exclude: '.git'
default_stages: [pre-commit]

repos:
- repo: https://github.com/psf/black
rev: 25.1.0
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.9.0
hooks:
- id: black

- repo: https://github.com/timothycrosley/isort
rev: 6.0.1
rev: 6.1.0
hooks:
- id: isort

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
Expand All @@ -23,4 +21,4 @@ repos:
- repo: https://github.com/christopher-hacker/enforce-notebook-run-order
rev: 2.0.1
hooks:
- id: enforce-notebook-run-order
- id: enforce-notebook-run-order
7 changes: 7 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- id: check-notebooks
name: check notebooks
description: check Jupyter Notebook contents
entry: check_notebooks
language: python
stages: [pre-commit]
types: [jupyter]
File renamed without changes.
Empty file.
95 changes: 95 additions & 0 deletions devops_tests/pre_commit_hooks/check_badges.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from __future__ import annotations

import argparse
from collections.abc import Sequence

import nbformat
from utils import relative_path, repo_path

COLAB_HEADER = f"""import sys
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_path().name}-examples')"""


def _preview_badge_markdown(absolute_path):
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_path().name}/blob/main/"
+ f"{relative_path(absolute_path)}"
)
return f"[![preview notebook]({svg_badge_url})]({link})"


def _mybinder_badge_markdown(absolute_path):
svg_badge_url = "https://mybinder.org/badge_logo.svg"
link = (
f"https://mybinder.org/v2/gh/open-atmos/{repo_path().name}.git/main?urlpath=lab/tree/"
+ f"{relative_path(absolute_path)}"
)
return f"[![launch on mybinder.org]({svg_badge_url})]({link})"


def _colab_badge_markdown(absolute_path):
svg_badge_url = "https://colab.research.google.com/assets/colab-badge.svg"
link = (
f"https://colab.research.google.com/github/open-atmos/{repo_path().name}/blob/main/"
+ f"{relative_path(absolute_path)}"
)
return f"[![launch on Colab]({svg_badge_url})]({link})"


def test_first_cell_contains_three_badges(notebook_filename):
"""checks if all notebooks feature Github preview, mybinder and Colab badges
(in the first cell)"""
with open(notebook_filename, encoding="utf8") as fp:
nb = nbformat.read(fp, nbformat.NO_CONVERT)
assert len(nb.cells) > 0
assert nb.cells[0].cell_type == "markdown"
lines = nb.cells[0].source.split("\n")
assert len(lines) == 3
assert lines[0] == _preview_badge_markdown(notebook_filename)
assert lines[1] == _mybinder_badge_markdown(notebook_filename)
assert lines[2] == _colab_badge_markdown(notebook_filename)


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)
assert len(nb.cells) > 1
assert nb.cells[1].cell_type == "markdown"


def test_third_cell_contains_colab_header(notebook_filename):
"""checks if all notebooks feature a Colab-magic cell"""
with open(notebook_filename, encoding="utf8") as fp:
nb = nbformat.read(fp, nbformat.NO_CONVERT)
assert len(nb.cells) > 2
assert nb.cells[2].cell_type == "code"
assert nb.cells[2].source == COLAB_HEADER


def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("filenames", nargs="*", help="Filenames to check.")
args = parser.parse_args(argv)

retval = 0
for filename in args.filenames:
try:
test_first_cell_contains_three_badges(filename)
test_second_cell_is_a_markdown_cell(filename)
test_third_cell_contains_colab_header(filename)
except ValueError as exc:
retval = 1
return retval


if __name__ == "__main__":
raise SystemExit(main())
104 changes: 104 additions & 0 deletions devops_tests/pre_commit_hooks/check_notebooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env python3

from __future__ import annotations

import argparse
import os
from collections.abc import Sequence

import nbformat


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"])


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 Exception(
"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 Exception(
"Notebook cell missing execution_count attribute. (May be due to PyCharm bug, see: https://youtrack.jetbrains.com/issue/PY-66491 )"
)


def main(argv: Sequence[str] | None = None) -> int:
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,
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)
for func in test_functions:
try:
func(notebook)
except Exception as e:
print(f"{filename} : {e}")
retval = 1
return retval


if __name__ == "__main__":
raise SystemExit(main())
46 changes: 46 additions & 0 deletions devops_tests/test_notebooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import sys

if sys.platform == "win32" and sys.version_info[:2] >= (3, 7):
import asyncio

asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

import gc
import os
import warnings

import nbformat
import pytest

from .utils import find_files

with warnings.catch_warnings():
warnings.filterwarnings("ignore")
from nbconvert.preprocessors import ExecutePreprocessor


@pytest.fixture(
params=find_files(file_extension=".ipynb"),
name="notebook_filename",
)
def _notebook_filename(request):
return request.param


def test_run_notebooks(notebook_filename, tmp_path):
"""executes a given notebook"""
os.environ["JUPYTER_PLATFORM_DIRS"] = "1"

executor = ExecutePreprocessor(timeout=15 * 60, kernel_name="python3")

with open(notebook_filename, encoding="utf8") as notebook_file:
# https://github.com/pytest-dev/pytest-asyncio/issues/212
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message="There is no current event loop")
executor.preprocess(
nbformat.read(notebook_file, as_version=4),
{"metadata": {"path": tmp_path}},
)

# so that nbconvert perplexities are reported here, and not at some dtor test later on
gc.collect()
18 changes: 18 additions & 0 deletions utils.py → devops_tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import os
import pathlib

from git.cmd import Git

Expand All @@ -25,3 +26,20 @@ def find_files(path_to_folder_from_project_root=".", file_extension=None):
return list(filter(lambda path: path.endswith(file_extension), all_files))

return all_files


def relative_path(absolute_path):
"""returns a path relative to the repo base (converting backslashes to slashes on Windows)"""
relpath = os.path.relpath(absolute_path, repo_path().absolute())
posixpath_to_make_it_usable_in_urls_even_on_windows = pathlib.Path(
relpath
).as_posix()
return posixpath_to_make_it_usable_in_urls_even_on_windows


def repo_path():
"""returns absolute path to the repo base (ignoring .git location if in a submodule)"""
path = pathlib.Path(__file__)
while not (path.is_dir() and Git(path).rev_parse("--git-dir") == ".git"):
path = path.parent
return path
29 changes: 29 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"

[tool.setuptools]
packages = ['devops_tests', 'devops_tests.pre_commit_hooks']

[project]
name = "devops_tests"
dependencies = [
"pytest",
"ghapi",
"gitpython",
"binaryornot",
"nbconvert",
"jsonschema",
"jupyter-client>=8.0.2",
"ipython",
"ipykernel",
]
readme = "README.md"
license = "GPL-3.0-only"
authors = [
{name = "https://github.com/open-atmos/devops_tests/graphs/contributors", email = "[email protected]"}
]
dynamic = ['version']

[project.scripts]
check_notebooks = "devops_tests.pre_commit_hooks.check_notebooks:main"
10 changes: 0 additions & 10 deletions requirements.txt

This file was deleted.

Loading