Skip to content

Commit

Permalink
TST: reintroduce coverage measurement and improve coverage to 100% (e…
Browse files Browse the repository at this point in the history
…xcluding defensive programming lines)
  • Loading branch information
neutrinoceros committed Oct 27, 2024
1 parent b455ba4 commit 2a3df9d
Show file tree
Hide file tree
Showing 5 changed files with 125 additions and 66 deletions.
44 changes: 42 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 21 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
86 changes: 45 additions & 41 deletions src/cmasher/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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]

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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"))
Expand Down
35 changes: 16 additions & 19 deletions src/cmasher/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down

0 comments on commit 2a3df9d

Please sign in to comment.