diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 9d7d9c9..f795c33 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -20,8 +20,8 @@ jobs: steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v5 + - run: pip install . - run: pip install pylint - - run: pip install -r requirements.txt - run: pylint --unsafe-load-any-extension=y --disable=fixme $(git ls-files '*.py') precommit: @@ -30,6 +30,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v1 - run: | + pip install . pip install pre-commit pre-commit clean pre-commit autoupdate @@ -47,8 +48,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - run: pip install -r requirements.txt + - run: pip install . - env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: pytest -vv -rP -We . - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8155bbd..ad340ed 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,20 +1,13 @@ -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 - 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 @@ -23,4 +16,25 @@ repos: - repo: https://github.com/christopher-hacker/enforce-notebook-run-order rev: 2.0.1 hooks: - - id: enforce-notebook-run-order \ No newline at end of file + - id: enforce-notebook-run-order + + - repo: local + hooks: + - id: check-notebooks + name: check notebooks + entry: hooks/check_notebooks.py + additional_dependencies: + - nbformat + language: python + types: [jupyter] + + - id: check-badges + name: check badges + entry: hooks/check_badges.py + additional_dependencies: + - "." + - nbformat + - pytest + - gitpython + language: python + types: [jupyter] diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..922a4c2 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,15 @@ +- id: check-notebooks + name: check notebooks + description: check Jupyter Notebook contents + entry: check_notebooks + language: python + stages: [pre-commit] + types: [jupyter] + +- id: check-badges + name: check badges + description: check badges in Jupyter Notebook + entry: check_badges + language: python + stages: [pre-commit] + types: [jupyter] diff --git a/__init__.py b/devops_tests/__init__.py similarity index 100% rename from __init__.py rename to devops_tests/__init__.py diff --git a/devops_tests/test_notebooks.py b/devops_tests/test_notebooks.py new file mode 100644 index 0000000..9174f4d --- /dev/null +++ b/devops_tests/test_notebooks.py @@ -0,0 +1,47 @@ +"""test execution of notebooks in the repository""" + +import gc +import os +import sys +import warnings + +import nbformat +import pytest + +from .utils import find_files + +if sys.platform == "win32" and sys.version_info[:2] >= (3, 7): + import asyncio + + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +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() diff --git a/devops_tests/utils.py b/devops_tests/utils.py new file mode 100644 index 0000000..e51a722 --- /dev/null +++ b/devops_tests/utils.py @@ -0,0 +1,45 @@ +""" +Utils functions to reuse in different parts of the codebase +""" + +import os +import pathlib + +from git import Git + + +def find_files(path_to_folder_from_project_root=".", file_extension=None): + """ + Returns all files in a current git repo. + The list of returned files may be filtered with `file_extension` param. + """ + all_files = [ + path + for path in Git( + Git(path_to_folder_from_project_root).rev_parse("--show-toplevel") + ) + .ls_files() + .split("\n") + if os.path.isfile(path) + ] + if file_extension is not 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 diff --git a/hooks/check_badges.py b/hooks/check_badges.py new file mode 100755 index 0000000..9bf36cc --- /dev/null +++ b/hooks/check_badges.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +Checks whether notebooks contain badges.""" +from __future__ import annotations + +import argparse +import os +import sys +from collections.abc import Sequence + +import nbformat + +repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../")) +sys.path.insert(0, repo_root) +from devops_tests.utils import ( # pylint: disable=wrong-import-position + relative_path, + repo_path, +) + +HEADER = f"""import os, sys +os.environ['NUMBA_THREADING_LAYER'] = 'omp' # PySDM and PyMPDATA are incompatible with TBB threads +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')""" + +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 fix_colab_header(notebook_path): + """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 + for idx, cell in enumerate(nb.cells): + if cell.cell_type == "code" and is_colab_header(cell.source): + header_index = idx + break + + modified = False + if header_index is not None: + if nb.cells[header_index].source != HEADER: + nb.cells[header_index].source = 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(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): + 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_notebook_has_at_least_three_cells(notebook_filename): + """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): + """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): + raise ValueError("First badge does not match Github preview badge") + if lines[1] != _mybinder_badge_markdown(notebook_filename): + raise ValueError("Second badge does not match MyBinder badge") + if lines[2] != _colab_badge_markdown(notebook_filename): + 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) + if nb.cells[1].cell_type != "markdown": + raise ValueError("Second cell is not a markdown cell") + + +def main(argv: Sequence[str] | None = None) -> int: + """collect failed notebook checks""" + parser = argparse.ArgumentParser() + parser.add_argument("filenames", nargs="*", help="Filenames to check.") + args = parser.parse_args(argv) + + failed_files = False + reformatted_files = [] + unchanged_files = [] + for filename in args.filenames: + try: + modified = fix_colab_header(filename) + 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 + try: + test_notebook_has_at_least_three_cells(filename) + test_first_cell_contains_three_badges(filename) + 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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hooks/check_notebooks.py b/hooks/check_notebooks.py new file mode 100755 index 0000000..870a124 --- /dev/null +++ b/hooks/check_notebooks.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +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_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 + 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 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, + 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 NotebookTestError as e: + print(f"{filename} : {e}") + retval = 1 + return retval + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2f20921 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools >= 77.0.3", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ['devops_tests'] + +[project] +name = "devops_tests" +dependencies = [ + "pytest", + "ghapi", + "gitpython", + "binaryornot", + "nbconvert", + "nbformat", + "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 = "sylwester.arabas@agh.edu.pl"} +] +dynamic = ['version'] + +[project.scripts] +check_notebooks = "devops_tests.hooks.check_notebooks:main" +check_badges = "devops_tests.hooks.check_badges:main" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 1cbac3c..0000000 --- a/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -pytest -ghapi -gitpython -binaryornot -nbconvert -jsonschema -jupyter-client>=8.0.2 -ipython -ipykernel -pint diff --git a/test_files/template.ipynb b/test_files/template.ipynb index 01ec238..51f70a6 100644 --- a/test_files/template.ipynb +++ b/test_files/template.ipynb @@ -1,39 +1,40 @@ { "cells": [ { - "metadata": {}, "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)" - ], - "id": "29186ad9e7311ae0" + ] }, { - "metadata": {}, "cell_type": "markdown", - "source": "", - "id": "7a729b2624b70eae" + "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" } }, - "cell_type": "code", + "outputs": [], "source": [ - "import sys\n", + "import os, sys\n", + "os.environ['NUMBA_THREADING_LAYER'] = 'omp' # PySDM and PyMPDATA are incompatible with TBB threads\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')" - ], - "id": "72ccd23c0ab9f08e", - "outputs": [], - "execution_count": 1 + ] } ], "metadata": { diff --git a/test_notebooks.py b/test_notebooks.py deleted file mode 100644 index c9012f4..0000000 --- a/test_notebooks.py +++ /dev/null @@ -1,231 +0,0 @@ -"""executes all Jupyter notebooks tracked by git""" - -# pylint: disable=wrong-import-position -# https://bugs.python.org/issue37373 -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 pathlib -import warnings - -import nbformat -import pint -import pytest -from git.cmd import Git - -from .utils import find_files - -with warnings.catch_warnings(): - warnings.filterwarnings("ignore") - from nbconvert.preprocessors import ExecutePreprocessor - -SI = pint.UnitRegistry() - - -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 - - -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')""" - - -@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() - - -def test_file_size(notebook_filename): - """checks if all example Jupyter notebooks have file size less than an arbitrary limit""" - assert os.stat(notebook_filename).st_size * SI.byte < 2 * SI.megabyte - - -def test_no_errors_or_warnings_in_output(notebook_filename): - """checks if all example Jupyter notebooks have clear std-err output - (i.e., no errors or warnings) visible; with exception of acceptable - diagnostics from the joblib package""" - with open(notebook_filename, encoding="utf8") as notebook_file: - notebook = nbformat.read(notebook_file, nbformat.NO_CONVERT) - for cell in notebook.cells: - if cell.cell_type == "code": - for output in cell.outputs: - if (output.get("name") == "stderr") or ( - output.get("output_type") in ("error", "pyerr") - ): - if not output["text"].startswith("[Parallel(n_jobs="): - raise AssertionError(output["text"]) - - -def test_jetbrains_bug_py_66491(notebook_filename): - """checks if all notebooks have the execution_count key for each cell in JSON what is - required by GitHub renderer and what happens not to be the case if generating the notebook - using buggy versions of PyCharm: https://youtrack.jetbrains.com/issue/PY-66491""" - with open(notebook_filename, encoding="utf8") as notebook_file: - notebook = nbformat.read(notebook_file, nbformat.NO_CONVERT) - for cell in notebook.cells: - if cell.cell_type == "code" and not hasattr(cell, "execution_count"): - raise AssertionError( - "notebook cell is missing the execution_count attribute" - + " (could be due to a bug in PyCharm," - + " see https://youtrack.jetbrains.com/issue/PY-66491 )" - ) - - -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(abslute_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(abslute_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 test_cell_contains_output(notebook_filename): - """checks if all notebook cells have an output present""" - with open(notebook_filename, encoding="utf8") as fp: - nb = nbformat.read(fp, nbformat.NO_CONVERT) - for cell in nb.cells: - if cell.cell_type == "code" and cell.source != "": - assert cell.execution_count is not None - - -def test_show_plot_used_instead_of_matplotlib(notebook_filename): - """checks if plotting is done with open_atmos_jupyter_utils show_plot()""" - with open(notebook_filename, encoding="utf8") as fp: - nb = nbformat.read(fp, nbformat.NO_CONVERT) - matplot_used = False - show_plot_used = False - for cell in nb.cells: - if cell.cell_type == "code": - if ( - "pyplot.show()" in cell.source - or "plt.show()" in cell.source - or "from matplotlib import pyplot" 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 AssertionError( - "if using matplotlib, please use open_atmos_jupyter_utils.show_plot()" - ) - - -def test_show_anim_used_instead_of_matplotlib(notebook_filename): - """checks if animation generation is done with open_atmos_jupyter_utils show_anim()""" - with open(notebook_filename, encoding="utf8") as fp: - nb = nbformat.read(fp, nbformat.NO_CONVERT) - matplot_used = False - show_anim_used = False - for cell in nb.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()""" - ) diff --git a/test_todos_annotated.py b/test_todos_annotated.py deleted file mode 100644 index bfd2684..0000000 --- a/test_todos_annotated.py +++ /dev/null @@ -1,72 +0,0 @@ -"""utilities to ensure all TO-DO comments in the code are annotated -with an id of an open GitHub issue""" - -import os -import re - -import pytest -from binaryornot.check import is_binary -from ghapi.all import GhApi, paged -from git.cmd import Git - -from .utils import find_files - - -def _grep(filepath, regex): - reg_obj = re.compile(regex) - res = [] - with open(filepath, encoding="utf8") as file_lines: - for line in file_lines: - if reg_obj.match(line): - res.append(line) - return res - - -@pytest.fixture( - params=find_files(), - name="git_tracked_file", -) -def _git_tracked_file(request): - return request.param - - -@pytest.fixture(scope="session", name="gh_issues") -def _gh_issues(): - res = {} - repo = os.path.basename(Git(".").rev_parse("--show-toplevel")) - api = GhApi(owner="open-atmos", repo=repo) - pages = paged( - api.issues.list_for_repo, - owner="open-atmos", - repo=repo, - state="all", - per_page=100, - ) - for page in pages: - for item in page.items: - res[item.number] = item.state - return res - - -def test_todos_annotated(git_tracked_file, gh_issues): - """raises assertion errors if a (TODO|FIXME) is not annotated or if the annotation - does not point to an open issue""" - if is_binary(git_tracked_file): - pytest.skip("binary file") - for line in _grep(git_tracked_file, r".*(TODO|FIXME).*"): - if "(TODO|FIXME)" in line: - continue - match = re.search(r"(TODO|FIXME) #(\d+)", line) - if match is None: - raise AssertionError(f"(TODO|FIXME) not annotated with issue id ({line})") - giving_up_with_hope_other_builds_did_it = len(gh_issues) == 0 - if not giving_up_with_hope_other_builds_did_it: - number = int(match.group(2)) - if number not in gh_issues.keys(): - raise AssertionError( - f"(TODO|FIXME) annotated with non-existent id ({line})" - ) - if gh_issues[number] != "open": - raise AssertionError( - f"(TODO|FIXME) remains for a non-open issue ({line})" - ) diff --git a/utils.py b/utils.py deleted file mode 100644 index 50e0cf6..0000000 --- a/utils.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Utils functions to reuse in different parts of the codebase -""" - -import os - -from git.cmd import Git - - -def find_files(path_to_folder_from_project_root=".", file_extension=None): - """ - Returns all files in a current git repo. - The list of returned files may be filtered with `file_extension` param. - """ - all_files = [ - path - for path in Git( - Git(path_to_folder_from_project_root).rev_parse("--show-toplevel") - ) - .ls_files() - .split("\n") - if os.path.isfile(path) - ] - if file_extension is not None: - return list(filter(lambda path: path.endswith(file_extension), all_files)) - - return all_files