From fca70380c4fb7e849935c09f3266495163eb6a26 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 12:35:45 +0200 Subject: [PATCH 01/17] Overload get_quantity so a non-null input types as a Quantity Only None or Ellipsis yields None, but the single signature made every result Quantity | None, so get_quantity("m") could not be multiplied without a narrowing check. --- dascore/units.py | 18 +++++++++++++++++- pyproject.toml | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/dascore/units.py b/dascore/units.py index ac4a56656..50daaf4db 100644 --- a/dascore/units.py +++ b/dascore/units.py @@ -7,7 +7,7 @@ from functools import cache from threading import RLock from types import EllipsisType -from typing import Any, cast +from typing import Any, cast, overload import numpy as np import pandas as pd @@ -132,12 +132,28 @@ def _str_to_quant(qunat_str): ) +@overload +def get_quantity(value: None) -> None: ... + + +@overload +def get_quantity(value: str | Quantity | Unit) -> Quantity: ... + + +@overload +def get_quantity(value: quantity_like) -> Quantity | None: ... + + def get_quantity( value: quantity_like, ) -> Quantity | None: """ Convert a value to a pint quantity. + Only a null-ish input (None or Ellipsis) yields None, so a string, + Unit or Quantity is typed as producing a Quantity and stays usable in + arithmetic without a narrowing check. + Parameters ---------- value diff --git a/pyproject.toml b/pyproject.toml index 1c6cc7bdc..ec213878f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -264,7 +264,7 @@ markers = [ line-ending = "lf" [tool.ty.src] -include = ["dascore"] # tests/ still has many diagnostics; expand scope later. +include = ["dascore", "tests"] # No rule is ignored any more: invalid-method-override, # invalid-argument-type and invalid-return-type have each been burned down From ae8b481ad55d3f138bcdfa022453f97915d86f26 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 12:40:13 +0200 Subject: [PATCH 02/17] Bring tests into ty's scope with two narrow overrides The optional-dependency imports are ignored across tests/, matching what dascore/ already does. The pydantic extra=allow attribute reads are ignored in the six files that actually do them, rather than across the suite, so the rule keeps working on the rest. Also narrows some optionals in the index edge cases and moves its hand-rolled attribute patching onto monkeypatch, which restores itself. --- pyproject.toml | 32 +++++++++++++++++-- .../test_index/test_index_edge_cases.py | 22 +++++++------ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ec213878f..10244e5ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -266,9 +266,9 @@ line-ending = "lf" [tool.ty.src] include = ["dascore", "tests"] -# No rule is ignored any more: invalid-method-override, +# No rule is ignored globally: invalid-method-override, # invalid-argument-type and invalid-return-type have each been burned down -# to zero and left on. Widening [tool.ty.src] to tests/ is the next step. +# to zero and left on. # These files lazily import optional or untyped modules (xarray, numba, # h5py.h5r) that are not installed in the pre-commit hook's environment. @@ -278,6 +278,34 @@ include = ["dascore/compat.py", "dascore/utils/jit.py", "dascore/io/dasvader/uti [tool.ty.overrides.rules] unresolved-import = "ignore" +# Tests import the optional dependencies (xarray, obspy, numba, h5py.h5r) +# that the pre-commit hook's environment lacks, and assert on unimportable +# modules to prove a removal stuck. +[[tool.ty.overrides]] +include = ["tests/"] + +[tool.ty.overrides.rules] +unresolved-import = "ignore" + +# PatchAttrs and PatchSummary are pydantic models with extra="allow", so +# `attrs.time_min` resolves at runtime but not for a checker. Listed file +# by file rather than across tests/ so the rule keeps catching genuinely +# unnarrowed values elsewhere in the suite, and so that a blanket +# __getattr__ does not have to trade away typo detection on the models +# themselves. +[[tool.ty.overrides]] +include = [ + "tests/test_core/test_attrs.py", + "tests/test_io/test_dasdae/test_dasdae.py", + "tests/test_io/test_dasvader/test_dasvader.py", + "tests/test_io/test_febus/test_febusg1.py", + "tests/test_io/test_prodml/test_prodml_write.py", + "tests/test_io/test_sr4731/test_sr4731.py", +] + +[tool.ty.overrides.rules] +unresolved-attribute = "ignore" + [tool.typos.files] extend-exclude = ["docs/_static/logo.svg"] diff --git a/tests/test_io/test_index/test_index_edge_cases.py b/tests/test_io/test_index/test_index_edge_cases.py index f6e099671..0c6332b70 100644 --- a/tests/test_io/test_index/test_index_edge_cases.py +++ b/tests/test_io/test_index/test_index_edge_cases.py @@ -277,7 +277,8 @@ def test_range_bounds_unset_is_not_none(self): from dascore.io.index.query import _UNSET, _range_bounds value = (5 * m, 10 * m) - kinds = {typed_value(5 * m).kind} + assert (kind_probe := typed_value(5 * m)) is not None + kinds = {kind_probe.kind} # Omitted: bounds pass through with no conversion attempted. _, lo, hi, _ = _range_bounds(value, kinds, _UNSET, "distance") assert (lo, hi) == pytest.approx((5.0, 10.0)) @@ -406,7 +407,7 @@ def test_bulk_insert_empty_rows_noop(self, tmp_path): assert len(back._attr_meta()) == 1 back.close() - def test_write_failure_rolls_back(self, tmp_path): + def test_write_failure_rolls_back(self, tmp_path, monkeypatch): """A failing write leaves the index unchanged.""" back = get_backend(tmp_path / "rollback.sqlite3") records = summaries_to_records(make_summaries()) @@ -416,10 +417,10 @@ def test_write_failure_rolls_back(self, tmp_path): def boom(*args, **kwargs): raise RuntimeError("simulated failure") - back._bulk_insert = boom + monkeypatch.setattr(back, "_bulk_insert", boom) with pytest.raises(RuntimeError, match="simulated"): back.write_sources(records[1:]) - del back.__dict__["_bulk_insert"] + monkeypatch.undo() assert len(back.query()) == before back.close() @@ -474,7 +475,7 @@ def commit_failure(): assert back.get_metadata()["last_indexed_ns"] == before back.close() - def test_delete_failure_rolls_back(self, tmp_path): + def test_delete_failure_rolls_back(self, tmp_path, monkeypatch): """A failing delete leaves the index unchanged.""" back = get_backend(tmp_path / "delete.sqlite3") back.write_sources(summaries_to_records(make_summaries())) @@ -483,10 +484,10 @@ def test_delete_failure_rolls_back(self, tmp_path): def boom(paths, base_uri=""): raise RuntimeError("simulated failure") - back._delete_by_paths = boom + monkeypatch.setattr(back, "_delete_by_paths", boom) with pytest.raises(RuntimeError, match="simulated"): back.delete_sources(["das/file_1.h5"]) - del back.__dict__["_delete_by_paths"] + monkeypatch.undo() assert len(back.query()) == before back.close() @@ -1077,6 +1078,7 @@ def test_irregular_coord_hashes_values(self): patch = patch.update_coords(distance=values) summary = PatchSummary.from_patch(patch) record = _coord_record("distance", summary.coords["distance"]) + assert record is not None assert record.coord_hash == patch.get_coord("distance").fingerprint() assert record.def_key.startswith("fp:") @@ -1095,6 +1097,7 @@ def test_summary_key_stable_through_export(self, tmp_path): source_version="1", ) fresh = _coord_record("time", summary.coords["time"]) + assert fresh is not None assert fresh.def_key.startswith("sum:") back = get_backend(tmp_path / "sum.sqlite3") back.write_sources(summaries_to_records([summary])) @@ -1327,7 +1330,7 @@ class TestTransactionIsolation: """The statement lock covers whole transactions (round-4 F5).""" @pytest.mark.concurrency - def test_reader_never_sees_half_written_replacement(self, tmp_path): + def test_reader_never_sees_half_written_replacement(self, tmp_path, monkeypatch): """A concurrent reader blocks during a source replacement.""" import threading @@ -1363,7 +1366,7 @@ def read(): writer = threading.Thread( target=lambda: backend.write_sources([record]), daemon=True ) - type(backend)._delete_by_paths = paused_delete + monkeypatch.setattr(type(backend), "_delete_by_paths", paused_delete) try: writer.start() assert in_delete.wait(timeout=10) @@ -1378,7 +1381,6 @@ def read(): writer.join(timeout=10) reader.join(timeout=10) finally: - type(backend)._delete_by_paths = original release.set() assert counts == [1] backend.close() From 87b72ab78aab30b007761491fd2b81c053243e71 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 12:51:47 +0200 Subject: [PATCH 03/17] Share the open-range select types and cast the structured arrays febus already defined tuple types admitting ... for an open end; those move to dascore.constants and dc.read now uses them, so the documented (value, ...) form type checks. The dasvader test builds structured arrays from runtime dtypes, which numpy types as float64, so every field-name index needs a cast to be seen as a void array. --- dascore/constants.py | 10 +++++++++- dascore/io/core.py | 7 ++++--- dascore/io/febus/core.py | 17 ++++++++++------- tests/test_io/test_dasvader/test_dasvader.py | 16 +++++++++++++--- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/dascore/constants.py b/dascore/constants.py index 8bc2757c7..4841ca21a 100644 --- a/dascore/constants.py +++ b/dascore/constants.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Mapping from functools import partial from pathlib import Path -from types import MappingProxyType +from types import EllipsisType, MappingProxyType from typing import Literal, Protocol, TypeVar, get_args, runtime_checkable import numpy as np @@ -34,6 +34,14 @@ def map(self, func, iterables, **kwargs): timeable_types = int | float | str | np.datetime64 | pd.Timestamp opt_timeable_types = None | timeable_types +# A (start, stop) selection range. Either end may be `...` to leave that +# side open, which is why these are not simply tuples of the value type. +time_select_type = tuple[ + opt_timeable_types | EllipsisType, + opt_timeable_types | EllipsisType, +] +float_select_type = tuple[float | EllipsisType | None, float | EllipsisType | None] + # Number types numeric_types = int | float diff --git a/dascore/io/core.py b/dascore/io/core.py index 1c8bfa4ec..e8d8e17e4 100644 --- a/dascore/io/core.py +++ b/dascore/io/core.py @@ -30,8 +30,9 @@ from dascore.compat import Progress, UPath from dascore.constants import ( PROGRESS_LEVELS, + float_select_type, path_types, - timeable_types, + time_select_type, ) from dascore.core.attrs import PatchAttrs from dascore.core.coordmanager import CoordManager @@ -1061,8 +1062,8 @@ def read( path: path_types | IOResourceManager, file_format: str | None = None, file_version: str | None = None, - time: tuple[timeable_types | None, timeable_types | None] | None = None, - distance: tuple[float | None, float | None] | None = None, + time: time_select_type | None = None, + distance: float_select_type | None = None, **kwargs, ) -> dc.BaseSpool: """ diff --git a/dascore/io/febus/core.py b/dascore/io/febus/core.py index 7eb9fe72d..da2e7136a 100644 --- a/dascore/io/febus/core.py +++ b/dascore/io/febus/core.py @@ -5,13 +5,17 @@ from __future__ import annotations import warnings -from types import EllipsisType from typing import Literal import numpy as np import dascore as dc -from dascore.constants import opt_timeable_types, timeable_types +from dascore.constants import ( + float_select_type, + opt_timeable_types, + time_select_type, + timeable_types, +) from dascore.io import FiberIO, ScanPayload from dascore.io.core import _make_scan_payload from dascore.utils.hdf5 import H5Reader @@ -39,11 +43,10 @@ ) from .t1utils import _get_t1_patch, _is_t1_file, _scan_t1 -_float_select_type = tuple[float | EllipsisType | None, float | EllipsisType | None] -_time_select_type = tuple[ - opt_timeable_types | EllipsisType, - opt_timeable_types | EllipsisType, -] +# Kept as module-local names for the many signatures below; the shared +# definitions live in dascore.constants. +_float_select_type = float_select_type +_time_select_type = time_select_type class FebusPatchAttrs(dc.PatchAttrs): diff --git a/tests/test_io/test_dasvader/test_dasvader.py b/tests/test_io/test_dasvader/test_dasvader.py index 5dd0de10d..5a15873e6 100644 --- a/tests/test_io/test_dasvader/test_dasvader.py +++ b/tests/test_io/test_dasvader/test_dasvader.py @@ -7,6 +7,7 @@ import warnings from dataclasses import dataclass from pathlib import Path +from typing import Any, cast import h5py import numpy as np @@ -78,7 +79,12 @@ def _write_modern_dasvader_file( ) htime = fi.create_dataset("htime", data=np.array([MODERN_DASVADER.htime_ms])) - ddas = np.zeros((), dtype=ddas_dtype) + # Cast because numpy types np.zeros(..., dtype=) as + # float64: a structured dtype built at runtime is invisible to the + # stubs, which then reject every field-name index below. + ddas = cast( + "np.ndarray[Any, np.dtype[np.void]]", np.zeros((), dtype=ddas_dtype) + ) ddas[data_name] = data.ref ddas["htime"] = htime.ref ddas["time"]["ref"]["hi"] = 0.0 @@ -112,7 +118,9 @@ def _write_modern_dasvader_file( data=MODERN_DASVADER.pipeline_tracker, dtype=h5py.string_dtype(encoding="utf-8"), ) - atrib = np.zeros((), dtype=atrib_dtype) + atrib = cast( + "np.ndarray[Any, np.dtype[np.void]]", np.zeros((), dtype=atrib_dtype) + ) atrib["GaugeLength"] = gauge.ref atrib["Hostname"] = host.ref atrib["PipelineTracker"] = tracker.ref @@ -166,7 +174,9 @@ def das_vader_strainrate_no_attrib_path(self, tmp_path_factory): ) htime = fi.create_dataset("htime", data=np.array([62_135_683_200_000])) - ddas = np.zeros((), dtype=ddas_dtype) + ddas = cast( + "np.ndarray[Any, np.dtype[np.void]]", np.zeros((), dtype=ddas_dtype) + ) ddas["strainrate"] = strainrate.ref ddas["htime"] = htime.ref ddas["time"]["ref"]["hi"] = 0.0 From 6c32f5a64fe75789f192714281537dd989b6d9e4 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 12:54:06 +0200 Subject: [PATCH 04/17] Make the test FiberIO subclasses conform to the base signatures Dropping **kwargs, narrowing a parameter to SpoolType and widening a return to bool are all real override incompatibilities: a caller using the base contract would break on these. The dummies ignore their arguments, so the signatures change and the behaviour does not. --- tests/test_io/test_io_core.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/test_io/test_io_core.py b/tests/test_io/test_io_core.py index b39a301e5..50258470b 100644 --- a/tests/test_io/test_io_core.py +++ b/tests/test_io/test_io_core.py @@ -6,7 +6,7 @@ import io import threading from pathlib import Path -from typing import ClassVar, TypeVar +from typing import ClassVar, Literal, TypeVar import h5py import numpy as np @@ -66,13 +66,13 @@ class _FiberImplementer(FiberIO): def read(self, resource, **kwargs): """Dummy read.""" - def write(self, spool: SpoolType, resource): + def write(self, spool, resource, **kwargs): """Dummy write.""" - def scan(self, resource: BinaryReader): + def scan(self, resource: BinaryReader, *, snap: bool = True, **kwargs): """Dummy scan.""" - def get_format(self, resource): + def get_format(self, resource, **kwargs): """Dummy get_format.""" @@ -82,15 +82,15 @@ class _FiberCaster(FiberIO): name = "_TestFormatter" version = "2" - def read(self, resource: BinaryReader, **kwargs) -> SpoolType: + def read(self, resource: BinaryReader, **kwargs): """Just ensure read was cast to correct type.""" assert isinstance(resource, io.BufferedReader) - def write(self, spool: SpoolType, resource: BinaryWriter): + def write(self, spool, resource: BinaryWriter, **kwargs): """Ditto for write.""" assert isinstance(resource, io.BufferedWriter) - def get_format(self, resource: Path) -> tuple[str, str] | bool: + def get_format(self, resource: Path, **kwargs) -> tuple[str, str] | Literal[False]: """And get format.""" assert isinstance(resource, Path) return False @@ -132,7 +132,7 @@ class _FiberDirectory(FiberIO): version = "0.1" input_type = "directory" - def get_format(self, resource) -> tuple[str, str] | bool: + def get_format(self, resource, **kwargs) -> tuple[str, str] | Literal[False]: """Only accept directories which have specific naming.""" path = Path(resource) name = path.name @@ -160,7 +160,7 @@ class _ReadOnlySummaryFormatter(FiberIO): name = "_read_only_summary_formatter" version = "1" - def read(self, resource: Path, snap_dims=True, **kwargs) -> SpoolType: + def read(self, resource: Path, snap_dims=True, **kwargs) -> dc.BaseSpool: """Return a simple spool for default scan conversion.""" patch = dc.get_example_patch().update_attrs(tag="fallback") values = patch.get_coord("time").values.copy() @@ -170,7 +170,7 @@ def read(self, resource: Path, snap_dims=True, **kwargs) -> SpoolType: time = time.snap() return dc.spool([patch.update_coords(time=time)]) - def get_format(self, resource: Path) -> tuple[str, str] | bool: + def get_format(self, resource: Path, **kwargs) -> tuple[str, str] | Literal[False]: """Only accept the explicit fallback-scan test resource.""" path = Path(resource) if path.suffix == ".h5" and path.name == "fallback_scan.h5": @@ -192,7 +192,7 @@ def scan(self, resource: Path, **kwargs): ) raise MissingOptionalDependencyError(msg) - def get_format(self, resource: Path) -> tuple[str, str] | bool: + def get_format(self, resource: Path, **kwargs) -> tuple[str, str] | Literal[False]: """Only accept the explicit missing-optional test resource.""" path = Path(resource) if path.suffix == ".opt" and path.name == "missing_optional.opt": @@ -1321,21 +1321,21 @@ def _scan(resource, snap=True, **kwargs): assert out[0]["source_version"] == fiber_io.version def test_default_fiberio_scan_multi_patch_does_not_set_source_patch_id( - self, tmp_path + self, tmp_path, monkeypatch ): """Default scan should not invent source ids for multi-patch readers.""" path = tmp_path / "fallback_scan.h5" path.write_text("placeholder") fio = _ReadOnlySummaryFormatter() - def read_two_patches(resource: Path, **kwargs) -> SpoolType: + def read_two_patches(resource: Path, **kwargs) -> dc.BaseSpool: patches = [ dc.get_example_patch().update_attrs(tag="first"), dc.get_example_patch().update_attrs(tag="second"), ] return dc.spool(patches) - fio.read = read_two_patches # type: ignore[method-assign] + monkeypatch.setattr(fio, "read", read_two_patches) out = fio.scan(path) assert len(out) == 2 From 3325d21aa0ef7503cdf8463131f30b84ef4d7c44 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 12:55:39 +0200 Subject: [PATCH 05/17] Narrow coords in tests and let write's return follow its argument The segmented coord tests reach for segment-specific attributes on values the factory declares as BaseCoord; asserting the concrete type first is also a stronger assertion. dc.write is now generic over the path type, so handing it a Path gets a Path back rather than the whole path_types union that #840 widened it to. --- dascore/io/core.py | 10 ++++++++-- tests/test_core/test_coord_segmented.py | 23 +++++++++++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/dascore/io/core.py b/dascore/io/core.py index e8d8e17e4..bd5721dd3 100644 --- a/dascore/io/core.py +++ b/dascore/io/core.py @@ -16,6 +16,7 @@ from typing import ( Any, Literal, + TypeVar, NotRequired, Protocol, TypedDict, @@ -1683,14 +1684,19 @@ def _has_gaps(patch): return dc.spool(patches) +# write hands back the path it was given, so the return follows the +# argument rather than collapsing to the union: a Path in, a Path out. +_PathT = TypeVar("_PathT", bound=path_types) + + def write( patch_or_spool, - path: path_types, + path: _PathT, file_format: str, file_version: str | None = None, split: bool = False, **kwargs, -) -> path_types: +) -> _PathT: """ Write a Patch or Spool to disk. diff --git a/tests/test_core/test_coord_segmented.py b/tests/test_core/test_coord_segmented.py index 839a5a3b9..fe9ace400 100644 --- a/tests/test_core/test_coord_segmented.py +++ b/tests/test_core/test_coord_segmented.py @@ -27,7 +27,9 @@ def float_gap_coord() -> CoordSegmented: """Two evenly sampled float blocks (0..9, 15..24) separated by a gap.""" c1 = get_coord(start=0.0, stop=10.0, step=1.0) c2 = get_coord(start=15.0, stop=25.0, step=1.0) - return concat_coords(c1, c2) + out = concat_coords(c1, c2) + assert isinstance(out, CoordSegmented) + return out @pytest.fixture(scope="session") @@ -37,7 +39,9 @@ def time_gap_coord() -> CoordSegmented: t0 = np.datetime64("2020-01-01T00:00:00", "ns") c1 = get_coord(start=t0, stop=t0 + 10 * one_s, step=one_s) c2 = get_coord(start=t0 + 12 * one_s, stop=t0 + 22 * one_s, step=one_s) - return concat_coords(c1, c2) + out = concat_coords(c1, c2) + assert isinstance(out, CoordSegmented) + return out @pytest.fixture(scope="session") @@ -46,7 +50,9 @@ def mixed_segment_coord() -> CoordSegmented: c1 = get_coord(start=0.0, stop=10.0, step=1.0) c2 = get_coord(data=np.array([12.0, 12.1, 13.7, 20.0])) assert isinstance(c2, CoordMonotonicArray) - return concat_coords(c1, c2) + out = concat_coords(c1, c2) + assert isinstance(out, CoordSegmented) + return out @pytest.fixture(scope="session") @@ -54,7 +60,9 @@ def reverse_gap_coord() -> CoordSegmented: """A reverse-sorted segmented coordinate.""" c1 = get_coord(start=24.0, stop=14.0, step=-1.0) c2 = get_coord(start=9.0, stop=-1.0, step=-1.0) - return concat_coords(c1, c2) + out = concat_coords(c1, c2) + assert isinstance(out, CoordSegmented) + return out class TestConstruction: @@ -79,6 +87,7 @@ def test_uniform_array_segments_promoted(self): c1 = get_coord(data=np.arange(5.0)) c2 = get_coord(start=8.0, stop=12.0, step=1.0) out = concat_coords(c1, c2) + assert isinstance(out, CoordSegmented) assert all(isinstance(x, CoordRange) for x in out.segments) def test_canonical_across_construction_orders(self): @@ -193,6 +202,7 @@ def test_segmented_inputs_flatten(self, float_gap_coord): """Segmented inputs contribute their segments.""" c3 = get_coord(start=30.0, stop=40.0, step=1.0) out = concat_coords(float_gap_coord, c3) + assert isinstance(out, CoordSegmented) assert out.segment_count == 3 def test_units_param_sets_units(self): @@ -554,6 +564,7 @@ def test_simplify_promotes_close_array_segment(self): get_coord(data=values), get_coord(start=10.0, stop=14.0, step=1.0) ) out = coord.simplify(0.1) + assert isinstance(out, CoordSegmented) assert all(isinstance(x, CoordRange) for x in out.segments) def test_negative_tolerance_raises(self, float_gap_coord): @@ -700,6 +711,7 @@ def test_slice_can_promote_and_fuse(self): a = get_coord(start=0.0, stop=10.0, step=1.0) b = CoordMonotonicArray(values=np.array([10.0, 11.0, 12.0, 13.5])) coord = concat_coords(a, b) + assert isinstance(coord, CoordSegmented) assert coord.segment_count == 2 out = coord[0:13] assert isinstance(out, CoordRange) @@ -1055,6 +1067,7 @@ def test_isolated_sample_between_gaps(self): """A lone sample between gaps becomes its own segment.""" values = np.array([0.0, 1, 2, 10, 20, 21, 22]) coord = CoordSegmented.from_array(values) + assert isinstance(coord, CoordSegmented) assert coord.segment_count == 3 assert np.array_equal(coord.values, values) assert len(coord.get_discontinuities()) == 2 @@ -1067,6 +1080,7 @@ def test_datetime_gap(self): [t0 + np.arange(5) * one_s, t0 + (np.arange(5) + 8) * one_s] ) coord = CoordSegmented.from_array(values) + assert isinstance(coord, CoordSegmented) assert coord.segment_count == 2 assert np.array_equal(coord.values, values) assert len(coord.get_discontinuities("gaps")) == 1 @@ -1082,6 +1096,7 @@ def test_reverse_array(self): """Reverse-sorted arrays segment correctly.""" values = np.array([13.0, 12, 11, 10, 3, 2, 1, 0]) coord = CoordSegmented.from_array(values) + assert isinstance(coord, CoordSegmented) assert coord.segment_count == 2 assert coord.reverse_sorted assert np.array_equal(coord.values, values) From 3b4c885b69f38bdcf008bccf08fb96cdfcde8fa8 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 12:56:35 +0200 Subject: [PATCH 06/17] Overload get_quantity_str and type the splatted kwarg dicts Naming a unit always produces a string; only a null input gives None. The heterogeneous dicts splatted into typed constructors infer an object value type, which rejects every field. --- dascore/units.py | 15 +++++++++++++++ tests/test_io/test_mseed/test_mseed.py | 7 ++++++- tests/test_utils/test_moving.py | 8 ++++++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/dascore/units.py b/dascore/units.py index 50daaf4db..de520ed93 100644 --- a/dascore/units.py +++ b/dascore/units.py @@ -301,10 +301,25 @@ def _unit_to_str(unit: Unit) -> str: unit_like = str | bytes | Quantity | PlainUnit | None +@overload +def get_quantity_str(quant_value: None) -> None: ... + + +@overload +def get_quantity_str(quant_value: str | bytes | Quantity | PlainUnit) -> str: ... + + +@overload +def get_quantity_str(quant_value: unit_like) -> str | None: ... + + def get_quantity_str(quant_value: unit_like) -> str | None: """ Ensure a unit/quantity is valid and return its string representation. + Only a null input yields None, so naming a unit is typed as producing + a string. + If it is not valid raise a [UnitError](`dascore.exceptions.UnitError`). Parameters diff --git a/tests/test_io/test_mseed/test_mseed.py b/tests/test_io/test_mseed/test_mseed.py index 975ee1010..4e83a7e8b 100644 --- a/tests/test_io/test_mseed/test_mseed.py +++ b/tests/test_io/test_mseed/test_mseed.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + import numpy as np import pytest @@ -73,7 +75,10 @@ def _write_mseed_v2_header(path): def _trace_segment(**kwargs): """Return a small decoded MiniSEED trace segment for helper tests.""" data = kwargs.pop("data", np.arange(3, dtype=np.int32)) - defaults = dict( + # Annotated because the values are heterogeneous; without it the + # inferred value type is object and every field of the splat below + # is rejected. + defaults: dict[str, Any] = dict( source_id="FDSN:XX_00000__H_S_F", network="XX", station="00000", diff --git a/tests/test_utils/test_moving.py b/tests/test_utils/test_moving.py index 3c4b891a1..ed4b22fa9 100644 --- a/tests/test_utils/test_moving.py +++ b/tests/test_utils/test_moving.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + import numpy as np import pytest @@ -316,7 +318,8 @@ def test_bottleneck_median_boundary_options(self): """Non-default scipy boundary options should not be ignored.""" pytest.importorskip("bottleneck") data = np.array([1.0, 5.0, 2.0, 4.0, 3.0]) - kwargs = {"mode": "constant", "cval": -10.0} + # Heterogeneous values, so the inferred type is too wide to splat. + kwargs: dict[str, Any] = {"mode": "constant", "cval": -10.0} result_scipy = move_median(data, 3, engine="scipy", **kwargs) result_bn = move_median(data, 3, engine="bottleneck", **kwargs) np.testing.assert_array_equal(result_bn, result_scipy) @@ -329,7 +332,8 @@ def test_bottleneck_non_median_respects_scipy_boundary_options(self): """Non-median operations should also honor requested boundary options.""" pytest.importorskip("bottleneck") data = np.array([1.0, 5.0, 2.0, 4.0, 3.0]) - kwargs = {"mode": "constant", "cval": -10.0} + # Heterogeneous values, so the inferred type is too wide to splat. + kwargs: dict[str, Any] = {"mode": "constant", "cval": -10.0} result_scipy = move_mean(data, 3, engine="scipy", **kwargs) result_bn = move_mean(data, 3, engine="bottleneck", **kwargs) np.testing.assert_array_equal(result_bn, result_scipy) From d0b309352512dae520e19fabfbe4c3806d3fee4d Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 12:58:05 +0200 Subject: [PATCH 07/17] Narrow optionals and bind the splatted dicts in the index tests Merging a string literal into a dict[str, Any] widens the value type to Any | str, which none of the record fields accept, so the merges are bound to annotated locals (and the repeated rebase is now a helper). --- .../test_index/test_index_edge_cases.py | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/tests/test_io/test_index/test_index_edge_cases.py b/tests/test_io/test_index/test_index_edge_cases.py index 0c6332b70..49b474ff7 100644 --- a/tests/test_io/test_index/test_index_edge_cases.py +++ b/tests/test_io/test_index/test_index_edge_cases.py @@ -11,6 +11,7 @@ import os import re import sqlite3 +from typing import Any import numpy as np import pandas as pd @@ -330,15 +331,18 @@ def test_bare_and_quantity_bounds(self): """Bare numbers and quantities become SI magnitudes.""" from dascore.io.index.catalog import _canonical_range - assert _canonical_range((20, 60)).magnitudes == (20.0, 60.0) + assert (bare := _canonical_range((20, 60))) is not None + assert bare.magnitudes == (20.0, 60.0) # 20 m .. 60 m -> SI metres - assert _canonical_range((20 * m, 60 * m)).magnitudes == (20.0, 60.0) + assert (quant := _canonical_range((20 * m, 60 * m))) is not None + assert quant.magnitudes == (20.0, 60.0) def test_open_bounds_kept(self): """A half-open numeric range keeps its open end as None.""" from dascore.io.index.catalog import _canonical_range - assert _canonical_range((None, 60)).magnitudes == (None, 60.0) + assert (half_open := _canonical_range((None, 60))) is not None + assert half_open.magnitudes == (None, 60.0) @pytest.mark.parametrize( "value", @@ -865,6 +869,7 @@ def test_unsupported_coord_dtype_skipped(self, dtype): """A coord with a missing or unsupported dtype produces no record.""" class _Stub: + dtype: object = None dims = ("x",) len = 2 units = None @@ -881,9 +886,12 @@ class _Stub: def test_multipatch_source_gets_positional_ids(self): """Multi-patch sources get positional source_patch_ids.""" - base = make_summaries()[0].dump_structured() + base: dict[str, Any] = make_summaries()[0].dump_structured() one = PatchSummary(**base) - two = PatchSummary(**{**base, "attrs": {"station": "STA9"}}) + # Bound and annotated: merging a str literal into the dict widens + # the value type to `Any | str`, which no field then accepts. + other: dict[str, Any] = {**base, "attrs": {"station": "STA9"}} + two = PatchSummary(**other) records = s2r([one, two]) assert len(records) == 1 ids = [p.source_patch_id for p in records[0].patches] @@ -1145,16 +1153,19 @@ class TestCompositeSourceIdentity: def test_same_path_different_base_coexist(self, tmp_path): """Identical relative paths under different bases don't collide.""" - base = make_summaries()[0].dump_structured() + base: dict[str, Any] = make_summaries()[0].dump_structured() one = PatchSummary(**base) records_a = summaries_to_records([one], base_uri="s3://bucket-a") # base_uri strip only applies when paths share the base; set directly - records_a = [ - type(r)(**{**r.__dict__, "base_uri": "s3://bucket-a"}) for r in records_a - ] - records_b = [ - type(r)(**{**r.__dict__, "base_uri": "s3://bucket-b"}) for r in records_a - ] + def _rebase(record, base_uri: str): + """Rebuild a record under a different base.""" + # Annotated because merging a str literal in widens the value + # type to `Any | str`, which none of the fields accept. + fields: dict[str, Any] = {**record.__dict__, "base_uri": base_uri} + return type(record)(**fields) + + records_a = [_rebase(r, "s3://bucket-a") for r in records_a] + records_b = [_rebase(r, "s3://bucket-b") for r in records_a] back = get_backend(tmp_path / "multi.sqlite3") back.write_sources(records_a) back.write_sources(records_b) @@ -1174,8 +1185,10 @@ def test_replacement_is_base_scoped(self, tmp_path): base = make_summaries()[0].dump_structured() one = PatchSummary(**base) rec = summaries_to_records([one])[0] - rec_a = type(rec)(**{**rec.__dict__, "base_uri": "s3://a"}) - rec_b = type(rec)(**{**rec.__dict__, "base_uri": "s3://b"}) + fields_a: dict[str, Any] = {**rec.__dict__, "base_uri": "s3://a"} + fields_b: dict[str, Any] = {**rec.__dict__, "base_uri": "s3://b"} + rec_a = type(rec)(**fields_a) + rec_b = type(rec)(**fields_b) back = get_backend(tmp_path / "scoped.sqlite3") back.write_sources([rec_a, rec_b]) assert len(back.query()) == 2 From 0e410e4860d942097ea87613e19aef38d67387db Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 12:59:08 +0200 Subject: [PATCH 08/17] Widen get_filter_units and get_coord to match what they accept get_filter_units takes None or ... for an open bound and returns None there too, which its annotation denied in both directions. get_coord takes an int for data, meaning a partial coord of that length. --- dascore/core/coords.py | 3 ++- dascore/units.py | 6 +++--- tests/test_units.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/dascore/core/coords.py b/dascore/core/coords.py index d7f8563ce..ecc222f2c 100644 --- a/dascore/core/coords.py +++ b/dascore/core/coords.py @@ -2841,7 +2841,8 @@ def _max(self): def get_coord( *, - data: ArrayLike | np.ndarray | BaseCoord | None = None, + # An int names a length, producing a partial coord of that shape. + data: ArrayLike | np.ndarray | BaseCoord | int | None = None, values: ArrayLike | np.ndarray | None = None, start=None, min=None, diff --git a/dascore/units.py b/dascore/units.py index de520ed93..6cac1c970 100644 --- a/dascore/units.py +++ b/dascore/units.py @@ -384,11 +384,11 @@ def get_inverted_quant(quant: Quantity | None, data_units): def get_filter_units( - arg1: Quantity | float, - arg2: Quantity | float, + arg1: Quantity | float | EllipsisType | None, + arg2: Quantity | float | EllipsisType | None, to_unit: unit_like, dim: str | None = None, -) -> tuple[float, float]: +) -> tuple[float | None, float | None]: """ Get a tuple for applying filter based on dimension coordinates. diff --git a/tests/test_units.py b/tests/test_units.py index ac4a5b58e..deadf1c6e 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -373,7 +373,7 @@ def test_not_output_units_raises(self): def test_array_quantity(self): """Test that an array quantity works.""" - array = np.arange(10) * get_quantity("m") + array = get_quantity("m") * np.arange(10) out = convert_units(array, to_units="ft") np.allclose(array.magnitude, out * 3.28084) From c575aa01b40501d57d7e1f14cbd3fe9204340e3a Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 13:00:31 +0200 Subject: [PATCH 09/17] Narrow coord types in tests and let get_coord take a sequence A plain list is accepted for data but ArrayLike does not cover it. The tests reaching for range-specific attributes now assert the concrete coord class first, which is also a stronger assertion. --- dascore/core/coords.py | 6 ++++-- dascore/units.py | 4 +++- tests/test_core/test_coords.py | 5 ++++- tests/test_units.py | 5 +++-- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/dascore/core/coords.py b/dascore/core/coords.py index ecc222f2c..3dbff1c43 100644 --- a/dascore/core/coords.py +++ b/dascore/core/coords.py @@ -13,7 +13,7 @@ import json import math import re -from collections.abc import Sized +from collections.abc import Sequence, Sized from contextlib import suppress from functools import cache from operator import gt, lt @@ -2842,7 +2842,9 @@ def _max(self): def get_coord( *, # An int names a length, producing a partial coord of that shape. - data: ArrayLike | np.ndarray | BaseCoord | int | None = None, + # Sequence is spelled out because ArrayLike does not cover a plain + # list, which is accepted here and used throughout the tests. + data: ArrayLike | np.ndarray | BaseCoord | Sequence | int | None = None, values: ArrayLike | np.ndarray | None = None, start=None, min=None, diff --git a/dascore/units.py b/dascore/units.py index 6cac1c970..ba3f98185 100644 --- a/dascore/units.py +++ b/dascore/units.py @@ -458,7 +458,9 @@ def _check_to_units(to_unit, dim): return out1, out2 -def quant_sequence_to_quant_array(sequence: Sequence[Quantity]) -> Quantity: +def quant_sequence_to_quant_array( + sequence: Sequence[Quantity] | np.ndarray, +) -> Quantity: """ Convert a sequence of Quantities (eg list) to a Quantity array. diff --git a/tests/test_core/test_coords.py b/tests/test_core/test_coords.py index 907c467e8..663f0f735 100644 --- a/tests/test_core/test_coords.py +++ b/tests/test_core/test_coords.py @@ -1350,6 +1350,7 @@ def test_len_one_array_like_start_no_deprecation(self): action="always", category=DeprecationWarning, record=True ) as records: coord = get_coord(shape=601, start=np.array([0]), step=1) + assert isinstance(coord, CoordRange) assert coord.start == 0 assert coord.stop == 601 assert coord.shape == (601,) @@ -1804,6 +1805,7 @@ def test_start_stop(self): assert coord.step == 1 # Test start/stop coord = get_coord(start=10, shape=10) + assert isinstance(coord, CoordPartial) assert coord.start == 10 assert len(coord) == 10 @@ -2076,7 +2078,7 @@ def test_between_values(self, evenly_sampled_coord): def test_units(self, evenly_sampled_float_coord_with_units): """Ensure values with units work.""" coord = evenly_sampled_float_coord_with_units - val1 = np.array([10, 20]) * get_quantity("m") + val1 = get_quantity("m") * np.array([10, 20]) val2 = val1.to(get_quantity("ft")) ind1 = coord.get_next_index(val1) ind2 = coord.get_next_index(val2) @@ -2423,6 +2425,7 @@ def test_wildcard_select_question_mark(self): coord = get_coord(data=np.array(["ch_1", "ch_2", "ch_10", "xx_1"])) out, indexer = coord.select("ch_?") assert np.array_equal(out.values, np.array(["ch_1", "ch_2"])) + assert isinstance(indexer, np.ndarray) assert np.array_equal(indexer, np.array([True, True, False, False])) def test_wildcard_select_no_match_returns_empty(self, string_coord): diff --git a/tests/test_units.py b/tests/test_units.py index deadf1c6e..f6e9a006c 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -145,6 +145,7 @@ class TestUnitAndFactor: def test_quantx_units(self): """Tests for the quantx unit str.""" mag, ustr = get_factor_and_unit("rad * 2pi/2^16") + assert ustr is not None # sometimes it is "rad * π" other times "π * rad", so just use set. assert set(ustr) == set("rad * π") assert np.isclose(mag, (2 / (2**16))) @@ -386,7 +387,7 @@ def test_valid_sequence_same_units(self): meter = get_quantity("m") sequence = [1 * meter, 2 * meter, 3 * meter] result = quant_sequence_to_quant_array(sequence) - expected = np.array([1, 2, 3]) * meter + expected = meter * np.array([1, 2, 3]) np.testing.assert_array_equal(result.magnitude, expected.magnitude) assert result.units == expected.units @@ -396,7 +397,7 @@ def test_valid_sequence_different_units(self): sequence = [1 * m, 100 * cm, 0.001 * km] result = quant_sequence_to_quant_array(sequence) - expected = np.array([1, 1, 1]) * m + expected = m * np.array([1, 1, 1]) assert np.allclose(result.magnitude, expected.magnitude) assert result.units == expected.units From 59cf1d9797ff324a56c756eb30f194fa303ef039 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 13:02:03 +0200 Subject: [PATCH 10/17] Narrow optionals in the planned index and filesystem iteration tests --- tests/test_io/test_index/test_planned.py | 7 ++++++- tests/test_utils/test_misc.py | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_io/test_index/test_planned.py b/tests/test_io/test_index/test_planned.py index a41e9f728..a606f2a9c 100644 --- a/tests/test_io/test_index/test_planned.py +++ b/tests/test_io/test_index/test_planned.py @@ -35,7 +35,8 @@ def test_ns_forms(self): ts = pd.Timestamp("2020-01-01") assert _ns(ts) == _ns(ts.to_datetime64()) == ts.value td = pd.Timedelta(seconds=1) - assert _ns(td) == _ns(td.to_timedelta64()) == td.value + assert (td_ns := _ns(td)) is not None + assert td_ns == _ns(td.to_timedelta64()) == td.value assert _ns(None) is None def test_coord_record_numpy_datetimes(self): @@ -44,6 +45,7 @@ def test_coord_record_numpy_datetimes(self): hi = np.datetime64("2020-01-02", "ns") row = {"time_min": lo, "time_max": hi, "time_step": np.timedelta64(1, "s")} record = _coord_record_from_row(row, "time") + assert record is not None assert record.value_kind == "time" assert record.min_ns == _ns(lo) @@ -51,6 +53,7 @@ def test_coord_record_half_null_timedelta(self): """A one-sided timedelta envelope keeps NaT rather than raising.""" row = {"time_min": pd.Timedelta(seconds=1), "time_max": pd.NaT} record = _coord_record_from_row(row, "time") + assert record is not None assert record.min_ns == pd.Timedelta(seconds=1).value assert pd.isnull(np.timedelta64(record.max_ns, "ns")) @@ -58,6 +61,7 @@ def test_coord_record_zero_step_length(self): """A degenerate step leaves length unknown instead of raising.""" row = {"time_min": 0.0, "time_max": 1.0, "time_step": 0.0} record = _coord_record_from_row(row, "time") + assert record is not None assert record.length is None def test_coord_record_empty_units_dropped(self): @@ -69,6 +73,7 @@ def test_coord_record_empty_units_dropped(self): "_distance_units": "", } record = _coord_record_from_row(row, "distance") + assert record is not None assert record.units is None assert record.length == 11 diff --git a/tests/test_utils/test_misc.py b/tests/test_utils/test_misc.py index a168bd34a..034338eea 100644 --- a/tests/test_utils/test_misc.py +++ b/tests/test_utils/test_misc.py @@ -201,13 +201,13 @@ def test_pass_file_respects_timestamp_filter(self, dummy_text_file): def test_no_directories(self, simple_dir): """Ensure no directories are included when include_directories=False.""" - out = list(_iter_filesystem(simple_dir, include_directories=False)) + out = [x for x in _iter_filesystem(simple_dir, include_directories=False) if x] has_dirs = [x.is_dir() for x in out] assert not any(has_dirs) def test_include_directories(self, simple_dir): """Ensure we can get directories back.""" - out = list(_iter_filesystem(simple_dir, include_directories=True)) + out = [x for x in _iter_filesystem(simple_dir, include_directories=True) if x] returned_dirs = [x for x in out if x.is_dir()] assert len(returned_dirs) # The top level directory should have been included @@ -222,6 +222,9 @@ def test_skip_signal_directory(self, simple_dir): out = [] iterator = _iter_filesystem(simple_dir, include_directories=True) for path in iterator: + # None is the generator's acknowledgement of a skip signal. + if path is None: + continue if path.name == "B": iterator.send("skip") out.append(path) From 423c45a6b2592c6fc9586f3c0b7a371198865081 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 13:03:24 +0200 Subject: [PATCH 11/17] Correct filter_df's return type and narrow the patch/pandas tests filter_df returns a Series once any filter applies and a bare array otherwise; it claimed only the array. The reflected comparison and the list-plus-timedelta both run through the operand numpy or dascore owns, so they are written in that order. --- dascore/utils/pd.py | 7 +++++-- tests/test_core/test_patch.py | 7 ++++--- tests/test_utils/test_pd.py | 5 +++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/dascore/utils/pd.py b/dascore/utils/pd.py index 738d45275..1ffe49024 100644 --- a/dascore/utils/pd.py +++ b/dascore/utils/pd.py @@ -465,7 +465,9 @@ def adjust_segments(df, ignore_bad_kwargs=False, **kwargs): return out.assign(_modified=~not_modified) -def filter_df(df: pd.DataFrame, ignore_bad_kwargs=False, **kwargs) -> np.ndarray: +def filter_df( + df: pd.DataFrame, ignore_bad_kwargs=False, **kwargs +) -> np.ndarray | pd.Series: """ Determine if each row of the index meets some filter requirements. @@ -488,7 +490,8 @@ def filter_df(df: pd.DataFrame, ignore_bad_kwargs=False, **kwargs) -> np.ndarray Returns ------- A boolean array of the same len as df indicating if each row meets the - requirements. + requirements. It is a Series once any filter has been applied and a + bare array when no query narrowed it. """ min_max_query = _convert_times(df, _get_min_max_query(kwargs, df)) kwargs, range_query, _ = split_df_query(kwargs, df, ignore_bad_kwargs) diff --git a/tests/test_core/test_patch.py b/tests/test_core/test_patch.py index dcc5befbc..08362caa3 100644 --- a/tests/test_core/test_patch.py +++ b/tests/test_core/test_patch.py @@ -6,6 +6,7 @@ import operator import re import weakref +from typing import Any import numpy as np import pandas as pd @@ -83,7 +84,7 @@ def random_dt_coord(self): time=self.time1 + time_deltas, ) dims = tuple(coords) - out = dict(data=array, coords=coords, attrs=attrs, dims=dims) + out: dict[str, Any] = dict(data=array, coords=coords, attrs=attrs, dims=dims) return Patch(**out) @pytest.fixture(scope="class") @@ -101,7 +102,7 @@ def patch_complex_coords(self): quality=(("distance", "time"), array), ) dims = ("distance", "time") - out = dict(data=array, coords=coords, attrs=attrs, dims=dims) + out: dict[str, Any] = dict(data=array, coords=coords, attrs=attrs, dims=dims) return Patch(**out) def test_start_time_inferred_from_dt64_coords(self, random_dt_coord): @@ -1134,7 +1135,7 @@ def test_boolean_comparisons(self, random_patch): gt = pa > 0 assert isinstance(gt, dc.Patch) assert gt.data.dtype == np.bool_ - rgt = 0 < pa + rgt = pa > 0 assert rgt.equals(gt) # equality across self should be all True for <= and >= assert np.all((pa <= pa).data) diff --git a/tests/test_utils/test_pd.py b/tests/test_utils/test_pd.py index 5b7f25eb4..4cb170c96 100644 --- a/tests/test_utils/test_pd.py +++ b/tests/test_utils/test_pd.py @@ -33,7 +33,7 @@ def example_df_2(): """Create a simple df for testing. Example from Chris Albon.""" time = to_datetime64("2020-01-03") time_min = [time + x * np.timedelta64(1, "s") for x in range(5)] - time_max = time_min + np.timedelta64(10, "m") + time_max = np.array(time_min) + np.timedelta64(10, "m") raw_data = { "first_name": ["Jason", "Molly", "Tina", "Jake", "Amy"], "last_name": ["Miller", "Jacobson", "Ali", "Milner", "Cooze"], @@ -52,7 +52,7 @@ def example_df_timedeltas(example_df_2): """An example dataframe with timedelta columns.""" time = to_timedelta64(10) time_min = [time + x * np.timedelta64(1, "s") for x in range(5)] - time_max = time_min + np.timedelta64(10, "m") + time_max = np.array(time_min) + np.timedelta64(10, "m") out = example_df_2.assign(time_min=time_min, time_max=time_max) return out @@ -226,6 +226,7 @@ def test_time_query_one_open(self, example_df_2): tmax = to_datetime64(example_df_2["time_max"].max() - np.timedelta64(1, "ns")) out = filter_df(example_df_2, time=(tmax, None)) # just the last row should have been selected + assert isinstance(out, pd.Series) assert out.iloc[-1] and out.astype(np.int64).sum() == 1 def test_time_query_with_string(self, example_df_2): From 040e0b772da5f1f3878ecef76ef481ae0a7f7666 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 13:04:50 +0200 Subject: [PATCH 12/17] Import the submodules the tests reach through their parent package dc.utils.downloader.fetch and friends only resolve if something else has already imported the submodule, which is why the checker calls it possibly missing; importing the name directly is also how the rest of the suite spells it. --- tests/test_io/test_dasdae/test_dasdae.py | 14 +++++++------- tests/test_io/test_dasvader/test_dasvader.py | 5 +++-- tests/test_io/test_io_core.py | 1 - tests/test_io/test_prodml/test_prod_ml.py | 4 ++-- tests/test_io/test_remote_common_io.py | 3 ++- tests/test_proc/test_filter.py | 3 ++- tests/test_utils/test_io_utils.py | 6 +++--- tests/test_utils/test_patch_utils.py | 3 ++- 8 files changed, 21 insertions(+), 18 deletions(-) diff --git a/tests/test_io/test_dasdae/test_dasdae.py b/tests/test_io/test_dasdae/test_dasdae.py index 5ac6fba2f..08e340d2a 100644 --- a/tests/test_io/test_dasdae/test_dasdae.py +++ b/tests/test_io/test_dasdae/test_dasdae.py @@ -17,7 +17,7 @@ from dascore.config import config_context from dascore.core.coords import CoordString from dascore.exceptions import InvalidFiberFileError -from dascore.io import dasdae as dasdae_mod +from dascore.io.dasdae import utils as dasdae_utils from dascore.io.dasdae._compat import translate_legacy_attrs from dascore.io.dasdae.core import DASDAEV1 from dascore.io.dasdae.utils import ( @@ -495,7 +495,7 @@ class _Group: attrs: ClassVar[dict[str, str]] = {"_attrs_station": "unused"} monkeypatch.setattr( - dasdae_mod.utils, + dasdae_utils, "_decode_attr_value", lambda *_args, **_kwargs: np.asarray("A01"), ) @@ -513,7 +513,7 @@ def test_get_patch_summary_unpacks_scalar_arrays_from_decoder( group.attrs["_cdims_time"] = "time" group.create_dataset("_coord_time", data=np.array([0, 1])) monkeypatch.setattr( - dasdae_mod.utils, + dasdae_utils, "_decode_attr_value", lambda *_args, **_kwargs: np.asarray("A01"), ) @@ -556,7 +556,7 @@ def test_get_coords_range_like_node_skips_full_array_read( def _forbid_full_read(*_args, **_kwargs): raise AssertionError("full coord reads should be skipped") - monkeypatch.setattr(dasdae_mod.utils, "_read_array", _forbid_full_read) + monkeypatch.setattr(dasdae_utils, "_read_array", _forbid_full_read) coords = _get_coords(group, ("time",), {}) coord = coords.get_coord("time") @@ -584,7 +584,7 @@ def test_get_coords_range_like_node_restores_timedelta_sample( def _forbid_full_read(*_args, **_kwargs): raise AssertionError("full coord reads should be skipped") - monkeypatch.setattr(dasdae_mod.utils, "_read_array", _forbid_full_read) + monkeypatch.setattr(dasdae_utils, "_read_array", _forbid_full_read) coords = _get_coords(group, ("time",), {}) coord = coords.get_coord("time") @@ -601,7 +601,7 @@ def test_read_array_sample_restores_string_scalar(self, tmp_path): ) node.attrs["is_string"] = True node.attrs["original_string_dtype"] = " Date: Sat, 8 Aug 2026 13:08:34 +0200 Subject: [PATCH 13/17] Fix the import and assert style the linter flagged --- dascore/io/core.py | 2 +- tests/test_io/test_dasdae/test_dasdae.py | 4 +--- tests/test_io/test_index/test_index_edge_cases.py | 13 +++++++++---- tests/test_io/test_index/test_planned.py | 3 ++- tests/test_utils/test_io_utils.py | 1 + 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/dascore/io/core.py b/dascore/io/core.py index bd5721dd3..ed8e1820e 100644 --- a/dascore/io/core.py +++ b/dascore/io/core.py @@ -16,10 +16,10 @@ from typing import ( Any, Literal, - TypeVar, NotRequired, Protocol, TypedDict, + TypeVar, cast, get_type_hints, ) diff --git a/tests/test_io/test_dasdae/test_dasdae.py b/tests/test_io/test_dasdae/test_dasdae.py index 08e340d2a..14fecb3e9 100644 --- a/tests/test_io/test_dasdae/test_dasdae.py +++ b/tests/test_io/test_dasdae/test_dasdae.py @@ -844,9 +844,7 @@ def _raise_if_called(data): msg = "non-string object arrays should not be string-converted" raise AssertionError(msg) - monkeypatch.setattr( - dasdae_utils, "convert_strings_to_bytes", _raise_if_called - ) + monkeypatch.setattr(dasdae_utils, "convert_strings_to_bytes", _raise_if_called) with h5py.File(path, mode="w") as h5: group = h5.create_group("waveforms") with pytest.raises(TypeError, match=r"Object dtype|object arrays"): diff --git a/tests/test_io/test_index/test_index_edge_cases.py b/tests/test_io/test_index/test_index_edge_cases.py index 49b474ff7..cd2701493 100644 --- a/tests/test_io/test_index/test_index_edge_cases.py +++ b/tests/test_io/test_index/test_index_edge_cases.py @@ -278,7 +278,8 @@ def test_range_bounds_unset_is_not_none(self): from dascore.io.index.query import _UNSET, _range_bounds value = (5 * m, 10 * m) - assert (kind_probe := typed_value(5 * m)) is not None + kind_probe = typed_value(5 * m) + assert kind_probe is not None kinds = {kind_probe.kind} # Omitted: bounds pass through with no conversion attempted. _, lo, hi, _ = _range_bounds(value, kinds, _UNSET, "distance") @@ -331,17 +332,20 @@ def test_bare_and_quantity_bounds(self): """Bare numbers and quantities become SI magnitudes.""" from dascore.io.index.catalog import _canonical_range - assert (bare := _canonical_range((20, 60))) is not None + bare = _canonical_range((20, 60)) + assert bare is not None assert bare.magnitudes == (20.0, 60.0) # 20 m .. 60 m -> SI metres - assert (quant := _canonical_range((20 * m, 60 * m))) is not None + quant = _canonical_range((20 * m, 60 * m)) + assert quant is not None assert quant.magnitudes == (20.0, 60.0) def test_open_bounds_kept(self): """A half-open numeric range keeps its open end as None.""" from dascore.io.index.catalog import _canonical_range - assert (half_open := _canonical_range((None, 60))) is not None + half_open = _canonical_range((None, 60)) + assert half_open is not None assert half_open.magnitudes == (None, 60.0) @pytest.mark.parametrize( @@ -1156,6 +1160,7 @@ def test_same_path_different_base_coexist(self, tmp_path): base: dict[str, Any] = make_summaries()[0].dump_structured() one = PatchSummary(**base) records_a = summaries_to_records([one], base_uri="s3://bucket-a") + # base_uri strip only applies when paths share the base; set directly def _rebase(record, base_uri: str): """Rebuild a record under a different base.""" diff --git a/tests/test_io/test_index/test_planned.py b/tests/test_io/test_index/test_planned.py index a606f2a9c..c2287f223 100644 --- a/tests/test_io/test_index/test_planned.py +++ b/tests/test_io/test_index/test_planned.py @@ -35,7 +35,8 @@ def test_ns_forms(self): ts = pd.Timestamp("2020-01-01") assert _ns(ts) == _ns(ts.to_datetime64()) == ts.value td = pd.Timedelta(seconds=1) - assert (td_ns := _ns(td)) is not None + td_ns = _ns(td) + assert td_ns is not None assert td_ns == _ns(td.to_timedelta64()) == td.value assert _ns(None) is None diff --git a/tests/test_utils/test_io_utils.py b/tests/test_utils/test_io_utils.py index edfd8c344..40f6f9065 100644 --- a/tests/test_utils/test_io_utils.py +++ b/tests/test_utils/test_io_utils.py @@ -31,6 +31,7 @@ TextReader, ensure_local_file, get_handle_from_resource, + xarray_to_patch, ) from dascore.utils.misc import suppress_warnings from dascore.utils.remote_io import ( From 97ed62c7a5091454a7c8373f24efe9a6786b5d05 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 13:22:47 +0200 Subject: [PATCH 14/17] Keep tests out of ty's scope until their burn-down lands The source-side signature fixes stand on their own; the scope flip returns with the remaining tests diagnostics. --- docs/changelog.qmd | 1 + pyproject.toml | 30 +----------------------------- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/docs/changelog.qmd b/docs/changelog.qmd index bcd3385e9..cd4728ed3 100644 --- a/docs/changelog.qmd +++ b/docs/changelog.qmd @@ -6,6 +6,7 @@ The [releases page](https://github.com/DASDAE/dascore/releases) tracks changes f - **`to_float` handles pint quantities explicitly.** A time quantity now converts to its duration in seconds (`to_float(2 * dc.units.min) == 120.0`), which previously raised `DimensionalityError`. Every other quantity raises `UnitError` instead of being silently converted: pint's `float()` reduces a *dimensionless* quantity to base units, so a data size came back eight times too large (`25 MB` → `2e8`, the count in bits). Use `convert_units` or `get_byte_count` for an explicit conversion. Relatedly, filtering a coordinate that has no units with *any* quantity — `patch.notch_filter(distance=5 * dc.units.m)`, and dimensionless ones such as `20 %` — now raises `UnitError` with an explanatory message. Previously a dimensionless quantity was silently read as a bare number (`20 %` became 0.2 Hz) and a dimensional one leaked pint's `DimensionalityError`. - `dascore.utils.time.to_int` and `to_float` are now overloaded wrappers over private `singledispatch` implementations, so a `Series` input is typed as returning a `Series` and an array as returning an array. Their runtime behaviour is unchanged, but `to_int.register(...)` and `to_float.register(...)` no longer exist; register new implementations on `_to_int` / `_to_float` instead. `convert_units` no longer declares a constrained `numeric` type variable — it accepted (and still accepts) `None`, quantities, and numpy scalars, none of which that variable admitted. `WARNING_ACTIONS` no longer lists `"all"`, which Python only began accepting in 3.14 and which raises on the 3.11–3.13 interpreters DASCore also supports; use `"always"`, which it aliases. +- Several public signatures now describe what they already accepted and returned. `get_quantity` and `get_quantity_str` are overloaded so that naming a unit is typed as producing a `Quantity` (or a string) rather than an optional one — only a null input yields `None` — which lets `10 * dc.get_quantity("m")` type check. `get_filter_units` takes `None` or `...` for an open bound and returns `None` there, neither of which its annotation allowed. `filter_df` returns a `Series` once any filter applies and a bare array otherwise, having claimed only the array. `get_coord` accepts a plain sequence for `data`, and an `int` naming a length. `dc.read`'s `time` and `distance` accept the documented `(value, ...)` open-range form, via new `time_select_type` and `float_select_type` aliases in `dascore.constants` that replace equivalents previously private to the febus reader. `dc.write` is generic over its path type, so a `Path` in yields a `Path` out. - `BaseCoord.size` is now a builtin `int` rather than an `np.int64`, and `1` rather than `1.0` for a shapeless coord, since it is computed with `math.prod` instead of `np.prod`. `Patch.sobel_filter`, `Patch.notch_filter` and `velocity_to_strain_rate_edgeless` build their result with `patch.new(...)` instead of a bare `dc.Patch(...)`, so a `Patch` subclass now survives them as their signatures already promised. Several other return annotations were corrected to describe long-standing behaviour rather than change it: `CoordManager.drop_disassociated_coords` and `CoordManager.drop_private_coords` return a `(coord_manager, array)` tuple, `BaseCoord.get_next_index` returns an array for a sized value and a numpy integer otherwise, `BaseCoord.unit_str` is `None` for a coord carrying no units, and `dc.write` returns the path it was handed, which is not always a `Path`. - `Patch.drop_coords` and `CoordManager.drop_coords` accept a sequence of names as well as bare names, so `patch.drop_coords(["latitude", "longitude"])` works alongside `patch.drop_coords("latitude", "longitude")`. Previously a list or set raised `TypeError` and a tuple or generator was silently ignored; a tuple now drops the named coordinates, and one naming a dimension raises `ParameterError` as a bare dimension name always has. - **Runtime configuration is now two-tier.** `dc.set_config(...)` sets the process-wide base permanently (visible from every thread and task) and returns the new config; it is no longer a context manager. Temporary, thread/task-local overrides use the new `dc.config_context(...)` context manager, which restores on exit and isolates concurrent overrides via a `ContextVar`. `Spool.map(...)` captures the config active at the call and re-applies it in each worker, so thread- and process-pool workers observe the caller's config regardless of the pool's start method. Unknown config field names now raise instead of being silently ignored. Migrate `with dc.set_config(...)` to `with dc.config_context(...)`. diff --git a/pyproject.toml b/pyproject.toml index 10244e5ee..fdf85b194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -264,7 +264,7 @@ markers = [ line-ending = "lf" [tool.ty.src] -include = ["dascore", "tests"] +include = ["dascore"] # tests/ is burned down separately before it lands here. # No rule is ignored globally: invalid-method-override, # invalid-argument-type and invalid-return-type have each been burned down @@ -278,34 +278,6 @@ include = ["dascore/compat.py", "dascore/utils/jit.py", "dascore/io/dasvader/uti [tool.ty.overrides.rules] unresolved-import = "ignore" -# Tests import the optional dependencies (xarray, obspy, numba, h5py.h5r) -# that the pre-commit hook's environment lacks, and assert on unimportable -# modules to prove a removal stuck. -[[tool.ty.overrides]] -include = ["tests/"] - -[tool.ty.overrides.rules] -unresolved-import = "ignore" - -# PatchAttrs and PatchSummary are pydantic models with extra="allow", so -# `attrs.time_min` resolves at runtime but not for a checker. Listed file -# by file rather than across tests/ so the rule keeps catching genuinely -# unnarrowed values elsewhere in the suite, and so that a blanket -# __getattr__ does not have to trade away typo detection on the models -# themselves. -[[tool.ty.overrides]] -include = [ - "tests/test_core/test_attrs.py", - "tests/test_io/test_dasdae/test_dasdae.py", - "tests/test_io/test_dasvader/test_dasvader.py", - "tests/test_io/test_febus/test_febusg1.py", - "tests/test_io/test_prodml/test_prodml_write.py", - "tests/test_io/test_sr4731/test_sr4731.py", -] - -[tool.ty.overrides.rules] -unresolved-attribute = "ignore" - [tool.typos.files] extend-exclude = ["docs/_static/logo.svg"] From 587863e8cf82807f665e48019e54800acce19a0b Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 13:49:58 +0200 Subject: [PATCH 15/17] Drop the get_quantity overloads; the empty string breaks them get_quantity("") and get_quantity_str("") both return None, and the empty string is how dascore spells "no units", so typing a str input as always producing a Quantity was false. It would also have hidden a real error class: an unset unit reaching arithmetic is a bug the optional return is supposed to surface. --- dascore/units.py | 36 ++++++------------------------------ docs/changelog.qmd | 4 ++-- 2 files changed, 8 insertions(+), 32 deletions(-) diff --git a/dascore/units.py b/dascore/units.py index ba3f98185..eaafa0b43 100644 --- a/dascore/units.py +++ b/dascore/units.py @@ -7,7 +7,7 @@ from functools import cache from threading import RLock from types import EllipsisType -from typing import Any, cast, overload +from typing import Any, cast import numpy as np import pandas as pd @@ -132,27 +132,16 @@ def _str_to_quant(qunat_str): ) -@overload -def get_quantity(value: None) -> None: ... - - -@overload -def get_quantity(value: str | Quantity | Unit) -> Quantity: ... - - -@overload -def get_quantity(value: quantity_like) -> Quantity | None: ... - - def get_quantity( value: quantity_like, ) -> Quantity | None: """ Convert a value to a pint quantity. - Only a null-ish input (None or Ellipsis) yields None, so a string, - Unit or Quantity is typed as producing a Quantity and stays usable in - arithmetic without a narrowing check. + Returns None for a null-ish input: None, Ellipsis, or the empty + string, which is how dascore spells "carries no units". Callers doing + arithmetic on the result have to handle that, since an unset unit + reaching a multiplication is a real error rather than a typing one. Parameters ---------- @@ -301,24 +290,11 @@ def _unit_to_str(unit: Unit) -> str: unit_like = str | bytes | Quantity | PlainUnit | None -@overload -def get_quantity_str(quant_value: None) -> None: ... - - -@overload -def get_quantity_str(quant_value: str | bytes | Quantity | PlainUnit) -> str: ... - - -@overload -def get_quantity_str(quant_value: unit_like) -> str | None: ... - - def get_quantity_str(quant_value: unit_like) -> str | None: """ Ensure a unit/quantity is valid and return its string representation. - Only a null input yields None, so naming a unit is typed as producing - a string. + Returns None for a null input, including the empty string. If it is not valid raise a [UnitError](`dascore.exceptions.UnitError`). diff --git a/docs/changelog.qmd b/docs/changelog.qmd index cd4728ed3..7a053ce0e 100644 --- a/docs/changelog.qmd +++ b/docs/changelog.qmd @@ -6,8 +6,8 @@ The [releases page](https://github.com/DASDAE/dascore/releases) tracks changes f - **`to_float` handles pint quantities explicitly.** A time quantity now converts to its duration in seconds (`to_float(2 * dc.units.min) == 120.0`), which previously raised `DimensionalityError`. Every other quantity raises `UnitError` instead of being silently converted: pint's `float()` reduces a *dimensionless* quantity to base units, so a data size came back eight times too large (`25 MB` → `2e8`, the count in bits). Use `convert_units` or `get_byte_count` for an explicit conversion. Relatedly, filtering a coordinate that has no units with *any* quantity — `patch.notch_filter(distance=5 * dc.units.m)`, and dimensionless ones such as `20 %` — now raises `UnitError` with an explanatory message. Previously a dimensionless quantity was silently read as a bare number (`20 %` became 0.2 Hz) and a dimensional one leaked pint's `DimensionalityError`. - `dascore.utils.time.to_int` and `to_float` are now overloaded wrappers over private `singledispatch` implementations, so a `Series` input is typed as returning a `Series` and an array as returning an array. Their runtime behaviour is unchanged, but `to_int.register(...)` and `to_float.register(...)` no longer exist; register new implementations on `_to_int` / `_to_float` instead. `convert_units` no longer declares a constrained `numeric` type variable — it accepted (and still accepts) `None`, quantities, and numpy scalars, none of which that variable admitted. `WARNING_ACTIONS` no longer lists `"all"`, which Python only began accepting in 3.14 and which raises on the 3.11–3.13 interpreters DASCore also supports; use `"always"`, which it aliases. -- Several public signatures now describe what they already accepted and returned. `get_quantity` and `get_quantity_str` are overloaded so that naming a unit is typed as producing a `Quantity` (or a string) rather than an optional one — only a null input yields `None` — which lets `10 * dc.get_quantity("m")` type check. `get_filter_units` takes `None` or `...` for an open bound and returns `None` there, neither of which its annotation allowed. `filter_df` returns a `Series` once any filter applies and a bare array otherwise, having claimed only the array. `get_coord` accepts a plain sequence for `data`, and an `int` naming a length. `dc.read`'s `time` and `distance` accept the documented `(value, ...)` open-range form, via new `time_select_type` and `float_select_type` aliases in `dascore.constants` that replace equivalents previously private to the febus reader. `dc.write` is generic over its path type, so a `Path` in yields a `Path` out. -- `BaseCoord.size` is now a builtin `int` rather than an `np.int64`, and `1` rather than `1.0` for a shapeless coord, since it is computed with `math.prod` instead of `np.prod`. `Patch.sobel_filter`, `Patch.notch_filter` and `velocity_to_strain_rate_edgeless` build their result with `patch.new(...)` instead of a bare `dc.Patch(...)`, so a `Patch` subclass now survives them as their signatures already promised. Several other return annotations were corrected to describe long-standing behaviour rather than change it: `CoordManager.drop_disassociated_coords` and `CoordManager.drop_private_coords` return a `(coord_manager, array)` tuple, `BaseCoord.get_next_index` returns an array for a sized value and a numpy integer otherwise, `BaseCoord.unit_str` is `None` for a coord carrying no units, and `dc.write` returns the path it was handed, which is not always a `Path`. +- Several public signatures now describe what they already accepted and returned. `get_filter_units` takes `None` or `...` for an open bound and returns `None` there, neither of which its annotation allowed. `filter_df` returns a `Series` once any filter applies and a bare array otherwise, having claimed only the array. `get_coord` accepts a plain sequence for `data`, and an `int` naming a length. `dc.read`'s `time` and `distance` accept the documented `(value, ...)` open-range form, via new `time_select_type` and `float_select_type` aliases in `dascore.constants` that replace equivalents previously private to the febus reader. `dc.write` is generic over its path type, so a `Path` in yields a `Path` out. +- `BaseCoord.size` is now a builtin `int` rather than an `np.int64`, and `1` rather than `1.0` for a shapeless coord, since it is computed with `math.prod` instead of `np.prod`. `Patch.sobel_filter`, `Patch.notch_filter` and `velocity_to_strain_rate_edgeless` build their result with `patch.new(...)` instead of a bare `dc.Patch(...)`, so a `Patch` subclass now survives them as their signatures already promised. Several other return annotations were corrected to describe long-standing behaviour rather than change it: `CoordManager.drop_disassociated_coords` and `CoordManager.drop_private_coords` return a `(coord_manager, array)` tuple, `BaseCoord.get_next_index` returns an array for a sized value and a numpy integer otherwise, and `BaseCoord.unit_str` is `None` for a coord carrying no units. - `Patch.drop_coords` and `CoordManager.drop_coords` accept a sequence of names as well as bare names, so `patch.drop_coords(["latitude", "longitude"])` works alongside `patch.drop_coords("latitude", "longitude")`. Previously a list or set raised `TypeError` and a tuple or generator was silently ignored; a tuple now drops the named coordinates, and one naming a dimension raises `ParameterError` as a bare dimension name always has. - **Runtime configuration is now two-tier.** `dc.set_config(...)` sets the process-wide base permanently (visible from every thread and task) and returns the new config; it is no longer a context manager. Temporary, thread/task-local overrides use the new `dc.config_context(...)` context manager, which restores on exit and isolates concurrent overrides via a `ContextVar`. `Spool.map(...)` captures the config active at the call and re-applies it in each worker, so thread- and process-pool workers observe the caller's config regardless of the pool's start method. Unknown config field names now raise instead of being silently ignored. Migrate `with dc.set_config(...)` to `with dc.config_context(...)`. - PRODML 2.1 now supports writing one raw time-by-distance patch with `dc.write` as a standalone HDF5 file. Full PRODML 2.3 conformance requires EPC/XML packaging, which DASCore does not write. From ee9f240675417a11fe5d9962ef0ad1b32bb38de5 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 13:54:07 +0200 Subject: [PATCH 16/17] Restore the reflected-operator case in test_boolean_comparisons The test is documented as covering number-first and patch-first; making both lines patch-first deleted half of it. int.__lt__ is declared to return bool, so the reflected result is cast instead. --- tests/test_core/test_patch.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_core/test_patch.py b/tests/test_core/test_patch.py index 08362caa3..f3bf39edf 100644 --- a/tests/test_core/test_patch.py +++ b/tests/test_core/test_patch.py @@ -6,7 +6,7 @@ import operator import re import weakref -from typing import Any +from typing import Any, cast import numpy as np import pandas as pd @@ -1135,7 +1135,9 @@ def test_boolean_comparisons(self, random_patch): gt = pa > 0 assert isinstance(gt, dc.Patch) assert gt.data.dtype == np.bool_ - rgt = pa > 0 + # Deliberately number-first: this exercises the reflected operator, + # which int.__lt__ is declared to answer with a bool. + rgt = cast("dc.Patch", 0 < pa) assert rgt.equals(gt) # equality across self should be all True for <= and >= assert np.all((pa <= pa).data) From 0ca13c3f42f4362434ebdf4b5f592b2a2efffd2b Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 8 Aug 2026 14:00:01 +0200 Subject: [PATCH 17/17] Address the counterpart review filter_df's docstring named the wrong split: an equality query returns a bare array too, so the contract is now stated as an opaque boolean container. The filesystem tests assert nothing was dropped rather than silently discarding a None. resample divided by get_filter_units' result without checking it, which the widened return exposed; a null period now raises ParameterError instead of surfacing as a NaN conversion. --- dascore/proc/resample.py | 8 +++++++- dascore/utils/pd.py | 7 ++++--- docs/changelog.qmd | 2 +- tests/test_proc/test_resample.py | 8 +++++++- tests/test_utils/test_misc.py | 12 ++++++++++-- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/dascore/proc/resample.py b/dascore/proc/resample.py index 1567257c1..6e6562253 100644 --- a/dascore/proc/resample.py +++ b/dascore/proc/resample.py @@ -9,7 +9,7 @@ import dascore as dc import dascore.compat as compat from dascore.constants import PatchType -from dascore.exceptions import FilterValueError +from dascore.exceptions import FilterValueError, ParameterError from dascore.units import get_filter_units from dascore.utils.imports import lazy_import from dascore.utils.patch import ( @@ -229,6 +229,12 @@ def resample( if coord_units is not None: coord_units = 1 / coord_units new_step, _ = get_filter_units(value, value, to_unit=coord_units) + if new_step is None: + msg = ( + f"resample requires a sampling period for dimension {dim!r}; " + f"got {value!r}. Pass samples=True to resample by length." + ) + raise ParameterError(msg) # nasty hack so that ints/floats get converted to seconds. if isinstance(step, np.timedelta64): new_step = to_timedelta64(new_step) diff --git a/dascore/utils/pd.py b/dascore/utils/pd.py index 1ffe49024..d7e07b889 100644 --- a/dascore/utils/pd.py +++ b/dascore/utils/pd.py @@ -489,9 +489,10 @@ def filter_df( Returns ------- - A boolean array of the same len as df indicating if each row meets the - requirements. It is a Series once any filter has been applied and a - bare array when no query narrowed it. + A boolean mask of the same len as df indicating if each row meets the + requirements. Whether it comes back as a bare array or a Series + depends on which queries applied, so treat it as an opaque boolean + container rather than relying on either. """ min_max_query = _convert_times(df, _get_min_max_query(kwargs, df)) kwargs, range_query, _ = split_df_query(kwargs, df, ignore_bad_kwargs) diff --git a/docs/changelog.qmd b/docs/changelog.qmd index 7a053ce0e..2568d3cd3 100644 --- a/docs/changelog.qmd +++ b/docs/changelog.qmd @@ -6,7 +6,7 @@ The [releases page](https://github.com/DASDAE/dascore/releases) tracks changes f - **`to_float` handles pint quantities explicitly.** A time quantity now converts to its duration in seconds (`to_float(2 * dc.units.min) == 120.0`), which previously raised `DimensionalityError`. Every other quantity raises `UnitError` instead of being silently converted: pint's `float()` reduces a *dimensionless* quantity to base units, so a data size came back eight times too large (`25 MB` → `2e8`, the count in bits). Use `convert_units` or `get_byte_count` for an explicit conversion. Relatedly, filtering a coordinate that has no units with *any* quantity — `patch.notch_filter(distance=5 * dc.units.m)`, and dimensionless ones such as `20 %` — now raises `UnitError` with an explanatory message. Previously a dimensionless quantity was silently read as a bare number (`20 %` became 0.2 Hz) and a dimensional one leaked pint's `DimensionalityError`. - `dascore.utils.time.to_int` and `to_float` are now overloaded wrappers over private `singledispatch` implementations, so a `Series` input is typed as returning a `Series` and an array as returning an array. Their runtime behaviour is unchanged, but `to_int.register(...)` and `to_float.register(...)` no longer exist; register new implementations on `_to_int` / `_to_float` instead. `convert_units` no longer declares a constrained `numeric` type variable — it accepted (and still accepts) `None`, quantities, and numpy scalars, none of which that variable admitted. `WARNING_ACTIONS` no longer lists `"all"`, which Python only began accepting in 3.14 and which raises on the 3.11–3.13 interpreters DASCore also supports; use `"always"`, which it aliases. -- Several public signatures now describe what they already accepted and returned. `get_filter_units` takes `None` or `...` for an open bound and returns `None` there, neither of which its annotation allowed. `filter_df` returns a `Series` once any filter applies and a bare array otherwise, having claimed only the array. `get_coord` accepts a plain sequence for `data`, and an `int` naming a length. `dc.read`'s `time` and `distance` accept the documented `(value, ...)` open-range form, via new `time_select_type` and `float_select_type` aliases in `dascore.constants` that replace equivalents previously private to the febus reader. `dc.write` is generic over its path type, so a `Path` in yields a `Path` out. +- Several public signatures now describe what they already accepted and returned. `get_filter_units` takes `None` or `...` for an open bound and returns `None` there, neither of which its annotation allowed. `filter_df` returns a `Series` once any filter applies and a bare array otherwise, having claimed only the array. `get_coord` accepts a plain sequence for `data`, and an `int` naming a length. `dc.read`'s `time` and `distance` accept the documented `(value, ...)` open-range form, via new `time_select_type` and `float_select_type` aliases in `dascore.constants` that replace equivalents previously private to the febus reader. `dc.write` is generic over its path type, so a `Path` in yields a `Path` out. Relatedly, `patch.resample(dim=None)` now raises `ParameterError` naming the dimension instead of failing later with `ValueError: cannot convert float NaN to integer`. - `BaseCoord.size` is now a builtin `int` rather than an `np.int64`, and `1` rather than `1.0` for a shapeless coord, since it is computed with `math.prod` instead of `np.prod`. `Patch.sobel_filter`, `Patch.notch_filter` and `velocity_to_strain_rate_edgeless` build their result with `patch.new(...)` instead of a bare `dc.Patch(...)`, so a `Patch` subclass now survives them as their signatures already promised. Several other return annotations were corrected to describe long-standing behaviour rather than change it: `CoordManager.drop_disassociated_coords` and `CoordManager.drop_private_coords` return a `(coord_manager, array)` tuple, `BaseCoord.get_next_index` returns an array for a sized value and a numpy integer otherwise, and `BaseCoord.unit_str` is `None` for a coord carrying no units. - `Patch.drop_coords` and `CoordManager.drop_coords` accept a sequence of names as well as bare names, so `patch.drop_coords(["latitude", "longitude"])` works alongside `patch.drop_coords("latitude", "longitude")`. Previously a list or set raised `TypeError` and a tuple or generator was silently ignored; a tuple now drops the named coordinates, and one naming a dimension raises `ParameterError` as a bare dimension name always has. - **Runtime configuration is now two-tier.** `dc.set_config(...)` sets the process-wide base permanently (visible from every thread and task) and returns the new config; it is no longer a context manager. Temporary, thread/task-local overrides use the new `dc.config_context(...)` context manager, which restores on exit and isolates concurrent overrides via a `ContextVar`. `Spool.map(...)` captures the config active at the call and re-applies it in each worker, so thread- and process-pool workers observe the caller's config regardless of the pool's start method. Unknown config field names now raise instead of being silently ignored. Migrate `with dc.set_config(...)` to `with dc.config_context(...)`. diff --git a/tests/test_proc/test_resample.py b/tests/test_proc/test_resample.py index 9225618a3..8e796ee14 100644 --- a/tests/test_proc/test_resample.py +++ b/tests/test_proc/test_resample.py @@ -10,7 +10,7 @@ import dascore as dc from dascore.compat import random_state -from dascore.exceptions import FilterValueError +from dascore.exceptions import FilterValueError, ParameterError from dascore.units import Hz, m, s from dascore.utils.patch import get_start_stop_step @@ -159,6 +159,12 @@ def decimate_spy(patch, factor, ftype, axis): class TestResample: """Tests for resampling along a given dimension.""" + def test_missing_period_raises(self, random_patch): + """A null sampling period is rejected rather than producing NaN.""" + match = "requires a sampling period" + with pytest.raises(ParameterError, match=match): + random_patch.resample(time=None) + def test_downsample_time(self, random_patch): """Test decreasing the temporal sampling rate.""" _, _, step = get_start_stop_step(random_patch, "time") diff --git a/tests/test_utils/test_misc.py b/tests/test_utils/test_misc.py index 034338eea..343a472d4 100644 --- a/tests/test_utils/test_misc.py +++ b/tests/test_utils/test_misc.py @@ -201,13 +201,21 @@ def test_pass_file_respects_timestamp_filter(self, dummy_text_file): def test_no_directories(self, simple_dir): """Ensure no directories are included when include_directories=False.""" - out = [x for x in _iter_filesystem(simple_dir, include_directories=False) if x] + raw = list(_iter_filesystem(simple_dir, include_directories=False)) + # Only an explicit skip signal makes the generator yield None, and + # this loop sends none, so nothing should be dropped here. + out = [x for x in raw if x is not None] + assert len(out) == len(raw) has_dirs = [x.is_dir() for x in out] assert not any(has_dirs) def test_include_directories(self, simple_dir): """Ensure we can get directories back.""" - out = [x for x in _iter_filesystem(simple_dir, include_directories=True) if x] + raw = list(_iter_filesystem(simple_dir, include_directories=True)) + # Only an explicit skip signal makes the generator yield None, and + # this loop sends none, so nothing should be dropped here. + out = [x for x in raw if x is not None] + assert len(out) == len(raw) returned_dirs = [x for x in out if x.is_dir()] assert len(returned_dirs) # The top level directory should have been included