From 2a3df9d62fe916ef0fcf7c08a4e185905353266c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Robert?= Date: Sun, 27 Oct 2024 13:53:41 +0100 Subject: [PATCH] TST: reintroduce coverage measurement and improve coverage to 100% (excluding defensive programming lines) --- .github/workflows/test.yml | 44 ++++++++++++++++- conftest.py | 2 +- pyproject.toml | 24 +++++++-- src/cmasher/tests/test_utils.py | 86 +++++++++++++++++---------------- src/cmasher/utils.py | 35 ++++++-------- 5 files changed, 125 insertions(+), 66 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c151add8..5ed047c7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,9 +72,49 @@ jobs: shell: bash # for windows-compat run: | source .venv/${{matrix.venv-loc}}/activate - coverage run -m pytest --color=yes --mpl + coverage run --parallel-mode -m pytest --color=yes --mpl - # TODO: add coverage report + upload step (use parallel mode) + - name: Upload coverage data + # only using reports from ubuntu because + # combining reports from multiple platforms is tricky (or impossible ?) + if: startsWith( matrix.os, 'ubuntu' ) + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: cmasher_coverage_data-${{ matrix.os }}-${{ matrix.python-version }} + path: .coverage.* + if-no-files-found: ignore + include-hidden-files: true + + + coverage: + name: Combine & check coverage + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: astral-sh/setup-uv@3b9817b1bf26186f03ab8277bab9b827ea5cc254 # v3.2.0 + - run: | # uv sync --only-group covcheck + uv venv + uv pip install . -r requirements/dev.txt + + - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + pattern: cmasher_coverage_data-* + merge-multiple: true + + - name: Check coverage + run: | + uv run coverage combine + uv run coverage html --skip-covered --skip-empty + uv run coverage report --fail-under=100 + + - name: Upload HTML report if check failed. + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: cmasher_coverage_report + path: htmlcov + if: ${{ failure() }} type-check: name: type check w/ Python ${{ matrix.python-version }} diff --git a/conftest.py b/conftest.py index d191f7b7..c0255e88 100644 --- a/conftest.py +++ b/conftest.py @@ -28,7 +28,7 @@ def pytest_sessionstart(session): def pytest_sessionfinish(session, exitstatus): AT_END = set(mpl.colormaps.keys()) - if diff := (AT_END - AT_START): + if diff := (AT_END - AT_START): # pragma: no cover print( f"The following colormaps appear to have leaked during test session {diff}", file=sys.stderr, diff --git a/pyproject.toml b/pyproject.toml index 3b0a94d9..90dccd6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,11 +64,29 @@ filterwarnings = [ 'ignore:datetime\.datetime\.utcfromtimestamp\(\) is deprecated:DeprecationWarning', ] +[tool.coverage.report] +show_missing = true +skip_covered = true +exclude_lines = [ + # a more strict default pragma + "\\# pragma: no cover\\b", + + # allow defensive code + "^\\s*raise NotImplementedError\\b", + + # typing-related code + "^if TYPE_CHECKING:", + ": \\.\\.\\.(\\s*#.*)?$", + "^ +\\.\\.\\.$", + "-> ['\"]?NoReturn['\"]?:", +] + [tool.coverage.run] -include = ["cmasher/*"] omit = [ - "cmasher/__version__*", - "cmasher/app_usage.py", + "scripts/*", + "src/cmasher/__version__*", + "src/cmasher/app_usage.py", + "*_test_copy.py", ] [tool.ruff.lint] diff --git a/src/cmasher/tests/test_utils.py b/src/cmasher/tests/test_utils.py index 1c0769d3..70406573 100644 --- a/src/cmasher/tests/test_utils.py +++ b/src/cmasher/tests/test_utils.py @@ -1,8 +1,10 @@ # %% IMPORTS # Built-in imports import os +import shutil from importlib.util import find_spec, module_from_spec, spec_from_file_location from os import path +from pathlib import Path # Package imports import matplotlib as mpl @@ -38,12 +40,14 @@ def _MPL38_colormap_eq(cmap, other) -> bool: # had the exact same name, which is not what we care about here from matplotlib.colors import Colormap - if not isinstance(other, Colormap) or cmap.colorbar_extend != other.colorbar_extend: + if ( + not isinstance(other, Colormap) or cmap.colorbar_extend != other.colorbar_extend + ): # pragma: no cover return False # To compare lookup tables the Colormaps have to be initialized if not cmap._isinit: cmap._init() - if not other._isinit: # type: ignore [attr-defined] + if not other._isinit: # type: ignore [attr-defined] # pragma: no cover other._init() # type: ignore [attr-defined] return np.array_equal(cmap._lut, other._lut) # type: ignore [attr-defined] @@ -63,6 +67,7 @@ def _MPL38_colormap_eq(cmap, other) -> bool: # Obtain list of all colormaps registered in MPL mpl_cmaps = plt.colormaps() +mpl_cmaps_as_str: list[str] = list(mpl_cmaps) # %% PYTEST CLASSES AND FUNCTIONS @@ -186,21 +191,26 @@ def test_invalid_nodes(self, cmaps, nodes): # Pytest class for create_cmap_mod class Test_create_cmap_mod: - # Test if a standalone module of rainforest can be created - def test_standalone_rainforest(self): + # Test if a standalone module can be created + @pytest.mark.parametrize("name", ["rainforest", "infinity"]) + def test_standalone_copy(self, name, tmp_path): # Obtain the currently registered version of rainforest - cmap_old = mpl.colormaps["cmr.rainforest"] + cmap_old = mpl.colormaps[f"cmr.{name}"] # Create standalone module for rainforest - cmap_path = create_cmap_mod("rainforest", _copy_name="rainforest_copy") + cmap_path = create_cmap_mod( + name, + save_dir=tmp_path, + _copy_name=f"{name}_test_copy", + ) # Try to import this module - spec = spec_from_file_location("rainforest_copy", cmap_path) + spec = spec_from_file_location(f"{name}_test_copy", cmap_path) mod = module_from_spec(spec) spec.loader.exec_module(mod) # Check if the colormap in MPL has been updated - cmap_new = mpl.colormaps["cmr.rainforest_copy"] + cmap_new = mpl.colormaps[f"cmr.{name}_test_copy"] # identity equality isn't achievable since mpl.colormaps.__getitem__ # may return a copy @@ -214,39 +224,18 @@ def test_standalone_rainforest(self): # Check if the values in both colormaps are the same assert np.allclose(cmap_old.colors, cmap_new.colors) - # Test if a standalone module of infinity can be created - def test_standalone_infinity(self): - # Obtain the currently registered version of infinity - cmap_old = mpl.colormaps["cmr.infinity"] - - # Create standalone module for infinity - cmap_path = create_cmap_mod("infinity", _copy_name="inifinity_copy") - - # Try to import this module - spec = spec_from_file_location("infinity_copy", cmap_path) - mod = module_from_spec(spec) - spec.loader.exec_module(mod) - - # Check if the colormap in MPL has been updated - cmap_new = mpl.colormaps["cmr.infinity"] - if mpl.__version_info__ >= (3, 8): - assert cmap_old == cmap_new - else: - assert _MPL38_colormap_eq(cmap_old, cmap_new) - - assert cmap_old == cmap_new - - # Check if the values in both colormaps are the same - assert np.allclose(cmap_old.colors, cmap_new.colors) - - # Check that the shifted version of infinity also exists - assert "cmr.infinity_s" in plt.colormaps() + if name == "infinity": + # Check that the shifted version of infinity also exists + assert "cmr.infinity_s" in plt.colormaps() # Test if providing an invalid colormap name fails - def test_invalid_cmap(self): + def test_invalid_cmap(self, tmp_path): # Check if a ValueError is raised with pytest.raises(ValueError): - create_cmap_mod("this is an incorrect colormap name") + create_cmap_mod( + "this is an incorrect colormap name", + save_dir=tmp_path, + ) # Pytest class for create_cmap_overview @@ -267,13 +256,16 @@ def test_list_cat(self): create_cmap_overview(["cmr.rainforest"]) # Test if providing all MPL colormap objects works - def test_mpl_cmaps_objs(self): + @pytest.mark.parametrize("sort", ["perceptual", "lightness"]) + def test_mpl_cmaps_objs(self, sort): cmaps = map(mpl.colormaps.__getitem__, mpl_cmaps) - create_cmap_overview(cmaps, sort="perceptual") + create_cmap_overview(cmaps, sort=sort) # Test if providing all MPL colormap names works - def test_mpl_cmaps_names(self): - create_cmap_overview(mpl_cmaps, sort="lightness") + @pytest.mark.parametrize("cmaps", [mpl_cmaps, mpl_cmaps_as_str]) + @pytest.mark.parametrize("sort", ["perceptual", "lightness"]) + def test_mpl_cmaps_names(self, cmaps, sort): + create_cmap_overview(cmaps, sort=sort) # Test if the lightness profiles can be plotted def test_lightness_profiles(self): @@ -381,6 +373,18 @@ def test_cmap_file_npy(self): _skip_registration=True, ) + def test_resilience(self, tmp_path): + # check that, in the presence of a npy and a txt, + # import_cmaps ignores the second + src = Path(dirpath).parent.joinpath("colormaps", "cm_rainforest.npy") + shutil.copy(src, tmp_path / "cm_rainforest.npy") + shutil.copy(src, tmp_path / "cm_rainforest.txt") + + import_cmaps( + tmp_path, + _skip_registration=True, + ) + # Test if providing a cmap .txt-file with 8-bit values works def test_cmap_file_8bit(self): import_cmaps(path.join(dirpath, "data/cm_8bit.txt")) diff --git a/src/cmasher/utils.py b/src/cmasher/utils.py index 707d2e79..a245991c 100644 --- a/src/cmasher/utils.py +++ b/src/cmasher/utils.py @@ -7,7 +7,7 @@ # %% IMPORTS # Built-in imports -import sys +import os from collections import OrderedDict from collections.abc import Callable from glob import glob @@ -72,14 +72,16 @@ # %% HELPER FUNCTIONS # Define function for obtaining the sorting order for lightness ranking -def _get_cmap_lightness_rank(cmap: CMAP) -> tuple[int, int, float, float, float, str]: +def _get_cmap_lightness_rank( + cmap: Colormap, +) -> tuple[int, int, float, float, float, str]: """ Returns a tuple of objects used for sorting the provided `cmap` based on its lightness profile. Parameters ---------- - cmap : str or :obj:`~matplotlib.colors.Colormap` object + cmap : :obj:`~matplotlib.colors.Colormap` object The registered name of the colormap in :mod:`matplotlib.cm` or its corresponding :obj:`~matplotlib.colors.Colormap` object. @@ -105,9 +107,6 @@ def _get_cmap_lightness_rank(cmap: CMAP) -> tuple[int, int, float, float, float, """ # Obtain the colormap - if isinstance(cmap, str): - cmap = mpl.colormaps[cmap] - cm_type = get_cmap_type(cmap) # Determine lightness profile stats for sequential/diverging/cyclic @@ -177,7 +176,7 @@ def _get_cmap_lightness_rank(cmap: CMAP) -> tuple[int, int, float, float, float, # Define function for obtaining the sorting order for perceptual ranking def _get_cmap_perceptual_rank( - cmap: CMAP, + cmap: Colormap, ) -> tuple[int, int, float, float, float, float, str]: """ In addition to returning the lightness rank as given by @@ -187,7 +186,7 @@ def _get_cmap_perceptual_rank( Parameters ---------- - cmap : str or :obj:`~matplotlib.colors.Colormap` object + cmap : :obj:`~matplotlib.colors.Colormap` object The registered name of the colormap in :mod:`matplotlib.cm` or its corresponding :obj:`~matplotlib.colors.Colormap` object. @@ -205,9 +204,6 @@ def _get_cmap_perceptual_rank( """ # Obtain the colormap - if isinstance(cmap, str): - cmap = mpl.colormaps[cmap] - cm_type = get_cmap_type(cmap) # Determine perceptual range for sequential/diverging/cyclic @@ -342,7 +338,10 @@ def combine_cmaps( # This function creates a standalone module of a CMasher colormap def create_cmap_mod( - cmap: str, *, save_dir: str = ".", _copy_name: str | None = None + cmap: str, + *, + save_dir: str | os.PathLike[str] = ".", + _copy_name: str | None = None, ) -> str: """ Creates a standalone Python module of the provided *CMasher* `cmap` and @@ -361,7 +360,7 @@ def create_cmap_mod( Optional -------- - save_dir : str. Default: '.' + save_dir: os.PathLike Default: '.' The path to the directory where the module must be saved. By default, the current directory is used. @@ -911,7 +910,7 @@ def sort_key(x): fontsize=10, c=text_color, ) - else: + else: # pragma: no cover raise RuntimeError # If savefig is not None, save the figure @@ -1055,7 +1054,7 @@ def get_cmap_type(cmap: CMAP) -> str: # MISC 1 # If the colormap has only a single lightness, it is misc - elif np.allclose(diff_L, 0): + elif np.allclose(diff_L, 0): # pragma: no cover return "misc" # SEQUENTIAL @@ -1622,16 +1621,14 @@ def view_cmap( # If show_grayscale is True, show both plots instead of just one if show_grayscale: - if isinstance(ax, Axes): - # defensive programming + if isinstance(ax, Axes): # pragma: no cover raise RuntimeError ax[0].imshow(data, cmap=cmap, aspect="auto") ax[0].set_axis_off() ax[1].imshow(data, cmap=cmap_L, aspect="auto") ax[1].set_axis_off() else: - if not isinstance(ax, Axes): - # defensive programming + if not isinstance(ax, Axes): # pragma: no cover raise RuntimeError ax.imshow(data, cmap=cmap, aspect="auto") ax.set_axis_off()