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/core/coords.py b/dascore/core/coords.py index d7f8563ce..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 @@ -2841,7 +2841,10 @@ 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. + # 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/io/core.py b/dascore/io/core.py index 1c8bfa4ec..ed8e1820e 100644 --- a/dascore/io/core.py +++ b/dascore/io/core.py @@ -19,6 +19,7 @@ NotRequired, Protocol, TypedDict, + TypeVar, cast, get_type_hints, ) @@ -30,8 +31,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 +1063,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: """ @@ -1682,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/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/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/units.py b/dascore/units.py index ac4a56656..eaafa0b43 100644 --- a/dascore/units.py +++ b/dascore/units.py @@ -138,6 +138,11 @@ def get_quantity( """ Convert a value to a pint quantity. + 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 ---------- value @@ -289,6 +294,8 @@ def get_quantity_str(quant_value: unit_like) -> str | None: """ Ensure a unit/quantity is valid and return its string representation. + Returns None for a null input, including the empty string. + If it is not valid raise a [UnitError](`dascore.exceptions.UnitError`). Parameters @@ -353,11 +360,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. @@ -427,7 +434,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/dascore/utils/pd.py b/dascore/utils/pd.py index 738d45275..d7e07b889 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. @@ -487,8 +489,10 @@ 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. + 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 bcd3385e9..2568d3cd3 100644 --- a/docs/changelog.qmd +++ b/docs/changelog.qmd @@ -6,7 +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. -- `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. 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(...)`. - 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. diff --git a/pyproject.toml b/pyproject.toml index 1c6cc7bdc..fdf85b194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -264,11 +264,11 @@ markers = [ line-ending = "lf" [tool.ty.src] -include = ["dascore"] # tests/ still has many diagnostics; expand scope later. +include = ["dascore"] # tests/ is burned down separately before it lands here. -# 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. 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) 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_core/test_patch.py b/tests/test_core/test_patch.py index dcc5befbc..f3bf39edf 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, cast 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,9 @@ def test_boolean_comparisons(self, random_patch): gt = pa > 0 assert isinstance(gt, dc.Patch) assert gt.data.dtype == np.bool_ - rgt = 0 < pa + # 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) diff --git a/tests/test_io/test_dasdae/test_dasdae.py b/tests/test_io/test_dasdae/test_dasdae.py index 5ac6fba2f..14fecb3e9 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"] = ") 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 +119,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 +175,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 @@ -193,14 +204,14 @@ def test_read_and_scan_fallback_for_reference_dereference( # This intentionally patches h5py internals to simulate dereference # failures that are otherwise hard to trigger from the public API. # TODO: revisit if h5py internals change or a higher-level hook appears. - original_getitem = h5py._hl.group.Group.__getitem__ + original_getitem = h5py_group.Group.__getitem__ def _patched_getitem(group, key): if isinstance(key, Reference): raise KeyError("simulated token dereference failure") return original_getitem(group, key) - monkeypatch.setattr(h5py._hl.group.Group, "__getitem__", _patched_getitem) + monkeypatch.setattr(h5py_group.Group, "__getitem__", _patched_getitem) patch = dc.read(dasvader_modern_path)[0] scanned = dc.scan(dasvader_modern_path) 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..cd2701493 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 @@ -277,7 +278,9 @@ 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} + 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") assert (lo, hi) == pytest.approx((5.0, 10.0)) @@ -329,15 +332,21 @@ 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) + bare = _canonical_range((20, 60)) + assert bare 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) + 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 _canonical_range((None, 60)).magnitudes == (None, 60.0) + half_open = _canonical_range((None, 60)) + assert half_open is not None + assert half_open.magnitudes == (None, 60.0) @pytest.mark.parametrize( "value", @@ -406,7 +415,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 +425,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 +483,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 +492,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() @@ -864,6 +873,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 @@ -880,9 +890,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] @@ -1077,6 +1090,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 +1109,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])) @@ -1142,16 +1157,20 @@ 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) @@ -1171,8 +1190,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 @@ -1327,7 +1348,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 +1384,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 +1399,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() diff --git a/tests/test_io/test_index/test_planned.py b/tests/test_io/test_index/test_planned.py index a41e9f728..c2287f223 100644 --- a/tests/test_io/test_index/test_planned.py +++ b/tests/test_io/test_index/test_planned.py @@ -35,7 +35,9 @@ 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 + td_ns = _ns(td) + assert td_ns 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 +46,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 +54,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 +62,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 +74,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_io/test_io_core.py b/tests/test_io/test_io_core.py index b39a301e5..4a81afd87 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 @@ -17,7 +17,6 @@ import dascore as dc from dascore.config import config_context -from dascore.constants import SpoolType from dascore.exceptions import ( InvalidFiberIOError, MissingOptionalDependencyError, @@ -66,13 +65,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 +81,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 +131,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 +159,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 +169,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 +191,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 +1320,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 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_io/test_prodml/test_prod_ml.py b/tests/test_io/test_prodml/test_prod_ml.py index 6cf6d84af..3d8d01d03 100644 --- a/tests/test_io/test_prodml/test_prod_ml.py +++ b/tests/test_io/test_prodml/test_prod_ml.py @@ -45,7 +45,7 @@ class TestProdMLFile: def issue_221_patch_path(self, tmp_path_factory): """Ensure dims are correctly ascertained.""" tmp_path = tmp_path_factory.mktemp("issue_221") - path = dc.utils.downloader.fetch("prodml_2.0.h5") + path = fetch("prodml_2.0.h5") new_path = shutil.copy2(path, tmp_path / "prod_2_monkey_patched.h5") with h5py.File(new_path, "a") as fi: # monkey patch dimensions to simulate issue. @@ -56,7 +56,7 @@ def issue_221_patch_path(self, tmp_path_factory): def issue_514_patch_path(self, tmp_path_factory): """Make a patch with bad endtime metadata. See #412.""" tmp_path = tmp_path_factory.mktemp("issue_514") - path = dc.utils.downloader.fetch("prodml_2.0.h5") + path = fetch("prodml_2.0.h5") new_path = shutil.copy2(path, tmp_path / "prod_2_issue_514.h5") with h5py.File(new_path, "a") as fi: # monkey patch dimensions to simulate issue. diff --git a/tests/test_io/test_remote_common_io.py b/tests/test_io/test_remote_common_io.py index a1ded3c4c..04ba09a07 100644 --- a/tests/test_io/test_remote_common_io.py +++ b/tests/test_io/test_remote_common_io.py @@ -7,6 +7,7 @@ import pytest import dascore as dc +from dascore.utils.downloader import fetch from dascore.utils.misc import suppress_warnings from tests.test_io._common_io_test_utils import ( get_flat_io_test, @@ -72,7 +73,7 @@ def isolated_remote_cache(tmp_path_factory, permanent_config): def _get_remote_case(fetch_name: str, to_http_range_path): """Return a range-capable HTTP path for one fetched local test file.""" with skip_timeout(): - local_path = dc.utils.downloader.fetch(fetch_name) + local_path = fetch(fetch_name) return to_http_range_path(local_path) diff --git a/tests/test_proc/test_filter.py b/tests/test_proc/test_filter.py index a0839b657..546ac9d42 100644 --- a/tests/test_proc/test_filter.py +++ b/tests/test_proc/test_filter.py @@ -18,6 +18,7 @@ ) from dascore.units import Hz, convert_units, get_unit, m from dascore.utils.misc import broadcast_for_index +from dascore.utils.patch import get_dim_sampling_rate class TestPassFilterChecks: @@ -283,7 +284,7 @@ def test_notch_filter_time_distance(self, random_patch): def test_notch_filter_high_frequency_error(self, random_patch): """Test notch filter raises error for frequency beyond Nyquist.""" - sr = dc.utils.patch.get_dim_sampling_rate(random_patch, "time") + sr = get_dim_sampling_rate(random_patch, "time") nyquist = 0.5 * sr too_high_freq = nyquist + 1 msg = f"possible filter values are in [0, {nyquist}] you passed {too_high_freq}" 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_units.py b/tests/test_units.py index ac4a5b58e..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))) @@ -373,7 +374,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) @@ -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 diff --git a/tests/test_utils/test_io_utils.py b/tests/test_utils/test_io_utils.py index 8c1fcf3f5..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 ( @@ -1137,12 +1138,12 @@ def test_convert_to_xarray(self, data_array_from_patch): def test_convert_from_xarray(self, data_array_from_patch): """Ensure xarray data arrays can be converted back.""" - out = dc.utils.io.xarray_to_patch(data_array_from_patch) + out = xarray_to_patch(data_array_from_patch) assert isinstance(out, dc.Patch) def test_round_trip(self, random_patch, data_array_from_patch): """Converting to xarray should be lossless.""" - out = dc.utils.io.xarray_to_patch(data_array_from_patch) + out = xarray_to_patch(data_array_from_patch) assert out == random_patch def test_convert_non_coord(self, random_patch): @@ -1152,7 +1153,7 @@ def test_convert_non_coord(self, random_patch): dar = patch.io.to_xarray() assert isinstance(dar, xr.DataArray) # Ensure it round-trips - patch2 = dc.utils.io.xarray_to_patch(dar) + patch2 = xarray_to_patch(dar) assert isinstance(patch2, dc.Patch) diff --git a/tests/test_utils/test_misc.py b/tests/test_utils/test_misc.py index a168bd34a..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 = list(_iter_filesystem(simple_dir, include_directories=False)) + 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 = list(_iter_filesystem(simple_dir, include_directories=True)) + 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 @@ -222,6 +230,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) 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) diff --git a/tests/test_utils/test_patch_utils.py b/tests/test_utils/test_patch_utils.py index 394192caa..45dcfe196 100644 --- a/tests/test_utils/test_patch_utils.py +++ b/tests/test_utils/test_patch_utils.py @@ -22,6 +22,7 @@ PatchCoordinateError, ) from dascore.units import percent +from dascore.utils.misc import suppress_warnings from dascore.utils.patch import ( _spool_up, align_patch_coords, @@ -1024,7 +1025,7 @@ def test_warn_above_warning(self, simple_patch): def test_no_warning_under_threshold(self, simple_patch): """Test no warning for window sizes under threshold.""" - with dc.utils.misc.suppress_warnings(action="error"): + with suppress_warnings(action="error"): # This should not raise (no warning) size = get_patch_window_size( simple_patch, {"time": 5}, samples=True, warn_above=10 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):