From 72643ce2a46e4a65e5a22abca0e984c28faf89bb Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 11 Jun 2026 10:41:33 -0400 Subject: [PATCH] fix(chunked): annotate ndarray with generic args; add py3.10 mypy gate Bare `np.ndarray` annotations in chunked.py are accepted by NumPy >=2.3 stubs (which added PEP 696 defaults to ndarray's type parameters) but rejected as `[type-arg]` under strict mypy with NumPy <2.3. This broke boost-histogram's downstream "hist" job, which type-checks on Python 3.10 where NumPy resolves to <2.3. Replace the nine bare `np.ndarray` annotations with `np.typing.NDArray[Any]` (the convention already used in plot.py), correct under every supported NumPy version. Add a Python-3.10-pinned `mypy` nox session and CI job so this class of "works on new NumPy, breaks on old" type error is caught locally and in CI, mirroring the downstream check. Assisted-by: ClaudeCode:claude-opus-4.8 --- .github/workflows/ci.yml | 17 ++++++++++++++++- noxfile.py | 13 +++++++++++++ src/hist/chunked.py | 22 +++++++++++++--------- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6002c7d6..750434c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,21 @@ jobs: - name: Test plotting too run: .venv/bin/python -m pytest --mpl + mypy: + name: Type check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup uv + uses: astral-sh/setup-uv@v8.1.0 + + - name: Install nox + run: uv tool install nox + + - name: Run mypy + run: nox -s mypy + minimums: name: Check minimums runs-on: ubuntu-latest @@ -87,7 +102,7 @@ jobs: pass: if: always() - needs: [pylint, checks, minimums] + needs: [pylint, checks, mypy, minimums] runs-on: ubuntu-slim timeout-minutes: 2 steps: diff --git a/noxfile.py b/noxfile.py index bea7672f..3e457db4 100755 --- a/noxfile.py +++ b/noxfile.py @@ -48,6 +48,19 @@ def tests(session): session.run("pytest", *args, *session.posargs) +@nox.session(python="3.10", venv_backend="uv") +def mypy(session): + """ + Type check. Pinned to Python 3.10, where NumPy resolves to <2.3 and so + ``ndarray`` has no generic defaults -- this catches bare ``np.ndarray`` + annotations that newer NumPy stubs silently accept. Mirrors the type check + in boost-histogram's downstream "hist" job. + """ + + session.install("-e.", "--group=test", "--group=plot", "mypy", "pandas-stubs") + session.run("mypy", *session.posargs) + + @nox.session(venv_backend="uv", default=False) def minimums(session): """ diff --git a/src/hist/chunked.py b/src/hist/chunked.py index 6b2114cf..b5d08679 100644 --- a/src/hist/chunked.py +++ b/src/hist/chunked.py @@ -57,7 +57,7 @@ def _validate_dense_view( *, shape: tuple[int, ...], dtype: np.dtype[tp.Any], -) -> np.ndarray: +) -> np.typing.NDArray[Any]: array = np.asarray(view) if array.shape != shape: msg = f"dense view shape mismatch: expected {shape}, got {array.shape}" @@ -68,7 +68,9 @@ def _validate_dense_view( return array -def _accumulate_dense_view(target: np.ndarray, source: np.ndarray) -> None: +def _accumulate_dense_view( + target: np.typing.NDArray[Any], source: np.typing.NDArray[Any] +) -> None: if target.dtype.fields is None: target[...] += source return @@ -76,7 +78,7 @@ def _accumulate_dense_view(target: np.ndarray, source: np.ndarray) -> None: _accumulate_dense_view(target[field_name], source[field_name]) -def _zero_dense_view(view: np.ndarray) -> None: +def _zero_dense_view(view: np.typing.NDArray[Any]) -> None: if view.dtype.fields is None: view.fill(0) return @@ -84,7 +86,7 @@ def _zero_dense_view(view: np.ndarray) -> None: _zero_dense_view(view[field_name]) -def _view_any_nonzero(view: np.ndarray) -> bool: +def _view_any_nonzero(view: np.typing.NDArray[Any]) -> bool: if view.dtype.fields is None: return bool(np.any(view)) return any( @@ -168,7 +170,7 @@ class ChunkedHist: _dense_view_nbytes: int = field(init=False) _chunk_axis_names: tuple[str, ...] = field(init=False) _scratch_dense_hist: hist.Hist[Any] = field(init=False) - _chunks: dict[ChunkKey, np.ndarray] = field(default_factory=dict) + _chunks: dict[ChunkKey, np.typing.NDArray[Any]] = field(default_factory=dict) def __init__( self, @@ -327,7 +329,9 @@ def dense_view_dtype(self) -> np.dtype[tp.Any]: def dense_axes(self) -> tuple[tp.Any, ...]: return tuple(self._scratch_dense_hist.axes) - def _save_chunk_view(self, key: ChunkKey, chunk_view: np.ndarray) -> None: + def _save_chunk_view( + self, key: ChunkKey, chunk_view: np.typing.NDArray[Any] + ) -> None: array = _validate_dense_view( chunk_view, shape=self.dense_view_shape, @@ -373,7 +377,7 @@ def split_fill_kwargs( if name not in self.chunk_axis_names } - def add_dense_view(self, key: ChunkKey, dense_view: np.ndarray) -> None: + def add_dense_view(self, key: ChunkKey, dense_view: np.typing.NDArray[Any]) -> None: self._check_chunk_key(key) dense_view = _validate_dense_view( dense_view, @@ -493,7 +497,7 @@ def empty_like(self) -> Self: label=self.label, ) - def items(self) -> Iterable[tuple[ChunkKey, np.ndarray]]: + def items(self) -> Iterable[tuple[ChunkKey, np.typing.NDArray[Any]]]: """Iterate over ``(chunk key, chunk array)`` pairs. Like :meth:`chunk_view`, the yielded arrays are live views of the @@ -559,7 +563,7 @@ def selection_dict(self, key: ChunkKey) -> dict[str, ChunkScalar]: def chunk_view( self, selection: Mapping[str, ChunkScalar | tp.Iterable[ChunkScalar]], - ) -> np.ndarray: + ) -> np.typing.NDArray[Any]: """Return the live array for one chunk. The returned array is a view of the internal storage; mutating it