diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef3aa8e..1065b60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,8 @@ on: - main jobs: - test: - name: Test + python_test: + name: Python tests runs-on: ${{ matrix.os }} strategy: matrix: @@ -42,30 +42,32 @@ jobs: run: | pytest - packaging: - name: Packaging + c_python_test: + name: CPython interface tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - name: Set up Python + uses: actions/setup-python@v5 with: python-version: '3.11' - - name: Install dependencies + - name: Install system dependencies + run: | + sudo apt install -y gcovr + - name: Install python dependencies run: | python -m pip install --upgrade pip - python -m pip install build twine validate-pyproject[all] - - name: Check and install package + python -m pip install numpy pytest + - name: Build module with coverage run: | - validate-pyproject pyproject.toml - python -m build - python -m twine check --strict dist/* - python -m pip install dist/*.whl - - name: Check vcztools CLI + # Build the extension module in-place so pytest can find it + CFLAGS="--coverage" python3 setup.py build_ext --inplace + - name: Run tests run: | - vcztools --help - # Make sure we don't have ``vcztools`` in the CWD - cd tests - python -m vcztools --help + pytest -vs tests/test_cpython_interface.py + - name: Show coverage + run: | + gcovr --filter vcztools c_test: name: C tests @@ -88,7 +90,33 @@ jobs: run: | ninja -C build coverage-text cat build/meson-logs/coverage.txt - - name: Valgrind + - name: Valgrind working-directory: ./lib run: | valgrind --leak-check=full --error-exitcode=1 ./build/tests + + packaging: + name: Packaging + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install build twine validate-pyproject[all] + - name: Check and install package + run: | + validate-pyproject pyproject.toml + python -m build + python -m twine check --strict dist/* + python -m pip install dist/*.whl + - name: Check vcztools CLI + run: | + vcztools --help + # Make sure we don't have ``vcztools`` in the CWD + cd tests + python -m vcztools --help + diff --git a/tests/test_cpython_interface.py b/tests/test_cpython_interface.py new file mode 100644 index 0000000..f406c5a --- /dev/null +++ b/tests/test_cpython_interface.py @@ -0,0 +1,53 @@ +import pytest +import numpy as np + +from vcztools import _vcztools + +FIXED_FIELD_NAMES = ["chrom", "pos", "id", "qual", "ref", "alt", "filter"] + + +def example_fixed_data(num_variants, num_samples=0): + chrom = np.array(["X"] * num_variants, dtype="S") + pos = np.arange(num_variants, dtype=np.int32) + id = np.array(["."] * num_variants, dtype="S").reshape((num_variants, 1)) + ref = np.array(["A"] * num_variants, dtype="S") + alt = np.array(["T"] * num_variants, dtype="S").reshape((num_variants, 1)) + qual = np.arange(num_variants, dtype=np.float32) + filter_ = np.ones(num_variants, dtype=bool).reshape((num_variants, 1)) + filter_id = np.array(["PASS"], dtype="S") + return { + "chrom": chrom, + "pos": pos, + "id": id, + "qual": qual, + "ref": ref, + "alt": alt, + "filter": filter_, + "filter_ids": filter_id, + } + + +class TestTypeChecking: + @pytest.mark.parametrize("name", FIXED_FIELD_NAMES) + def test_field_incorrect_length(self, name): + num_variants = 5 + data = example_fixed_data(num_variants) + data[name] = data[name][1:] + with pytest.raises(ValueError, match=f"Array {name.upper()} must have "): + _vcztools.VcfEncoder(num_variants, 0, **data) + + @pytest.mark.parametrize("name", FIXED_FIELD_NAMES) + def test_field_incorrect_dtype(self, name): + num_variants = 5 + data = example_fixed_data(num_variants) + data[name] = np.zeros(data[name].shape, dtype=np.int64) + with pytest.raises(ValueError, match=f"Wrong dtype for {name.upper()}"): + _vcztools.VcfEncoder(num_variants, 0, **data) + + @pytest.mark.parametrize("name", FIXED_FIELD_NAMES) + def test_field_incorrect_type(self, name): + num_variants = 5 + data = example_fixed_data(num_variants) + data[name] = "A Python string" + with pytest.raises(TypeError, match=f"must be numpy.ndarray"): + _vcztools.VcfEncoder(num_variants, 0, **data) diff --git a/vcztools/_vcztoolsmodule.c b/vcztools/_vcztoolsmodule.c index 077b782..e9c484d 100644 --- a/vcztools/_vcztoolsmodule.c +++ b/vcztools/_vcztoolsmodule.c @@ -119,7 +119,7 @@ VcfEncoder_store_fixed_array(VcfEncoder *self, PyArrayObject *array, const char goto out; } if (PyArray_DTYPE(array)->type_num != type) { - PyErr_Format(PyExc_ValueError, "Array %s is not of the correct type", name); + PyErr_Format(PyExc_ValueError, "Wrong dtype for %s", name); goto out; } @@ -189,7 +189,7 @@ VcfEncoder_init(VcfEncoder *self, PyObject *args, PyObject *kwds) goto out; } - /* NOTE: we generalise this pattern for CHROM also to save a bit of time + /* NOTE: we could generalise this pattern for CHROM also to save a bit of time * in building numpy String arrays */ assert(PyArray_CheckExact(filter_ids)); if (!PyArray_CHKFLAGS(filter_ids, NPY_ARRAY_IN_ARRAY)) { @@ -209,7 +209,7 @@ VcfEncoder_init(VcfEncoder *self, PyObject *args, PyObject *kwds) if (VcfEncoder_add_array(self, "IDS/", "FILTER", filter_ids) != 0) { goto out; } - if (VcfEncoder_store_fixed_array(self, filter, "filter", NPY_BOOL, 2, num_variants) + if (VcfEncoder_store_fixed_array(self, filter, "FILTER", NPY_BOOL, 2, num_variants) != 0) { goto out; }