From beb26273f8a82a8d62bc9425bb8d6059b36970b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 10:05:01 +0000 Subject: [PATCH 1/7] Fix CI and CD: twine metadata, mypy/numpy/python version clash Three related failures were making CI and CD red on main and on every open PR. CD: the "Distribution build" job failed on `twine check --strict` with "Invalid distribution metadata: '2.5' is not a valid metadata version". hatchling emits Metadata-Version 2.5, which only Twine 7 understands. build-and-inspect-python-package gained Twine 7 in v3.0.0, but v3 also stopped force-tagging minor releases, so the floating `@v2` tag could never pick the fix up. Pin to v3.0.1. CI: the "Format" job failed in mypy while parsing numpy's own stubs: "Type statement is only supported in Python 3.12 and greater". The Format job runs on Python 3.14, where numpy resolves to 2.5.x, whose stubs use PEP 695 `type` statements. mypy was targeting 3.10, so it refused to parse them. numpy 2.5 itself requires Python >=3.12, so the target has to be at least 3.12 for its stubs to parse. Align the stale 3.10 references, which sat below the project's own `requires-python = ">=3.11"`: - mypy python_version 3.10 -> 3.12 - test matrix 3.10 -> 3.11, so the minimum supported version is actually exercised (it previously was not tested at all) - drop the 3.10 classifier Raising the mypy target uncovered a real bug that the 3.10 target had been masking: `reverse_negative_flood_fill` returns a bool array, which was passed to `beam_shape_erode` (which expects floats) and then handed to `fits.writeto`, where astropy rejects it. This is the same failure Beth reported via #14. Cast the flood-fill mask to float32 at the point it is created, so both the erosion step and the FITS write get a float array, and drop the now-redundant `type: ignore[assignment]`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GTPJweMZavwh86CQ7qH7rb --- .github/workflows/cd.yml | 2 +- .github/workflows/ci.yml | 2 +- eye_patch/masking.py | 4 ++-- pyproject.toml | 3 +-- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 8c7f2bf..4e9c19e 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -29,7 +29,7 @@ jobs: with: fetch-depth: 0 - - uses: hynek/build-and-inspect-python-package@v2 + - uses: hynek/build-and-inspect-python-package@v3.0.1 publish: needs: [dist] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eca34c1..18cf087 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14"] runs-on: [ubuntu-latest] steps: diff --git a/eye_patch/masking.py b/eye_patch/masking.py index 536246b..5a2cc69 100644 --- a/eye_patch/masking.py +++ b/eye_patch/masking.py @@ -899,14 +899,14 @@ def create_snr_mask_from_fits( mask_data = reverse_negative_flood_fill( base_image=np.squeeze(signal_data), masking_options=masking_options, - ) + ).astype(np.float32) mask_data = mask_data.reshape(signal_data.shape) else: logger.info(f"Clipping using a {masking_options.base_snr_clip=}") mask_data = (signal_data > masking_options.base_snr_clip).astype(float) if masking_options.beam_shape_erode: - mask_data = beam_shape_erode( # type: ignore[assignment] + mask_data = beam_shape_erode( mask=mask_data, fits_header=fits_header, minimum_response=masking_options.beam_shape_erode_minimum_response, diff --git a/pyproject.toml b/pyproject.toml index 1131f81..4be17f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -98,7 +97,7 @@ report.exclude_also = ['\.\.\.', 'if typing.TYPE_CHECKING:'] [tool.mypy] files = ["eye_patch", "tests"] -python_version = "3.10" +python_version = "3.12" warn_unused_configs = true strict = true enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] From 5df81c9aa6fbf02ffe1b711e7a93476d0cfabbce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 12:11:47 +0000 Subject: [PATCH 2/7] Pin the mypy pre-commit hook to Python 3.11 Target mypy at the project's actual minimum (3.11) rather than 3.12. numpy 2.5 requires Python >=3.12 and its stubs use PEP 695 `type` statements, which mypy refuses to parse while targeting anything below 3.12. Pinning the hook's environment to 3.11 resolves numpy to the 2.4.x line, whose stubs parse cleanly at that target, so the type check now matches `requires-python = ">=3.11"` instead of silently skipping over it. The Format job installs 3.11 alongside 3.x so the hook can find it; 3.x stays last and remains the interpreter pre-commit itself runs on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GTPJweMZavwh86CQ7qH7rb --- .github/workflows/ci.yml | 7 ++++++- .pre-commit-config.yaml | 5 +++++ pyproject.toml | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18cf087..ce91f7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,14 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 + # 3.11 is installed alongside so the mypy hook can pin itself to it + # (see language_version in .pre-commit-config.yaml). The last entry + # stays the default interpreter that pre-commit itself runs on. - uses: actions/setup-python@v6 with: - python-version: "3.x" + python-version: | + 3.11 + 3.x - uses: astral-sh/setup-uv@v7 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1871e17..878f0a2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,6 +55,11 @@ repos: - id: mypy files: eye_patch|tests args: ["--ignore-missing-imports"] + # Pinned to the project's minimum supported Python. numpy 2.5 requires + # >=3.12 and its stubs use PEP 695 `type` statements, which mypy will + # not parse while targeting 3.11. Resolving numpy on 3.11 keeps us on + # the 2.4.x stubs, which do parse. + language_version: python3.11 additional_dependencies: - numpy - capn-crunch diff --git a/pyproject.toml b/pyproject.toml index 4be17f8..483ef05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,7 @@ report.exclude_also = ['\.\.\.', 'if typing.TYPE_CHECKING:'] [tool.mypy] files = ["eye_patch", "tests"] -python_version = "3.12" +python_version = "3.11" warn_unused_configs = true strict = true enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] From 9658e7506e3921d9d549e6c38fdb1440618021d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 12:17:58 +0000 Subject: [PATCH 3/7] Correct beam_shape_erode return-dtype docstring The docstring claimed a bool return for no/single scale and int32 otherwise. Neither held: the single-scale path casts to the input dtype, and the multi-scale path returns the float64 array it accumulates into. Now that the mask is float from the point it is created, the bool return is gone entirely. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GTPJweMZavwh86CQ7qH7rb --- eye_patch/masking.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/eye_patch/masking.py b/eye_patch/masking.py index 5a2cc69..7da19f8 100644 --- a/eye_patch/masking.py +++ b/eye_patch/masking.py @@ -301,7 +301,9 @@ def beam_shape_erode( scales (list[int] | tuple[int, ...] | None, optional): Defines the scales that are being used during multi-scale clean. Perform the beam erosion at each of these scales. Defaults to None. Returns: - MaskLike: The eroded beam shape. If a no/single scale provide it is a bool return, otherwise int32. + MaskLike: The eroded beam shape. For no/single scale the dtype of ``mask`` is + preserved; for multiple scales a float64 bitmask is returned, where the + n'th scale is stored as the n'th bit. """ if not all(key in fits_header for key in ["BMAJ", "BMIN", "BPA"]): From 787faf5426b091e383b9e03d9852c277309efc40 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 12:21:42 +0000 Subject: [PATCH 4/7] Add regression test for the flood-fill mask dtype codecov flagged 0% patch coverage, which turned out to be pointing at a real gap rather than a threshold quirk: every existing mask test runs with `flood_fill=False`, so nothing exercised the branch that produced the bool array. That is why this shipped. Covers the path end to end and asserts the written mask is floating point. Verified it fails without the fix, raising the same KeyError from astropy's image HDU while writing the mask that Beth hit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GTPJweMZavwh86CQ7qH7rb --- tests/test_masking.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_masking.py b/tests/test_masking.py index e74bf99..bacf429 100644 --- a/tests/test_masking.py +++ b/tests/test_masking.py @@ -491,6 +491,26 @@ def test_fits_masking(fits_dir): assert valid == np.prod(SHAPE) +def test_fits_masking_flood_fill_writes_float_mask(fits_dir): + """The flood fill path builds its mask as a bool array, which ``fits.writeto`` + rejects. Reported by Beth via #14, and not caught earlier because every other + mask test runs with ``flood_fill=False``.""" + masking_options = MaskingOptions(flood_fill=True) + names = create_snr_mask_from_fits( + fits_image_path=fits_dir / "image.fits", + fits_rms_path=fits_dir / "rms.fits", + fits_bkg_path=fits_dir / "bkg.fits", + masking_options=masking_options, + ) + + assert isinstance(names, FITSMaskNames) + assert names.mask_fits.exists() + + mask_data = fits.getdata(names.mask_fits) + assert mask_data.dtype.kind == "f", "A bool mask cannot be written to FITS" + assert np.sum(mask_data) == np.prod(SHAPE) + + def test_fits_masking_with_signal(fits_dir): masking_options = MaskingOptions(flood_fill=False) names = create_snr_mask_from_fits( From b530ca57457f9e44c8e4d5336b1ce18be745554c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 12:22:43 +0000 Subject: [PATCH 5/7] Drop explanatory comments from CI and pre-commit config Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GTPJweMZavwh86CQ7qH7rb --- .github/workflows/ci.yml | 3 --- .pre-commit-config.yaml | 4 ---- 2 files changed, 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce91f7f..855c910 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,6 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - # 3.11 is installed alongside so the mypy hook can pin itself to it - # (see language_version in .pre-commit-config.yaml). The last entry - # stays the default interpreter that pre-commit itself runs on. - uses: actions/setup-python@v6 with: python-version: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 878f0a2..06a9e5d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,10 +55,6 @@ repos: - id: mypy files: eye_patch|tests args: ["--ignore-missing-imports"] - # Pinned to the project's minimum supported Python. numpy 2.5 requires - # >=3.12 and its stubs use PEP 695 `type` statements, which mypy will - # not parse while targeting 3.11. Resolving numpy on 3.11 keeps us on - # the 2.4.x stubs, which do parse. language_version: python3.11 additional_dependencies: - numpy From 1fee9e398ff522da00c97381050afa93790f026b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 12:28:21 +0000 Subject: [PATCH 6/7] Cover the flood-fill mask reaching beam_shape_erode codecov was still reporting 0% patch coverage. The remaining uncovered changed line was the `beam_shape_erode` call itself: no test ran `create_snr_mask_from_fits` with `beam_shape_erode=True`, so the one place the bool mask was actually passed into a float-typed parameter, which is what mypy flagged, went unexercised. Adds a fixture whose image carries the beam keywords, and a test running flood fill and beam erosion together through the public entry point. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GTPJweMZavwh86CQ7qH7rb --- tests/test_masking.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_masking.py b/tests/test_masking.py index bacf429..ed234f7 100644 --- a/tests/test_masking.py +++ b/tests/test_masking.py @@ -491,6 +491,38 @@ def test_fits_masking(fits_dir): assert valid == np.prod(SHAPE) +@pytest.fixture +def beam_fits_dir(tmpdir, beam_fits_header): + """As ``fits_dir``, but the image carries the beam keywords that + ``beam_shape_erode`` needs to build its kernel.""" + beam_fits_dir = Path(tmpdir) / "beam_fits" + beam_fits_dir.mkdir() + + fits.writeto(beam_fits_dir / "image.fits", np.ones(SHAPE), header=beam_fits_header) + fits.writeto(beam_fits_dir / "rms.fits", np.ones(SHAPE) * 0.1) + fits.writeto(beam_fits_dir / "bkg.fits", np.ones(SHAPE) * 0.5) + + return beam_fits_dir + + +def test_fits_masking_flood_fill_with_beam_erode(beam_fits_dir): + """The flood filled mask reaches ``beam_shape_erode``, which types its input + as floats. This is the path the bool mask was travelling down.""" + masking_options = MaskingOptions(flood_fill=True, beam_shape_erode=True) + names = create_snr_mask_from_fits( + fits_image_path=beam_fits_dir / "image.fits", + fits_rms_path=beam_fits_dir / "rms.fits", + fits_bkg_path=beam_fits_dir / "bkg.fits", + masking_options=masking_options, + ) + + assert isinstance(names, FITSMaskNames) + assert names.mask_fits.exists() + + mask_data = fits.getdata(names.mask_fits) + assert mask_data.dtype.kind == "f", "A bool mask cannot be written to FITS" + + def test_fits_masking_flood_fill_writes_float_mask(fits_dir): """The flood fill path builds its mask as a bool array, which ``fits.writeto`` rejects. Reported by Beth via #14, and not caught earlier because every other From b38b8d6f826793dcf0c63b9505cbb066d5d62e26 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 12:43:33 +0000 Subject: [PATCH 7/7] Type the new tests The two tests added here were unannotated. mypy did not catch it because disallow_untyped_defs is only switched on for eye_patch.*, not tests. The new fixture takes `tmp_path` rather than `tmpdir`, since the latter is `py.path.local` and the surrounding fixture was converting it to a Path anyway. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GTPJweMZavwh86CQ7qH7rb --- tests/test_masking.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_masking.py b/tests/test_masking.py index ed234f7..ca24903 100644 --- a/tests/test_masking.py +++ b/tests/test_masking.py @@ -492,10 +492,10 @@ def test_fits_masking(fits_dir): @pytest.fixture -def beam_fits_dir(tmpdir, beam_fits_header): +def beam_fits_dir(tmp_path: Path, beam_fits_header: fits.Header) -> Path: """As ``fits_dir``, but the image carries the beam keywords that ``beam_shape_erode`` needs to build its kernel.""" - beam_fits_dir = Path(tmpdir) / "beam_fits" + beam_fits_dir = tmp_path / "beam_fits" beam_fits_dir.mkdir() fits.writeto(beam_fits_dir / "image.fits", np.ones(SHAPE), header=beam_fits_header) @@ -505,7 +505,7 @@ def beam_fits_dir(tmpdir, beam_fits_header): return beam_fits_dir -def test_fits_masking_flood_fill_with_beam_erode(beam_fits_dir): +def test_fits_masking_flood_fill_with_beam_erode(beam_fits_dir: Path) -> None: """The flood filled mask reaches ``beam_shape_erode``, which types its input as floats. This is the path the bool mask was travelling down.""" masking_options = MaskingOptions(flood_fill=True, beam_shape_erode=True) @@ -523,7 +523,7 @@ def test_fits_masking_flood_fill_with_beam_erode(beam_fits_dir): assert mask_data.dtype.kind == "f", "A bool mask cannot be written to FITS" -def test_fits_masking_flood_fill_writes_float_mask(fits_dir): +def test_fits_masking_flood_fill_writes_float_mask(fits_dir: Path) -> None: """The flood fill path builds its mask as a bool array, which ``fits.writeto`` rejects. Reported by Beth via #14, and not caught earlier because every other mask test runs with ``flood_fill=False``."""