diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7ed85fc..a1303a2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,10 @@ updates: schedule: # Check for updates to GitHub Actions every weekday interval: "daily" + groups: + dependencies: + patterns: + - "*" labels: - "new: pull request" - "bot" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b3fd086..237baf6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,53 +1,105 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks # See https://pre-commit.ci/#configuration +# See https://github.com/scientific-python/cookie#sp-repo-review ci: autofix_prs: false + autoupdate_commit_msg: "chore: update pre-commit hooks" + + +# Alphabetised, for lack of a better order. +files: | + (?x)( + benchmarks\/.+\.py| + docs\/.+\.py| + lib\/.+\.py| + noxfile\.py| + pyproject\.toml| + setup\.py| + src\/.+\.py + ) +minimum_pre_commit_version: 1.21.0 + repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.6.0" - hooks: - # Prevent giant files from being committed. - - id: check-added-large-files - # Check whether files parse as valid Python. - - id: check-ast - # Check for file name conflicts on case-insensitive file-systems. - - id: check-case-conflict - # Check for files that contain merge conflict strings. - - id: check-merge-conflict - # Check for debugger imports and py37+ `breakpoint()` calls in Python source. - - id: debug-statements - # Check TOML file syntax. - - id: check-toml - # Check YAML file syntax. - - id: check-yaml - # Makes sure files end in a newline and only a newline - - id: end-of-file-fixer - exclude: nae.20100104-06_0001_0001.pp - # Replaces or checks mixed line ending - - id: mixed-line-ending - exclude: nae.20100104-06_0001_0001.pp - # Don't commit to main branch. - #- id: no-commit-to-branch - # Trims trailing whitespace - - id: trailing-whitespace - exclude: nae.20100104-06_0001_0001.pp - - - repo: https://github.com/codespell-project/codespell - rev: "v2.3.0" - hooks: - - id: codespell - types_or: [python, markdown] - additional_dependencies: [tomli] - - - repo: https://github.com/aio-libs/sort-all - rev: "v1.2.0" - hooks: - - id: sort-all +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + # Prevent giant files from being committed. + - id: check-added-large-files + # Check whether files parse as valid Python. + - id: check-ast + # Check for file name conflicts on case-insensitive filesystems. + - id: check-case-conflict + # Check for files that contain merge conflict strings. + - id: check-merge-conflict + # Check for debugger imports and py37+ `breakpoint()` calls in Python source. + - id: debug-statements + # Check TOML file syntax. + - id: check-toml + # Check YAML file syntax. + - id: check-yaml + # Makes sure files end in a newline and only a newline. + # Duplicates Ruff W292 but also works on non-Python files. + - id: end-of-file-fixer + # Replaces or checks mixed line ending. + - id: mixed-line-ending + # Don't commit to main branch. + - id: no-commit-to-branch + # Trims trailing whitespace. + # Duplicates Ruff W291 but also works on non-Python files. + - id: trailing-whitespace + +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.4.4" + hooks: + - id: ruff types: [file, python] + args: [--fix, --show-fixes] + - id: ruff-format + types: [file, python] + +- repo: https://github.com/codespell-project/codespell + rev: "v2.2.6" + hooks: + - id: codespell + types_or: [asciidoc, python, markdown, rst] + additional_dependencies: [tomli] + +- repo: https://github.com/adamchainz/blacken-docs + rev: 1.16.0 + hooks: + - id: blacken-docs + types: [file, rst] - - repo: https://github.com/abravalheri/validate-pyproject +- repo: https://github.com/aio-libs/sort-all + rev: v1.2.0 + hooks: + - id: sort-all + types: [file, python] + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v1.10.0' + hooks: + - id: mypy + exclude: 'src\/mo_pack\/tests' + +- repo: https://github.com/abravalheri/validate-pyproject + # More exhaustive than Ruff RUF200. rev: "v0.18" hooks: - - id: validate-pyproject + - id: validate-pyproject + +- repo: https://github.com/scientific-python/cookie + rev: 2024.04.23 + hooks: + - id: sp-repo-review + additional_dependencies: ["repo-review[cli]"] + args: ["--show=errskip"] + +- repo: https://github.com/numpy/numpydoc + rev: v1.7.0 + hooks: + - id: numpydoc-validation + types: [file, python] + exclude: 'src\/mo_pack\/tests' \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8c57577..a9d3641 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,6 @@ requires = [ "oldest-supported-numpy", "setuptools>=64", "setuptools_scm[toml]>=8", - "wheel", ] # defined by PEP-517 build-backend = "setuptools.build_meta" @@ -52,9 +51,12 @@ ignore = [ [tool.pytest.ini_options] -addopts = ["-ra"] +addopts = ["-ra", "--strict-config", "--strict-markers"] +filterwarnings = ["error"] +log_cli_level = "INFO" minversion = "6.0" testpaths = "src/mo_pack" +xfail_strict = true [tool.setuptools] @@ -79,3 +81,116 @@ mo_pack = ["tests/test_data/*.pp"] write_to = "src/mo_pack/_version.py" local_scheme = "dirty-tag" version_scheme = "release-branch-semver" + + +[tool.numpydoc_validation] +checks = [ + "all", # Enable all numpydoc validation rules, apart from the following: + + # -> Docstring text (summary) should start in the line immediately + # after the opening quotes (not in the same line, or leaving a + # blank line in between) + "GL01", # Permit summary line on same line as docstring opening quotes. + + # -> Closing quotes should be placed in the line after the last text + # in the docstring (do not close the quotes in the same line as + # the text, or leave a blank line between the last text and the + # quotes) + "GL02", # Permit a blank line before docstring closing quotes. + + # -> Double line break found; please use only one blank line to + # separate sections or paragraphs, and do not leave blank lines + # at the end of docstrings + "GL03", # Ignoring. + + # -> See Also section not found + "SA01", # Not all docstrings require a "See Also" section. + + # -> No extended summary found + "ES01", # Not all docstrings require an "Extended Summary" section. + + # -> No examples section found + "EX01", # Not all docstrings require an "Examples" section. + + # -> No Yields section found + "YD01", # Not all docstrings require a "Yields" section. +] +exclude = [ + '\.__eq__$', + '\.__ne__$', + '\.__repr__$', +] + +[tool.repo-review] +ignore = [ + # https://learn.scientific-python.org/development/guides/style/#PC170 + "PC170", # Uses PyGrep hooks + # https://learn.scientific-python.org/development/guides/style/#PC180 + "PC180", # Uses prettier + # https://learn.scientific-python.org/development/guides/packaging-simple/#PY004 + "PY004", # Has docs folder + # https://learn.scientific-python.org/development/guides/docs/#readthedocsyaml + "RTD", # ReadTheDocs + + "PY007", # TODO: Tox adoption blocked by SciTools/iris#5184. +] + +[tool.mypy] +# See https://mypy.readthedocs.io/en/stable/config_file.html +ignore_missing_imports = true +warn_unused_configs = true +warn_unreachable = true +enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] +exclude = [ + "src\\/mo_pack\\/tests$", +] +strict = true + +[tool.ruff] +line-length = 88 +src = ["src"] + +[tool.ruff.format] +preview = false + +[tool.ruff.lint] +ignore = [ + # NOTE: Non-permanent exclusions should be added to the ".ruff.toml" file. + + # flake8-commas (COM) + # https://docs.astral.sh/ruff/rules/#flake8-commas-com + "COM812", # Trailing comma missing. + "COM819", # Trailing comma prohibited. + + # flake8-implicit-str-concat (ISC) + # https://docs.astral.sh/ruff/rules/single-line-implicit-string-concatenation/ + # NOTE: This rule may cause conflicts when used with "ruff format". + "ISC001", # Implicitly concatenate string literals on one line. + ] + preview = false + select = [ + "ALL", + # list specific rules to include that is skipped using numpy convention. + "D212", # Multi-line docstring summary should start at the first line + ] + +[tool.ruff.lint.isort] +force-sort-within-sections = true + +[tool.ruff.lint.per-file-ignores] +# All test scripts. + +"src/mo_pack/tests/*.py" = [ + # https://docs.astral.sh/ruff/rules/undocumented-public-module/ + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D205", # 1 blank line required between summary line and description + "D401", # 1 First line of docstring should be in imperative mood + + "ANN", # https://docs.astral.sh/ruff/rules/#flake8-annotations-ann + "S101", # Use of assert detected +] + +[tool.ruff.lint.pydocstyle] +convention = "numpy" diff --git a/setup.py b/setup.py index 18b9d0e..4dd447d 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,13 @@ -from setuptools import Command, Extension, setup +"""Register the Cython extension.""" + +from __future__ import annotations from pathlib import Path +from typing import ClassVar from Cython.Build import cythonize import numpy as np +from setuptools import Command, Extension, setup BASE_DIR = Path(__file__).resolve().parent PACKAGE_NAME = "mo_pack" @@ -11,21 +15,24 @@ PACKAGE_DIR = SRC_DIR / PACKAGE_NAME -class CleanCython(Command): +class CleanCython(Command): # type: ignore[misc] + """Command for purging artifacts built by Cython.""" + description = "Purge artifacts built by Cython" - user_options = [] + user_options: ClassVar[list[tuple[str, str, str]]] = [] - def initialize_options(self): - pass + def initialize_options(self: CleanCython) -> None: + """Set options/attributes/caches used by the command to default values.""" - def finalize_options(self): - pass + def finalize_options(self: CleanCython) -> None: + """Set final values for all options/attributes used by the command.""" - def run(self): + def run(self: CleanCython) -> None: + """Execute the actions intended by the command.""" for path in PACKAGE_DIR.rglob("*"): if path.suffix in (".pyc", ".pyo", ".c", ".so"): msg = f"clean: removing file {path}" - print(msg) + print(msg) # noqa: T201 path.unlink() diff --git a/src/mo_pack/__init__.py b/src/mo_pack/__init__.py index a0a7075..279947d 100644 --- a/src/mo_pack/__init__.py +++ b/src/mo_pack/__init__.py @@ -2,10 +2,28 @@ # # This file is part of mo_pack and is released under the BSD license. # See LICENSE in the root of the repository for full licensing details. -from ._packing import (compress_rle, compress_wgdos, - decompress_rle, decompress_wgdos) +"""Python bindings for the C library **libmo_unpack**. + +Provides Python bindings to the C library +`libmo_unpack `_ , which contains +packing methods used to encode and decode the data payloads of Met Office UM +Post-Processing and Fields files. + +Supports both RLE and WGDOS encoding methods. + +""" + +from ._packing import compress_rle, compress_wgdos, decompress_rle, decompress_wgdos try: from ._version import version as __version__ except ModuleNotFoundError: __version__ = "unknown" + +__all__ = [ + "__version__", + "compress_rle", + "compress_wgdos", + "decompress_rle", + "decompress_wgdos", +] diff --git a/src/mo_pack/tests/test_compress_rle.py b/src/mo_pack/tests/test_compress_rle.py index d50d65e..cb06fa0 100644 --- a/src/mo_pack/tests/test_compress_rle.py +++ b/src/mo_pack/tests/test_compress_rle.py @@ -4,38 +4,32 @@ # See LICENSE in the root of the repository for full licensing details. """Tests for the `mo_pack.compress_rle` function.""" -import unittest - import numpy as np +import pytest from mo_pack import compress_rle -class Test(unittest.TestCase): +class Test: def test_no_mdi(self): data = np.arange(42, dtype=np.float32).reshape(7, 6) compressed_data = compress_rle(data) - expected = np.arange(42, dtype='f4') - expected.byteswap(True) - self.assertEqual(compressed_data, expected.data) + expected = np.arange(42, dtype="f4") + expected.byteswap(inplace=True) + assert compressed_data == expected.data def test_mdi(self): data = np.arange(12, dtype=np.float32).reshape(3, 4) + 5 data[1, 1:] = 999 compressed_data = compress_rle(data, missing_data_indicator=999) - expected = np.array([5, 6, 7, 8, 9, 999, 3, 13, 14, 15, 16], - dtype='f4') - expected.byteswap(True) - self.assertEqual(compressed_data, expected.data) + expected = np.array([5, 6, 7, 8, 9, 999, 3, 13, 14, 15, 16], dtype="f4") + expected.byteswap(inplace=True) + assert compressed_data == expected.data def test_mdi_larger(self): # Check that everything still works if the compressed data are # *larger* than the original data. data = np.arange(12, dtype=np.float32).reshape(3, 4) + 5 data[data % 2 == 0] = 666 - with self.assertRaises(ValueError): + with pytest.raises(ValueError, match="WGDOS exit code was non-zero"): compress_rle(data, missing_data_indicator=666) - - -if __name__ == '__main__': - unittest.main() diff --git a/src/mo_pack/tests/test_decompress_rle.py b/src/mo_pack/tests/test_decompress_rle.py index 9f3e80d..d54d9af 100644 --- a/src/mo_pack/tests/test_decompress_rle.py +++ b/src/mo_pack/tests/test_decompress_rle.py @@ -4,43 +4,36 @@ # See LICENSE in the root of the repository for full licensing details. """Tests for the `mo_pack.decompress_rle` function.""" -import unittest - import numpy as np from numpy.testing import assert_array_equal +import pytest from mo_pack import decompress_rle -class Test(unittest.TestCase): +class Test: def test_no_mdi(self): # The input to decompress_rle must be big-endian 32-bit floats. - src_buffer = np.arange(12, dtype='>f4').data + src_buffer = np.arange(12, dtype=">f4").data result = decompress_rle(src_buffer, 3, 4) assert_array_equal(result, np.arange(12).reshape(3, 4)) - self.assertEqual(result.dtype, '=f4') + assert result.dtype == np.dtype("=f4") def test_mdi(self): # The input to decompress_rle must be big-endian 32-bit floats. - src_floats = np.arange(11, dtype='>f4') + src_floats = np.arange(11, dtype=">f4") src_floats[5] = 666 src_floats[6] = 3 src_buffer = src_floats.data result = decompress_rle(src_buffer, 3, 4, missing_data_indicator=666) - assert_array_equal(result, [[0, 1, 2, 3], - [4, 666, 666, 666], - [7, 8, 9, 10]]) + assert_array_equal(result, [[0, 1, 2, 3], [4, 666, 666, 666], [7, 8, 9, 10]]) def test_not_enough_source_data(self): - src_buffer = np.arange(4, dtype='>f4').data - with self.assertRaises(ValueError): + src_buffer = np.arange(4, dtype=">f4").data + with pytest.raises(ValueError, match="RLE exit code was non-zero"): decompress_rle(src_buffer, 5, 6) def test_too_much_source_data(self): - src_buffer = np.arange(10, dtype='>f4').data - with self.assertRaises(ValueError): + src_buffer = np.arange(10, dtype=">f4").data + with pytest.raises(ValueError, match="RLE exit code was non-zero"): decompress_rle(src_buffer, 2, 3) - - -if __name__ == '__main__': - unittest.main() diff --git a/src/mo_pack/tests/test_rle.py b/src/mo_pack/tests/test_rle.py index 5a81edb..c2263d8 100644 --- a/src/mo_pack/tests/test_rle.py +++ b/src/mo_pack/tests/test_rle.py @@ -2,28 +2,23 @@ # # This file is part of mo_pack and is released under the BSD license. # See LICENSE in the root of the repository for full licensing details. -""" -Integration tests for the `mo_pack.compress_rle` and +"""Integration tests for the `mo_pack.compress_rle` and `mo_pack.decompress_rle` functions. """ -import unittest - import numpy as np from numpy.testing import assert_array_equal from mo_pack import compress_rle, decompress_rle - MDI = 999 -class Test(unittest.TestCase): +class Test: def _test(self, original, rows, cols): compressed_data = compress_rle(original, missing_data_indicator=MDI) - result = decompress_rle(compressed_data, rows, cols, - missing_data_indicator=MDI) + result = decompress_rle(compressed_data, rows, cols, missing_data_indicator=MDI) assert_array_equal(result, original) def test_no_mdi(self): @@ -34,7 +29,3 @@ def test_mdi(self): data = np.arange(12, dtype=np.float32).reshape(3, 4) + 5 data[1, 1:] = 999 self._test(data, 3, 4) - - -if __name__ == '__main__': - unittest.main() diff --git a/src/mo_pack/tests/test_wgdos.py b/src/mo_pack/tests/test_wgdos.py index eff5909..16bf07e 100644 --- a/src/mo_pack/tests/test_wgdos.py +++ b/src/mo_pack/tests/test_wgdos.py @@ -2,27 +2,24 @@ # # This file is part of mo_pack and is released under the BSD license. # See LICENSE in the root of the repository for full licensing details. -""" -Tests for the `mo_pack.compress_wgdos` and `mo_pack.decompress_wgdos` +"""Tests for the `mo_pack.compress_wgdos` and `mo_pack.decompress_wgdos` functions. """ -import os -import unittest +from pathlib import Path import numpy as np -from numpy.testing import assert_array_equal, assert_almost_equal +from numpy.testing import assert_almost_equal, assert_array_equal +import pytest import mo_pack -class TestPackWGDOS(unittest.TestCase): - def assert_equal_when_decompressed(self, compressed_data, expected_array, - mdi=0): +class TestPackWGDOS: + def assert_equal_when_decompressed(self, compressed_data, expected_array, mdi=0): x, y = expected_array.shape - decompressed_data = mo_pack.decompress_wgdos( - compressed_data, x, y, mdi) + decompressed_data = mo_pack.decompress_wgdos(compressed_data, x, y, mdi) np.testing.assert_array_equal(decompressed_data, expected_array) def test_pack_wgdos(self): @@ -32,29 +29,33 @@ def test_pack_wgdos(self): def test_mdi(self): data = np.arange(12, dtype=np.float32).reshape(3, 4) - compressed_data = mo_pack.compress_wgdos(data, - missing_data_indicator=4.0) + compressed_data = mo_pack.compress_wgdos(data, missing_data_indicator=4.0) expected_data = data data[1, 0] = 4.0 - self.assert_equal_when_decompressed(compressed_data, data, mdi=4.0) + self.assert_equal_when_decompressed(compressed_data, expected_data, mdi=4.0) def test_accuracy(self): - data = np.array([[0.1234, 0.2345, 0.3456], [0.4567, 0.5678, 0.6789]], - dtype=np.float32) + data = np.array( + [[0.1234, 0.2345, 0.3456], [0.4567, 0.5678, 0.6789]], dtype=np.float32 + ) compressed = mo_pack.compress_wgdos(data, accuracy=-4) decompressed_data = mo_pack.decompress_wgdos(compressed, 2, 3) - expected = np.array([[0.12340003, 0.18590003, 0.34560001], - [0.40810001, 0.56779999, 0.63029999]], - dtype=np.float32) + expected = np.array( + [ + [0.12340003, 0.18590003, 0.34560001], + [0.40810001, 0.56779999, 0.63029999], + ], + dtype=np.float32, + ) np.testing.assert_array_equal(decompressed_data, expected) -class TestdecompressWGDOS(unittest.TestCase): +class TestdecompressWGDOS: def test_incorrect_size(self): data = np.arange(77, dtype=np.float32).reshape(7, 11) compressed_data = mo_pack.compress_wgdos(data) - with self.assertRaises(ValueError): - decompressed_data = mo_pack.decompress_wgdos(compressed_data, 5, 6) + with pytest.raises(ValueError, match="WGDOS exit code was non-zero"): + _ = mo_pack.decompress_wgdos(compressed_data, 5, 6) def test_different_shape(self): data = np.arange(24, dtype=np.float32).reshape(8, 3) @@ -63,19 +64,16 @@ def test_different_shape(self): np.testing.assert_array_equal(decompressed_data, data.reshape(4, 6)) def test_real_data(self): - test_dir = os.path.dirname(os.path.abspath(__file__)) - fname = os.path.join(test_dir, 'test_data', - 'nae.20100104-06_0001_0001.pp') - with open(fname, 'rb') as fh: + test_dir = Path(__file__).parent.resolve() + fname = test_dir / "test_data" / "nae.20100104-06_0001_0001.pp" + with fname.open("rb") as fh: fh.seek(268) data = mo_pack.decompress_wgdos(fh.read(339464), 360, 600) assert_almost_equal(data.mean(), 130.84694, decimal=1) - expected = [[388.78125, 389.46875, 384.0625, 388.46875], - [388.09375, 381.375, 374.28125, 374.875], - [382.34375, 373.671875, 371.171875, 368.25], - [385.265625, 373.921875, 368.5, 365.3125]] + expected = [ + [388.78125, 389.46875, 384.0625, 388.46875], + [388.09375, 381.375, 374.28125, 374.875], + [382.34375, 373.671875, 371.171875, 368.25], + [385.265625, 373.921875, 368.5, 365.3125], + ] assert_array_equal(data[:4, :4], expected) - - -if __name__ == '__main__': - unittest.main()