From 120d661bd41227ab2a9f89920f356273bae24a16 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Tue, 4 Aug 2026 19:44:29 +0200 Subject: [PATCH 1/9] Say BaseCoord where a coord does not stay itself The coord methods which canonicalize declared they return Self, but a coord routinely comes back as a different class: empty() always gives a CoordPartial, index() and snap() and sort() give a CoordRange from an array coord, and select() does too once the selection turns out to be evenly sampled. Those six say BaseCoord now. The type variable stays where the class really is preserved, such as convert_units. The base also disagreed with every one of its implementations about two parameter names -- arg against args, unit against units -- so the base moved, which is the side nothing calls. CoordPartial aliased update_limits and set_units to update, whose only parameter is **kwargs. That made set_units unusable: patch.set_units on any dimension whose coord holds no values raised TypeError rather than recording the units. They are spelled out now, each keeping the signature its base declares. --- dascore/core/coords.py | 59 ++++++++++++++++------------ pyproject.toml | 2 +- tests/test_core/test_coordmanager.py | 14 +++++++ tests/test_core/test_coords.py | 12 ++++++ 4 files changed, 60 insertions(+), 27 deletions(-) diff --git a/dascore/core/coords.py b/dascore/core/coords.py index e45ea7c65..c9736aa87 100644 --- a/dascore/core/coords.py +++ b/dascore/core/coords.py @@ -347,7 +347,7 @@ def _validate_nullish_to_nan(cls, value): return tuple(iterate(value)) @abc.abstractmethod - def convert_units(self, unit) -> Self: + def convert_units(self, units) -> Self: """Convert from one unit to another. Set units if None are set.""" def _get_value_index(self, coord_array, values_to_find): @@ -433,8 +433,8 @@ def _select_by_samples(self, arg): @abc.abstractmethod def select( - self, arg, relative=False, samples=False - ) -> tuple[Self, slice | ArrayLike]: + self, args, relative=False, samples=False + ) -> tuple[BaseCoord, slice | ArrayLike]: """ Returns an entity that can be used in a list for numpy indexing and selected coord. @@ -442,7 +442,7 @@ def select( def order( self, array, relative=False, samples=False - ) -> tuple[Self, slice | ArrayLike]: + ) -> tuple[BaseCoord, slice | ArrayLike]: """ Order coordinate according to array values or samples. @@ -700,7 +700,7 @@ def coord_range(self, extend: bool = True): def sort(self, reverse=False) -> tuple[BaseCoord, slice | ArrayLike]: """Sort the contents of the coord. Return new coord and slice for sorting.""" - def snap(self) -> CoordRange: + def snap(self) -> BaseCoord: """ Snap the coordinates to evenly sampled grid points. @@ -766,7 +766,7 @@ def get_discontinuities(self, kind="all", tolerance=None) -> pd.DataFrame: return pd.DataFrame(columns=columns) @abc.abstractmethod - def update_limits(self, min=None, max=None, step=None, **kwargs) -> Self: + def update_limits(self, min=None, max=None, step=None, **kwargs) -> BaseCoord: """ Update the limits or sampling of the coordinates. @@ -796,7 +796,7 @@ def update_data( data: ArrayLike | np.ndarray | None = None, values: ArrayLike | np.ndarray | None = None, **kwargs, - ) -> Self: + ) -> BaseCoord: """ Update the data of the coordinate. @@ -903,7 +903,7 @@ def _get_relative_values(self, value): out = self.min() + value if pos else self.max() + value return out - def empty(self, axes=None) -> Self: + def empty(self, axes=None) -> BaseCoord: """ Empty out the coordinate. @@ -922,7 +922,7 @@ def empty(self, axes=None) -> Self: data = np.empty(tuple(new_shape), dtype=self.dtype) return get_coord(data=data) - def index(self, indexer, axis: int | None = None) -> Self: + def index(self, indexer, axis: int | None = None) -> BaseCoord: """ Index the coordinate and return new coordinate. @@ -1145,7 +1145,7 @@ def approx_equal(self: BaseCoord, other: BaseCoord) -> bool: return True return all_close(self.values, other.values) - def change_length(self, length: int) -> Self: + def change_length(self, length: int) -> BaseCoord: """ Adjust the length of the coordinate by changing the end value. @@ -1240,9 +1240,16 @@ def update(self, **kwargs): """No values to change so update can just call new.""" return self.new(**kwargs) - # Other operations that normally modify data do not in this case. - update_limits = update - set_units = update + # Other operations that normally modify data do not in this case; + # they are spelled out rather than aliased so each keeps the + # signature its base declares. + def update_limits(self, min=None, max=None, step=None, **kwargs) -> BaseCoord: + """No values to change, so only the metadata in kwargs is applied.""" + return self.update(min=min, max=max, step=step, **kwargs) + + def set_units(self, units) -> Self: + """No values to change, so this only records the new units.""" + return self.update(units=units) def convert_units(self, units) -> Self: """Convert scalar metadata units, or set units if none exist.""" @@ -1307,7 +1314,7 @@ def select( @compose_docstring(doc=get_docstring(BaseCoord.order)) def order( self, array, relative=False, samples=False - ) -> tuple[Self, slice | ArrayLike]: + ) -> tuple[BaseCoord, slice | ArrayLike]: """ {doc}. """ @@ -1315,7 +1322,7 @@ def order( return super().order(array, relative=relative, samples=samples) @compose_docstring(doc=get_docstring(BaseCoord.change_length)) - def change_length(self, length: int) -> Self: + def change_length(self, length: int) -> BaseCoord: """ {doc} """ @@ -1616,7 +1623,7 @@ def _get_index(self, value, forward=True): return fraction.astype(np.int64) @compose_docstring(doc=get_docstring(BaseCoord.update_limits)) - def update_limits(self, min=None, max=None, step=None, **kwargs) -> Self: + def update_limits(self, min=None, max=None, step=None, **kwargs) -> BaseCoord: """{doc}.""" if all(x is not None for x in [min, max, step]): msg = "At most two parameters can be specified in update_limits." @@ -1676,7 +1683,7 @@ def _max(self): return np.max([self.stop - self.step, self.start]) @compose_docstring(doc=get_docstring(BaseCoord.change_length)) - def change_length(self, length: int) -> Self: + def change_length(self, length: int) -> BaseCoord: """ {doc} """ @@ -1730,7 +1737,7 @@ def convert_units(self, units) -> Self: def select( self, args, relative=False, samples=False - ) -> tuple[Self, slice | ArrayLike]: + ) -> tuple[BaseCoord, slice | ArrayLike]: """Apply select, return selected coords and index for selecting data.""" if is_array(args): return self._select_by_array(args, relative=relative, samples=samples) @@ -1800,7 +1807,7 @@ def snap(self): return out.change_length(len(self)) @compose_docstring(doc=get_docstring(BaseCoord.update_limits)) - def update_limits(self, min=None, max=None, step=None, **kwargs) -> Self: + def update_limits(self, min=None, max=None, step=None, **kwargs) -> BaseCoord: """{doc}.""" if sum(x is not None for x in [min, max, step]) > 1: msg = "At most one parameter can be specified in update_limits." @@ -1863,7 +1870,7 @@ class CoordMonotonicArray(CoordArray): def select( self, args, relative=False, samples=False - ) -> tuple[Self, slice | ArrayLike]: + ) -> tuple[BaseCoord, slice | ArrayLike]: """Apply select, return selected coords and index for selecting data.""" if is_array(args): return self._select_by_array(args, relative=relative, samples=samples) @@ -2332,7 +2339,7 @@ def sort(self, reverse=False) -> tuple[BaseCoord, slice | ArrayLike]: return self.new(segments=segments), slice(None, None, -1) @compose_docstring(doc=get_docstring(BaseCoord.update_limits)) - def update_limits(self, min=None, max=None, step=None, **kwargs) -> Self: + def update_limits(self, min=None, max=None, step=None, **kwargs) -> BaseCoord: """{doc}.""" if step is not None: msg = ( @@ -2363,7 +2370,7 @@ def _shift(self, delta) -> Self: segments.append(new) return self.new(segments=tuple(segments)) - def snap(self) -> CoordRange: + def snap(self) -> BaseCoord: """ Snap the coordinates to evenly sampled grid points. @@ -2708,9 +2715,9 @@ def _validate_values(cls, values): values["step"] = None return values - def convert_units(self, unit) -> Self: + def convert_units(self, units) -> Self: """String coordinates cannot be converted between units.""" - if unit not in (None, ""): + if units not in (None, ""): _raise_string_coord_error("unit conversion") return self @@ -2730,7 +2737,7 @@ def _get_compatible_value(self, value, relative=False): def select( self, args, relative=False, samples=False - ) -> tuple[Self, slice | ArrayLike]: + ) -> tuple[BaseCoord, slice | ArrayLike]: """Select by exact values, wildcard patterns, regexes, samples, or masks.""" if relative: _raise_string_coord_error("relative selection") @@ -2779,7 +2786,7 @@ def reverse_sorted(self) -> bool: return False return bool(np.all(values[:-1] >= values[1:])) - def update_limits(self, min=None, max=None, step=None, **kwargs) -> Self: + def update_limits(self, min=None, max=None, step=None, **kwargs) -> BaseCoord: """Reject numeric limit updates on string coords.""" # Deliberately match BaseCoord/CoordRange parameter names for API parity. unsupported_kwargs = set(kwargs) - {"data"} diff --git a/pyproject.toml b/pyproject.toml index 3b6f68be4..7ba108d2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -268,7 +268,7 @@ include = ["dascore"] # tests/ still has many diagnostics; expand scope later. # Rules with large pre-existing error counts, ignored until incrementally # burned down. Counts as of 2026-08-04: invalid-argument-type 86, -# invalid-return-type 36, invalid-method-override 15. +# invalid-return-type 31, invalid-method-override 4. [tool.ty.rules] invalid-argument-type = "ignore" invalid-return-type = "ignore" diff --git a/tests/test_core/test_coordmanager.py b/tests/test_core/test_coordmanager.py index 69cfde0b4..80f3dfd86 100644 --- a/tests/test_core/test_coordmanager.py +++ b/tests/test_core/test_coordmanager.py @@ -1321,6 +1321,20 @@ def test_snap_expected_dt( assert snapped.coord_map["time"].step == expected_dt +class TestSetUnits: + """Tests for setting coordinate units.""" + + def test_set_units_on_valueless_coord(self): + """A dim coord with no values takes units like any other.""" + cm = get_coord_manager( + {"time": get_coord(shape=(10,)), "distance": np.arange(4) * 1.0}, + dims=("time", "distance"), + ) + assert isinstance(cm.coord_map["time"], CoordPartial) + out = cm.set_units(time="s") + assert out.coord_map["time"].units == get_quantity("s") + + class TestConvertUnits: """Tests for converting coordinate units.""" diff --git a/tests/test_core/test_coords.py b/tests/test_core/test_coords.py index f1a0685bf..287fecae1 100644 --- a/tests/test_core/test_coords.py +++ b/tests/test_core/test_coords.py @@ -1687,6 +1687,18 @@ def test_non_coord_eq_self(self, basic_non_coord): """Ensure non coords are equal to themselves.""" assert basic_non_coord == basic_non_coord + def test_set_units_positionally(self, basic_non_coord): + """Units are set the same way as on any other coord.""" + out = basic_non_coord.set_units("m") + assert out.units == dc.get_quantity("m") + assert isinstance(out, CoordPartial) + + def test_update_limits_only_touches_metadata(self, basic_non_coord): + """There are no values to limit, so only the metadata changes.""" + out = basic_non_coord.update_limits(units="m") + assert out.units == dc.get_quantity("m") + assert isinstance(out, CoordPartial) + def test_dimensionless_shape_survives_dump(self): """A partial coord keeps its shape when defaults are excluded.""" coord = get_coord(shape=()) From 6b2008e515edff7faac190b097cd02f5323accf2 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Wed, 5 Aug 2026 06:55:56 +0200 Subject: [PATCH 2/9] Build the sintela attrs and widen what the coord manager takes The protobuf attrs were collected in a plain dict and splatted into the model, so every field was offered the dict's value union; building the model directly and applying the family's extras with new() leaves each field its own type. Two helpers there also under-declared: a packet with no header time contributes None, and the record parser only iterates. Patch's coords parameter was narrower than the CoordManagerInput it forwards to, and get_coord_manager took only a tuple of dims while its callers have a Sequence -- which also meant a list of dims never compared equal to a CoordManager's tuple. --- dascore/core/coordmanager.py | 4 +++- dascore/core/patch.py | 8 ++++++-- dascore/io/sintela/protobuf_utils.py | 18 ++++++++++-------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/dascore/core/coordmanager.py b/dascore/core/coordmanager.py index f206ca56d..905c5ae66 100644 --- a/dascore/core/coordmanager.py +++ b/dascore/core/coordmanager.py @@ -1133,7 +1133,7 @@ def _flip_coord(coord, axis): def get_coord_manager( coords: CoordManagerInput | CoordManager | None = None, - dims: tuple[str, ...] | None = None, + dims: Sequence[str] | None = None, shape=None, ) -> CoordManager: """ @@ -1175,6 +1175,8 @@ def get_coord_manager( >>> cm = get_coord_manager(coords=coords, dims=dims) """ # return coords if we already have a coord manager. + # A list of dims would never compare equal to a CoordManager's tuple. + dims = None if dims is None else tuple(dims) if isinstance(coords, CoordManager): # maybe try to rename dims. if dims is not None and dims != coords.dims: diff --git a/dascore/core/patch.py b/dascore/core/patch.py index ccf9e9d9a..ab30ddd84 100644 --- a/dascore/core/patch.py +++ b/dascore/core/patch.py @@ -16,7 +16,11 @@ from dascore import transform from dascore.compat import DataArray, array from dascore.core.attrs import PatchAttrs -from dascore.core.coordmanager import CoordManager, get_coord_manager +from dascore.core.coordmanager import ( + CoordManager, + CoordManagerInput, + get_coord_manager, +) from dascore.core.coords import BaseCoord from dascore.core.summary import PatchSummary from dascore.utils.array import ( @@ -77,7 +81,7 @@ class Patch(NamespaceOwner): def __init__( self, data: ArrayLike | DataArray | None = None, - coords: Mapping[str, ArrayLike | BaseCoord] | CoordManager | None = None, + coords: CoordManagerInput | CoordManager | None = None, dims: Sequence[str] | None = None, attrs: Mapping | PatchAttrs | None = None, ): diff --git a/dascore/io/sintela/protobuf_utils.py b/dascore/io/sintela/protobuf_utils.py index 505155b2d..a7cbb2876 100644 --- a/dascore/io/sintela/protobuf_utils.py +++ b/dascore/io/sintela/protobuf_utils.py @@ -43,7 +43,7 @@ from __future__ import annotations import struct -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from functools import cache from typing import Any @@ -464,7 +464,7 @@ def _common_header_time(common_header) -> np.datetime64 | None: def _parse_records( - records: list[EnvelopeRecord], *, scan_mode: bool = False + records: Iterable[EnvelopeRecord], *, scan_mode: bool = False ) -> tuple[list[Any], ParsedMeta]: """Decode protobuf payloads and return messages plus selected META.""" messages = _get_proto_messages(include_sample_fields=not scan_mode) @@ -530,8 +530,12 @@ def _get_distance_coord(start_channel: int, spacing: float, count: int, step: in ) -def _get_times(times: list[np.datetime64]): - """Build a time coordinate from packet timestamps.""" +def _get_times(times: list[np.datetime64 | None]): + """ + Build a time coordinate from packet timestamps. + + A packet whose common header carries no time contributes NaT. + """ return get_coord(data=np.asarray(times, dtype="datetime64[ns]")) @@ -557,7 +561,7 @@ def _base_attrs( Each packet family supplies its own ``data_type``/``data_units`` via ``extra``; the fields below are shared across all families. """ - attrs = dict( + attrs = SintelaProtobufAttrs( data_category="DAS", packet_type=packet_type, recorder_namespace=meta.recorder_namespace, @@ -572,9 +576,7 @@ def _base_attrs( start_channel=int(getattr(common_header, "start_channel", 0)), channel_step=None, ) - if extra: - attrs.update(extra) - return SintelaProtobufAttrs(**attrs) + return attrs.new(**extra) if extra else attrs def _get_band_attr_data_type(band_def: tuple[tuple[Any, ...], ...]) -> tuple[str, str]: From 2e827b99996b2c05d0a7310f0f1686f53ddcccff Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Wed, 5 Aug 2026 06:57:40 +0200 Subject: [PATCH 3/9] Name what resolves to a quantity, and let unbyte pass values through get_quantity has always taken a bare number as dimensionless and a pint Unit as itself, but three of its neighbours declared narrower subsets of the same idea, so passing a value from one to another was an error. They share one alias now. unbyte only decodes bytes and hands everything else back untouched, which its bytes | str signature could not say. --- dascore/core/patch.py | 1 - dascore/units.py | 15 +++++++++++---- dascore/utils/misc.py | 15 +++++++++++---- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/dascore/core/patch.py b/dascore/core/patch.py index ab30ddd84..2ea0be6fd 100644 --- a/dascore/core/patch.py +++ b/dascore/core/patch.py @@ -21,7 +21,6 @@ CoordManagerInput, get_coord_manager, ) -from dascore.core.coords import BaseCoord from dascore.core.summary import PatchSummary from dascore.utils.array import ( PatchUFunc, diff --git a/dascore/units.py b/dascore/units.py index 51d73d752..7adae3af1 100644 --- a/dascore/units.py +++ b/dascore/units.py @@ -113,8 +113,15 @@ def _str_to_quant(qunat_str): return ureg.Quantity(qunat_str) +# Anything get_quantity can resolve: a unit or quantity, a string naming +# one, a numpy time value, or a bare number (which is dimensionless). +quantity_like = ( + str | Quantity | Unit | np.datetime64 | np.timedelta64 | int | float | None +) + + def get_quantity( - value: str | Quantity | Unit | np.datetime64 | np.timedelta64 | None, + value: quantity_like, ) -> Quantity | None: """ Convert a value to a pint quantity. @@ -174,8 +181,8 @@ def _get_conversion_factors(from_quant, to_quant) -> tuple[float, float, float]: def convert_units( data: numeric | Quantity, - to_units: str | Quantity | None, - from_units: str | Quantity | None = None, + to_units: quantity_like, + from_units: quantity_like = None, ) -> numeric: """ Convert units in array from one type of units to another. @@ -254,7 +261,7 @@ def _unit_to_str(unit: Unit) -> str: return str(unit) -def get_quantity_str(quant_value: str | Quantity | None) -> str | None: +def get_quantity_str(quant_value: quantity_like) -> str | None: """ Ensure a unit/quantity is valid and return its string representation. diff --git a/dascore/utils/misc.py b/dascore/utils/misc.py index 06b9d54d6..646b0699b 100644 --- a/dascore/utils/misc.py +++ b/dascore/utils/misc.py @@ -16,7 +16,7 @@ from io import IOBase from pathlib import Path from types import ModuleType -from typing import Literal, overload +from typing import Literal, TypeVar, overload import numpy as np import pandas as pd @@ -34,6 +34,8 @@ from dascore.utils.paths import coerce_to_upath, is_local_path, is_pathlike from dascore.utils.progress import track +_T = TypeVar("_T") + def register_func(list_or_dict: list | dict, key=None): """ @@ -527,10 +529,15 @@ def all_diffs_close_enough(diffs): return np.allclose(diffs, med, rtol=0.001) -def unbyte(byte_or_str: bytes | str) -> str: - """Ensure a string is given by str or possibly bytes.""" +def unbyte(byte_or_str: _T | bytes) -> _T | str: + """ + Decode a bytes value, passing anything else through unchanged. + + Callers use this to normalize values which may or may not have come + from a binary file, so the non-bytes case is the common one. + """ if isinstance(byte_or_str, bytes | np.bytes_): - byte_or_str = byte_or_str.decode("utf8") + return byte_or_str.decode("utf8") return byte_or_str From 746dd8df2ce28871ff7be4166684cacdcc481544 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Wed, 5 Aug 2026 06:58:39 +0200 Subject: [PATCH 4/9] Include pint's plain unit and inline one more kwargs dict --- dascore/io/terra15/utils.py | 5 +++-- dascore/units.py | 17 ++++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/dascore/io/terra15/utils.py b/dascore/io/terra15/utils.py index de11079f4..3b67f33af 100644 --- a/dascore/io/terra15/utils.py +++ b/dascore/io/terra15/utils.py @@ -179,8 +179,9 @@ def _get_time_coord(data_node, snap_dims=True): """Get the time coordinate.""" t_min, t_max, _time_len, d_time = _get_scanned_time_info(data_node) if snap_dims: - kwargs = dict(start=t_min, stop=t_max + d_time, step=d_time, units="s") - time_coord = get_coord(**kwargs) + time_coord = get_coord( + start=t_min, stop=t_max + d_time, step=d_time, units="s" + ) else: time_coord = _get_raw_time_coord(data_node) return time_coord diff --git a/dascore/units.py b/dascore/units.py index 7adae3af1..e3fe4efa8 100644 --- a/dascore/units.py +++ b/dascore/units.py @@ -12,6 +12,7 @@ import pandas as pd import pint from pint import DimensionalityError, Quantity, UndefinedUnitError, Unit +from pint.facets.plain import PlainUnit from platformdirs import user_cache_path import dascore as dc @@ -115,8 +116,18 @@ def _str_to_quant(qunat_str): # Anything get_quantity can resolve: a unit or quantity, a string naming # one, a numpy time value, or a bare number (which is dimensionless). +# PlainUnit is the base pint builds its registry Unit from, and is what +# a quantity's .units is statically. quantity_like = ( - str | Quantity | Unit | np.datetime64 | np.timedelta64 | int | float | None + str + | Quantity + | Unit + | PlainUnit + | np.datetime64 + | np.timedelta64 + | int + | float + | None ) @@ -236,7 +247,7 @@ def assert_dtype_compatible_with_units(dtype, quantity) -> Quantity: return quant -def invert_quantity(unit: pint.Unit | str | Quantity | None) -> Quantity | None: +def invert_quantity(unit: quantity_like) -> Quantity | None: """Invert a unit.""" # just get magnitude for isnull test to avoid warning of casting # quantity to array. @@ -331,7 +342,7 @@ def get_inverted_quant(quant: Quantity | None, data_units): def get_filter_units( arg1: Quantity | float, arg2: Quantity | float, - to_unit: str | Quantity, + to_unit: quantity_like, dim: str | None = None, ) -> tuple[float, float]: """ From 9416727fb05a09b51db3878e5288070d0101708c Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Wed, 5 Aug 2026 07:01:19 +0200 Subject: [PATCH 5/9] Route local paths through the coercing helper and tidy small unions --- dascore/core/attrs.py | 8 +++++--- dascore/core/summary.py | 2 +- dascore/io/core.py | 6 +++--- dascore/io/index/indexer.py | 10 +++++++--- dascore/utils/misc.py | 9 +++++++-- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/dascore/core/attrs.py b/dascore/core/attrs.py index a95def439..84c39456c 100644 --- a/dascore/core/attrs.py +++ b/dascore/core/attrs.py @@ -138,9 +138,11 @@ def from_dict( out = model_dump() else: out = attr_map - if isinstance(out, Mapping): - out = dict(out) - out.pop("dims", None) + # Anything not already a mapping came from model_dump, which + # returns one, so this only restates the contract for the checker. + assert isinstance(out, Mapping), "attr_map must resolve to a mapping" + out = dict(out) + out.pop("dims", None) return cls(**out) def update(self, **kwargs) -> Self: diff --git a/dascore/core/summary.py b/dascore/core/summary.py index 3d55a0151..f61368242 100644 --- a/dascore/core/summary.py +++ b/dascore/core/summary.py @@ -84,7 +84,7 @@ def _flatten_coord_summary( exclude: set[str] | None = None, ) -> dict[str, Any]: """Flatten a single coord summary into scan/index-style fields.""" - exclude = set() if exclude is None else exclude + exclude = set[str]() if exclude is None else exclude summary_dict = _coord_summary_to_dict(summary) out = {} if dim_tuple and coord_name not in exclude: diff --git a/dascore/io/core.py b/dascore/io/core.py index d1f119ef4..86e0b9dab 100644 --- a/dascore/io/core.py +++ b/dascore/io/core.py @@ -234,8 +234,8 @@ def _scan_payload_to_summary( shape=tuple(payload.get("shape", ())), dtype=str(payload["dtype"]), source_path=source_path, - source_format=source_format, - source_version=source_version, + source_format=source_format or "", + source_version=source_version or "", source_patch_id=( normalize_source_patch_id(source_patch_id) or normalize_source_patch_id(payload.get("source_patch_id")) @@ -694,7 +694,7 @@ def _yield_extensions(self, extension, input_type=None): def _get_format( self, - path: str | Path | IOResourceManager, + path: path_types | IOResourceManager, file_format: str | None = None, file_version: str | None = None, fiber_io_hint: dict[str, FiberIO] | None = None, diff --git a/dascore/io/index/indexer.py b/dascore/io/index/indexer.py index 3d7be4f9e..913ca1f07 100644 --- a/dascore/io/index/indexer.py +++ b/dascore/io/index/indexer.py @@ -33,7 +33,11 @@ ) from dascore.io.index.schema import SPOOL_HIDDEN_COLUMNS from dascore.utils.misc import _iter_filesystem -from dascore.utils.paths import directory_writable, requires_local_directory +from dascore.utils.paths import ( + coerce_to_local_path, + directory_writable, + requires_local_directory, +) def _path_digest(path) -> str: @@ -132,7 +136,7 @@ def __init__( ): path = UPath(path).absolute() if isinstance(path, UPath) else Path(path) requires_local_directory(path, label="DBDirectoryIndexer") - self.path = Path(path).absolute() + self.path = coerce_to_local_path(path).absolute() self.index_path = Path(self._find_index_path(index_path)) try: self._backend = get_backend(self.index_path) @@ -284,7 +288,7 @@ def _walk(self) -> dict[str, tuple[int, int, Path]]: signal = None if candidate is None: # the reply to a "skip" send continue - path = Path(candidate) + path = coerce_to_local_path(candidate) if path.is_dir(): if self._directory_format(path): signal = "skip" diff --git a/dascore/utils/misc.py b/dascore/utils/misc.py index 646b0699b..f61d0bde8 100644 --- a/dascore/utils/misc.py +++ b/dascore/utils/misc.py @@ -31,7 +31,12 @@ MissingOptionalDependencyError, ParameterError, ) -from dascore.utils.paths import coerce_to_upath, is_local_path, is_pathlike +from dascore.utils.paths import ( + coerce_to_local_path, + coerce_to_upath, + is_local_path, + is_pathlike, +) from dascore.utils.progress import track _T = TypeVar("_T") @@ -224,7 +229,7 @@ def _iter_filesystem( if is_pathlike(paths): if is_local_path(paths): yield from _iter_local_filesystem( - Path(paths), + coerce_to_local_path(paths), ext=ext, timestamp=timestamp, skip_hidden=skip_hidden, From c022ac9d95c253507aa63783b21dfe635d8c09fb Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Wed, 5 Aug 2026 07:02:58 +0200 Subject: [PATCH 6/9] Type a handful of small argument mismatches --- dascore/core/coordmanager.py | 4 ++-- dascore/core/summary.py | 2 +- dascore/io/terra15/utils.py | 4 +--- dascore/utils/downloader.py | 2 +- dascore/viz/spectrogram.py | 3 ++- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/dascore/core/coordmanager.py b/dascore/core/coordmanager.py index 905c5ae66..741c643b0 100644 --- a/dascore/core/coordmanager.py +++ b/dascore/core/coordmanager.py @@ -42,7 +42,7 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Mapping, Sequence +from collections.abc import Collection, Mapping, Sequence from itertools import zip_longest from types import EllipsisType from typing import Annotated, Any @@ -447,7 +447,7 @@ def new(self, dims=None, coord_map=None, dim_map=None) -> Self: def drop_coords( self, - *coords: str | Sequence[str], + *coords: str | Collection[str], array: MaybeArray = None, ) -> tuple[Self, MaybeArray]: """ diff --git a/dascore/core/summary.py b/dascore/core/summary.py index f61368242..c4b6a9ee9 100644 --- a/dascore/core/summary.py +++ b/dascore/core/summary.py @@ -261,7 +261,7 @@ def dump_structured(self) -> dict[str, Any]: def flat_dump(self, dim_tuple: bool = False, exclude=None) -> dict[str, Any]: """Return a flat dict suitable for indexing/dataframes.""" - exclude = set(() if exclude is None else exclude) + exclude = set[str](() if exclude is None else exclude) # Build flattened attrs first, then overlay coord summaries so coord- # derived fields win over any attr using the same simplified key. out = self.attrs.flat_dump(exclude=exclude) diff --git a/dascore/io/terra15/utils.py b/dascore/io/terra15/utils.py index 3b67f33af..63f4ad093 100644 --- a/dascore/io/terra15/utils.py +++ b/dascore/io/terra15/utils.py @@ -179,9 +179,7 @@ def _get_time_coord(data_node, snap_dims=True): """Get the time coordinate.""" t_min, t_max, _time_len, d_time = _get_scanned_time_info(data_node) if snap_dims: - time_coord = get_coord( - start=t_min, stop=t_max + d_time, step=d_time, units="s" - ) + time_coord = get_coord(start=t_min, stop=t_max + d_time, step=d_time, units="s") else: time_coord = _get_raw_time_coord(data_node) return time_coord diff --git a/dascore/utils/downloader.py b/dascore/utils/downloader.py index 411a85780..d368c57a1 100644 --- a/dascore/utils/downloader.py +++ b/dascore/utils/downloader.py @@ -12,7 +12,7 @@ from dascore.config import get_config from dascore.constants import DATA_VERSION -REGISTRY_PATH = Path(files("dascore").joinpath("data_registry.txt")) +REGISTRY_PATH = Path(str(files("dascore").joinpath("data_registry.txt"))) LARGE_REGISTRY_FILES = frozenset({"whale_1.hdf5"}) diff --git a/dascore/viz/spectrogram.py b/dascore/viz/spectrogram.py index 38556a5a7..113fc35f5 100644 --- a/dascore/viz/spectrogram.py +++ b/dascore/viz/spectrogram.py @@ -9,6 +9,7 @@ import numpy as np from scipy.signal import spectrogram as scipy_spectrogram +import dascore as dc from dascore.constants import PatchType from dascore.core.attrs import PatchAttrs from dascore.core.coordmanager import get_coord_manager @@ -40,7 +41,7 @@ def _get_new_original_coord(old_coord, array): def _get_transformed_coord(coord, freqs): """Get the transformed coordinates.""" - units = 1 / coord.units if coord.units is not None else None + units = dc.get_quantity(1 / coord.units) if coord.units is not None else None return get_coord(data=freqs, units=units) From c33f7ff21068657f3821f0630814ddd777eaefe7 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Wed, 5 Aug 2026 07:06:05 +0200 Subject: [PATCH 7/9] Add the ruff and ty badges The repo lints with ruff and type-checks with ty in pre-commit, so say so where everything else is said. --- pyproject.toml | 4 ++-- readme.md | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7ba108d2c..c3f835b51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -267,8 +267,8 @@ line-ending = "lf" include = ["dascore"] # tests/ still has many diagnostics; expand scope later. # Rules with large pre-existing error counts, ignored until incrementally -# burned down. Counts as of 2026-08-04: invalid-argument-type 86, -# invalid-return-type 31, invalid-method-override 4. +# burned down. Counts as of 2026-08-05: invalid-argument-type 31, +# invalid-return-type 30, invalid-method-override 4. [tool.ty.rules] invalid-argument-type = "ignore" invalid-return-type = "ignore" diff --git a/readme.md b/readme.md index 2791d1bc8..4aa19d40f 100644 --- a/readme.md +++ b/readme.md @@ -4,6 +4,8 @@ A python library for distributed fiber optic sensing. [![coverage](https://codecov.io/gh/dasdae/dascore/branch/master/graph/badge.svg)](https://codecov.io/gh/dasdae/dascore) [![CodSpeed Badge](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/DASDAE/dascore) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +[![Checked with ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) [![PyPI Version](https://img.shields.io/pypi/v/dascore.svg)](https://pypi.python.org/pypi/dascore) [![supported versions](https://img.shields.io/pypi/pyversions/dascore.svg?label=python_versions)](https://pypi.python.org/pypi/dascore) [![PyPI Downloads](https://img.shields.io/pypi/dm/dascore.svg?label=pypi)](https://pypi.org/project/dascore/) From 00e62a7a1da978e6bd883b55cdcd2936fdfdb383 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Wed, 5 Aug 2026 07:45:44 +0200 Subject: [PATCH 8/9] Fix what the adversarial review of #821 found Two of the changes were regressions and three of the annotations were false. CoordPartial.update_limits forwarded min, max and step whether or not the caller passed them, and a None reaching the nullish validator overwrites the stored scalar with nan -- so stacking along a dimension whose coord holds no values quietly lost its step, and with it the coord's fingerprint. It forwards only what it was given. The test that claimed to cover this used a coord with nothing to lose; it now uses one with a step, and fails without the fix. PatchAttrs.from_dict grew an assert whose comment was wrong: the branch it replaced took arbitrary caller input, so a pandas Series of attrs stopped working, the error escaped the TypeError handler in the scan path, and python -O removed the check entirely. The guard is back. drop_coords takes bare names -- the body makes a set of its varargs, so a collection is either unhashable or silently ignored -- and Patch takes every mapping get_coord_manager does, including the {name: list} form its own tests pass. Both said otherwise. quantity_like left out bytes and Ellipsis, which get_quantity opens by handling, while promising get_quantity_str a numpy time it stringifies into a date. Self was correct for change_length and for a segmented coord's snap, so they keep it, and CoordPartial's set_units override goes away: the base rebuilds the same class and already takes its argument positionally. Three of the changes turned out to do nothing at runtime and are reverted rather than left as noise. --- dascore/core/attrs.py | 14 +++++++------- dascore/core/coordmanager.py | 4 ++-- dascore/core/coords.py | 25 ++++++++++++------------- dascore/core/patch.py | 10 +++------- dascore/io/core.py | 4 ++-- dascore/io/index/indexer.py | 4 ++-- dascore/io/sintela/protobuf_utils.py | 4 +++- dascore/units.py | 18 +++++++++++++----- dascore/utils/misc.py | 10 +++++++++- dascore/viz/spectrogram.py | 3 +-- pyproject.toml | 4 ++-- tests/test_core/test_coordmanager.py | 10 ++++++++++ tests/test_core/test_coords.py | 8 +++++--- tests/test_io/test_indexer.py | 4 +++- tests/test_utils/test_misc.py | 5 +++++ 15 files changed, 79 insertions(+), 48 deletions(-) diff --git a/dascore/core/attrs.py b/dascore/core/attrs.py index 84c39456c..ce0a63c34 100644 --- a/dascore/core/attrs.py +++ b/dascore/core/attrs.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Annotated, Any +from typing import Annotated, Any, cast from pydantic import ConfigDict, Field, PlainValidator, model_validator from typing_extensions import Self @@ -138,12 +138,12 @@ def from_dict( out = model_dump() else: out = attr_map - # Anything not already a mapping came from model_dump, which - # returns one, so this only restates the contract for the checker. - assert isinstance(out, Mapping), "attr_map must resolve to a mapping" - out = dict(out) - out.pop("dims", None) - return cls(**out) + if isinstance(out, Mapping): + out = dict(out) + out.pop("dims", None) + # Anything else may still be unpackable -- a pandas Series, say -- + # and the constructor has always been what rejects the rest. + return cls(**cast("Mapping[str, Any]", out)) def update(self, **kwargs) -> Self: """Update an attribute in the model, return new model.""" diff --git a/dascore/core/coordmanager.py b/dascore/core/coordmanager.py index 741c643b0..80ff32595 100644 --- a/dascore/core/coordmanager.py +++ b/dascore/core/coordmanager.py @@ -42,7 +42,7 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Collection, Mapping, Sequence +from collections.abc import Mapping, Sequence from itertools import zip_longest from types import EllipsisType from typing import Annotated, Any @@ -447,7 +447,7 @@ def new(self, dims=None, coord_map=None, dim_map=None) -> Self: def drop_coords( self, - *coords: str | Collection[str], + *coords: str, array: MaybeArray = None, ) -> tuple[Self, MaybeArray]: """ diff --git a/dascore/core/coords.py b/dascore/core/coords.py index c9736aa87..aafcc1317 100644 --- a/dascore/core/coords.py +++ b/dascore/core/coords.py @@ -1145,7 +1145,7 @@ def approx_equal(self: BaseCoord, other: BaseCoord) -> bool: return True return all_close(self.values, other.values) - def change_length(self, length: int) -> BaseCoord: + def change_length(self, length: int) -> Self: """ Adjust the length of the coordinate by changing the end value. @@ -1240,16 +1240,15 @@ def update(self, **kwargs): """No values to change so update can just call new.""" return self.new(**kwargs) - # Other operations that normally modify data do not in this case; - # they are spelled out rather than aliased so each keeps the - # signature its base declares. + # update_limits is spelled out rather than aliased to update so it keeps + # the signature its base declares. It must forward only what the caller + # supplied: a None reaching _validate_nullish_to_nan would overwrite the + # stored start, stop or step with nan. def update_limits(self, min=None, max=None, step=None, **kwargs) -> BaseCoord: - """No values to change, so only the metadata in kwargs is applied.""" - return self.update(min=min, max=max, step=step, **kwargs) - - def set_units(self, units) -> Self: - """No values to change, so this only records the new units.""" - return self.update(units=units) + """No values to limit, so only what was passed is applied.""" + limits = {"min": min, "max": max, "step": step} + passed = {i: v for i, v in limits.items() if v is not None} + return self.update(**passed, **kwargs) def convert_units(self, units) -> Self: """Convert scalar metadata units, or set units if none exist.""" @@ -1322,7 +1321,7 @@ def order( return super().order(array, relative=relative, samples=samples) @compose_docstring(doc=get_docstring(BaseCoord.change_length)) - def change_length(self, length: int) -> BaseCoord: + def change_length(self, length: int) -> Self: """ {doc} """ @@ -1683,7 +1682,7 @@ def _max(self): return np.max([self.stop - self.step, self.start]) @compose_docstring(doc=get_docstring(BaseCoord.change_length)) - def change_length(self, length: int) -> BaseCoord: + def change_length(self, length: int) -> Self: """ {doc} """ @@ -2370,7 +2369,7 @@ def _shift(self, delta) -> Self: segments.append(new) return self.new(segments=tuple(segments)) - def snap(self) -> BaseCoord: + def snap(self) -> CoordRange: """ Snap the coordinates to evenly sampled grid points. diff --git a/dascore/core/patch.py b/dascore/core/patch.py index 2ea0be6fd..2e792b963 100644 --- a/dascore/core/patch.py +++ b/dascore/core/patch.py @@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence from functools import cached_property -from typing import Final +from typing import Any, Final from uuid import uuid4 import numpy as np @@ -16,11 +16,7 @@ from dascore import transform from dascore.compat import DataArray, array from dascore.core.attrs import PatchAttrs -from dascore.core.coordmanager import ( - CoordManager, - CoordManagerInput, - get_coord_manager, -) +from dascore.core.coordmanager import CoordManager, get_coord_manager from dascore.core.summary import PatchSummary from dascore.utils.array import ( PatchUFunc, @@ -80,7 +76,7 @@ class Patch(NamespaceOwner): def __init__( self, data: ArrayLike | DataArray | None = None, - coords: CoordManagerInput | CoordManager | None = None, + coords: Mapping[str, Any] | CoordManager | None = None, dims: Sequence[str] | None = None, attrs: Mapping | PatchAttrs | None = None, ): diff --git a/dascore/io/core.py b/dascore/io/core.py index 86e0b9dab..55a2a759e 100644 --- a/dascore/io/core.py +++ b/dascore/io/core.py @@ -234,8 +234,8 @@ def _scan_payload_to_summary( shape=tuple(payload.get("shape", ())), dtype=str(payload["dtype"]), source_path=source_path, - source_format=source_format or "", - source_version=source_version or "", + source_format=source_format, + source_version=source_version, source_patch_id=( normalize_source_patch_id(source_patch_id) or normalize_source_patch_id(payload.get("source_patch_id")) diff --git a/dascore/io/index/indexer.py b/dascore/io/index/indexer.py index 913ca1f07..da5f70afe 100644 --- a/dascore/io/index/indexer.py +++ b/dascore/io/index/indexer.py @@ -136,7 +136,7 @@ def __init__( ): path = UPath(path).absolute() if isinstance(path, UPath) else Path(path) requires_local_directory(path, label="DBDirectoryIndexer") - self.path = coerce_to_local_path(path).absolute() + self.path = Path(coerce_to_local_path(path)).absolute() self.index_path = Path(self._find_index_path(index_path)) try: self._backend = get_backend(self.index_path) @@ -288,7 +288,7 @@ def _walk(self) -> dict[str, tuple[int, int, Path]]: signal = None if candidate is None: # the reply to a "skip" send continue - path = coerce_to_local_path(candidate) + path = Path(candidate) if path.is_dir(): if self._directory_format(path): signal = "skip" diff --git a/dascore/io/sintela/protobuf_utils.py b/dascore/io/sintela/protobuf_utils.py index a7cbb2876..fa8dc8dc0 100644 --- a/dascore/io/sintela/protobuf_utils.py +++ b/dascore/io/sintela/protobuf_utils.py @@ -534,7 +534,9 @@ def _get_times(times: list[np.datetime64 | None]): """ Build a time coordinate from packet timestamps. - A packet whose common header carries no time contributes NaT. + Callers reject a packet with no header time before getting here; the + None in the signature is what the list comprehension produces, not a + supported input. """ return get_coord(data=np.asarray(times, dtype="datetime64[ns]")) diff --git a/dascore/units.py b/dascore/units.py index e3fe4efa8..21ab5dfcc 100644 --- a/dascore/units.py +++ b/dascore/units.py @@ -6,6 +6,7 @@ from collections.abc import Sequence from functools import cache from threading import RLock +from types import EllipsisType from typing import Any, TypeVar import numpy as np @@ -116,17 +117,19 @@ def _str_to_quant(qunat_str): # Anything get_quantity can resolve: a unit or quantity, a string naming # one, a numpy time value, or a bare number (which is dimensionless). -# PlainUnit is the base pint builds its registry Unit from, and is what -# a quantity's .units is statically. +# PlainUnit is the base pint builds its registry Unit from (so it covers +# Unit too), and is what a quantity's .units is statically. bytes and +# Ellipsis are the two cases get_quantity opens by handling. quantity_like = ( str + | bytes | Quantity - | Unit | PlainUnit | np.datetime64 | np.timedelta64 | int | float + | EllipsisType | None ) @@ -272,7 +275,12 @@ def _unit_to_str(unit: Unit) -> str: return str(unit) -def get_quantity_str(quant_value: quantity_like) -> str | None: +# The subset of quantity_like which names a unit; a numpy time value +# would come back stringified as a date rather than a unit. +unit_like = str | bytes | Quantity | PlainUnit | None + + +def get_quantity_str(quant_value: unit_like) -> str | None: """ Ensure a unit/quantity is valid and return its string representation. @@ -342,7 +350,7 @@ def get_inverted_quant(quant: Quantity | None, data_units): def get_filter_units( arg1: Quantity | float, arg2: Quantity | float, - to_unit: quantity_like, + to_unit: unit_like, dim: str | None = None, ) -> tuple[float, float]: """ diff --git a/dascore/utils/misc.py b/dascore/utils/misc.py index f61d0bde8..5bb5c9458 100644 --- a/dascore/utils/misc.py +++ b/dascore/utils/misc.py @@ -534,7 +534,15 @@ def all_diffs_close_enough(diffs): return np.allclose(diffs, med, rtol=0.001) -def unbyte(byte_or_str: _T | bytes) -> _T | str: +@overload +def unbyte(byte_or_str: bytes) -> str: ... + + +@overload +def unbyte(byte_or_str: _T) -> _T: ... + + +def unbyte(byte_or_str): """ Decode a bytes value, passing anything else through unchanged. diff --git a/dascore/viz/spectrogram.py b/dascore/viz/spectrogram.py index 113fc35f5..38556a5a7 100644 --- a/dascore/viz/spectrogram.py +++ b/dascore/viz/spectrogram.py @@ -9,7 +9,6 @@ import numpy as np from scipy.signal import spectrogram as scipy_spectrogram -import dascore as dc from dascore.constants import PatchType from dascore.core.attrs import PatchAttrs from dascore.core.coordmanager import get_coord_manager @@ -41,7 +40,7 @@ def _get_new_original_coord(old_coord, array): def _get_transformed_coord(coord, freqs): """Get the transformed coordinates.""" - units = dc.get_quantity(1 / coord.units) if coord.units is not None else None + units = 1 / coord.units if coord.units is not None else None return get_coord(data=freqs, units=units) diff --git a/pyproject.toml b/pyproject.toml index c3f835b51..560e82f6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -267,8 +267,8 @@ line-ending = "lf" include = ["dascore"] # tests/ still has many diagnostics; expand scope later. # Rules with large pre-existing error counts, ignored until incrementally -# burned down. Counts as of 2026-08-05: invalid-argument-type 31, -# invalid-return-type 30, invalid-method-override 4. +# burned down. Counts as of 2026-08-05: invalid-argument-type 36, +# invalid-return-type 31, invalid-method-override 4. [tool.ty.rules] invalid-argument-type = "ignore" invalid-return-type = "ignore" diff --git a/tests/test_core/test_coordmanager.py b/tests/test_core/test_coordmanager.py index 80f3dfd86..1c271c13c 100644 --- a/tests/test_core/test_coordmanager.py +++ b/tests/test_core/test_coordmanager.py @@ -1334,6 +1334,16 @@ def test_set_units_on_valueless_coord(self): out = cm.set_units(time="s") assert out.coord_map["time"].units == get_quantity("s") + def test_patch_set_units_on_valueless_coord(self): + """The same holds through the patch, which is how users reach it.""" + cm = get_coord_manager( + {"time": get_coord(shape=(10,)), "distance": np.arange(4) * 1.0}, + dims=("time", "distance"), + ) + patch = dc.Patch(data=np.zeros((10, 4)), coords=cm, dims=cm.dims) + out = patch.set_units(time="s") + assert out.get_coord("time").units == get_quantity("s") + class TestConvertUnits: """Tests for converting coordinate units.""" diff --git a/tests/test_core/test_coords.py b/tests/test_core/test_coords.py index 287fecae1..3210eefe7 100644 --- a/tests/test_core/test_coords.py +++ b/tests/test_core/test_coords.py @@ -1693,10 +1693,12 @@ def test_set_units_positionally(self, basic_non_coord): assert out.units == dc.get_quantity("m") assert isinstance(out, CoordPartial) - def test_update_limits_only_touches_metadata(self, basic_non_coord): - """There are no values to limit, so only the metadata changes.""" - out = basic_non_coord.update_limits(units="m") + def test_update_limits_only_touches_metadata(self): + """There are no values to limit, so the stored scalars survive.""" + coord = get_coord(shape=(4,), step=1.0, dtype="float64") + out = coord.update_limits(units="m") assert out.units == dc.get_quantity("m") + assert out.step == coord.step assert isinstance(out, CoordPartial) def test_dimensionless_shape_survives_dump(self): diff --git a/tests/test_io/test_indexer.py b/tests/test_io/test_indexer.py index 171fd9a01..a3574f092 100644 --- a/tests/test_io/test_indexer.py +++ b/tests/test_io/test_indexer.py @@ -134,7 +134,9 @@ def test_remote_directory_not_supported(self): def test_local_upath_normalized_to_path(self, tmp_path): """Local UPath inputs should normalize to pathlib.Path internally.""" out = DBDirectoryIndexer(UPath(tmp_path)) - assert isinstance(out.path, Path) + # A local UPath is itself a Path subclass, so isinstance is not + # enough to show it was normalized. + assert type(out.path) is type(Path(tmp_path)) assert out.path == Path(tmp_path).absolute() def test_index_map_dir_comes_from_config(self, tmp_path): diff --git a/tests/test_utils/test_misc.py b/tests/test_utils/test_misc.py index 48a57cb77..a168bd34a 100644 --- a/tests/test_utils/test_misc.py +++ b/tests/test_utils/test_misc.py @@ -152,6 +152,11 @@ def test_multiple_subdirs(self, simple_dir): } assert out == expected + def test_local_file_uri(self, simple_dir): + """A local path carrying a file:// scheme walks like a plain one.""" + out = set(_iter_filesystem(simple_dir.as_uri())) + assert out == set(_iter_filesystem(simple_dir)) + def test_extension(self, simple_dir): """Test filtering based on extension.""" out = set(_iter_filesystem(simple_dir, ext=".txt")) From 3160136132a595c08404b0f5b4db983444c918e0 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Wed, 5 Aug 2026 08:36:35 +0200 Subject: [PATCH 9/9] Enable invalid-method-override The four remaining ones were each a real disagreement, not noise. BaseCoord's shape validator was named for a job it does not do, and the name collided with CoordPartial's start/stop/step validator -- pydantic lets the subclass replace it, so a partial coord silently lost the int to tuple coercion every other coord has. Renaming it to what it does restores that and removes the collision. PlanResolver.live_entries promised a Mapping where the registry it returns is popped from. CoordManager.new named three fields where its base takes any. sensible_model_equals declared an other it cannot require, and now returns NotImplemented for anything that cannot carry the same fields, which is what __eq__ is supposed to do. With those gone the rule holds at zero, so it comes out of the ignore list. --- dascore/core/coordmanager.py | 3 ++- dascore/core/coords.py | 2 +- dascore/io/index/planned.py | 2 +- dascore/utils/models.py | 11 +++++++---- pyproject.toml | 5 ++--- tests/test_core/test_coords.py | 4 ++++ 6 files changed, 17 insertions(+), 10 deletions(-) diff --git a/dascore/core/coordmanager.py b/dascore/core/coordmanager.py index 80ff32595..c57f54cb8 100644 --- a/dascore/core/coordmanager.py +++ b/dascore/core/coordmanager.py @@ -425,7 +425,7 @@ def snap( assert out.shape == self.shape return out, array - def new(self, dims=None, coord_map=None, dim_map=None) -> Self: + def new(self, dims=None, coord_map=None, dim_map=None, **kwargs) -> Self: """ Return a new coordmanager with specified attributes replaced. @@ -442,6 +442,7 @@ def new(self, dims=None, coord_map=None, dim_map=None) -> Self: dims=dims if dims is not None else self.dims, coord_map=coord_map if coord_map is not None else self.coord_map, dim_map=dim_map if dim_map is not None else self.dim_map, + **kwargs, ) return out diff --git a/dascore/core/coords.py b/dascore/core/coords.py index aafcc1317..7ae542d9e 100644 --- a/dascore/core/coords.py +++ b/dascore/core/coords.py @@ -341,7 +341,7 @@ def check_time_units(cls, data: Any) -> Any: @field_validator("shape", mode="before") @classmethod - def _validate_nullish_to_nan(cls, value): + def _validate_shape_to_tuple(cls, value): """Ensure shape is a tuple.""" # This also allows shape to be an int. return tuple(iterate(value)) diff --git a/dascore/io/index/planned.py b/dascore/io/index/planned.py index 99b563584..3118a1dd8 100644 --- a/dascore/io/index/planned.py +++ b/dascore/io/index/planned.py @@ -330,7 +330,7 @@ def __init__( # informational only: the directory/file the plan derived from self.origin_path = origin_path - def live_entries(self) -> Mapping[str, dc.Patch]: + def live_entries(self) -> dict[str, dc.Patch]: """Expose the loader's live registry (for absorption/transfer).""" return self.loader.live_entries() diff --git a/dascore/utils/models.py b/dascore/utils/models.py index ee0458e26..6ed84f56d 100644 --- a/dascore/utils/models.py +++ b/dascore/utils/models.py @@ -74,12 +74,15 @@ PositiveFiniteFloat = Annotated[float, Field(gt=0, allow_inf_nan=False)] -def sensible_model_equals( - self: BaseModel | Mapping, other: BaseModel | Mapping -) -> bool: +def sensible_model_equals(self: BaseModel | Mapping, other: object) -> bool: """Custom equality to not compare private attrs and handle numpy arrays.""" d1 = self.model_dump() if isinstance(self, BaseModel) else self - d2 = other.model_dump() if isinstance(other, BaseModel) else other + if isinstance(other, BaseModel): + d2 = other.model_dump() + elif isinstance(other, Mapping): + d2 = other + else: # nothing else can carry the same fields + return NotImplemented if not set(d1) == set(d2): # different keys, not equal return False for name in set(x for x in d1 if not x.startswith("_")): diff --git a/pyproject.toml b/pyproject.toml index 560e82f6b..f526ddfaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -267,12 +267,11 @@ line-ending = "lf" include = ["dascore"] # tests/ still has many diagnostics; expand scope later. # Rules with large pre-existing error counts, ignored until incrementally -# burned down. Counts as of 2026-08-05: invalid-argument-type 36, -# invalid-return-type 31, invalid-method-override 4. +# burned down. Counts as of 2026-08-05: invalid-argument-type 34, +# invalid-return-type 31. invalid-method-override reached zero and is on. [tool.ty.rules] invalid-argument-type = "ignore" invalid-return-type = "ignore" -invalid-method-override = "ignore" # 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_coords.py b/tests/test_core/test_coords.py index 3210eefe7..907c467e8 100644 --- a/tests/test_core/test_coords.py +++ b/tests/test_core/test_coords.py @@ -1687,6 +1687,10 @@ def test_non_coord_eq_self(self, basic_non_coord): """Ensure non coords are equal to themselves.""" assert basic_non_coord == basic_non_coord + def test_shape_accepts_an_int(self): + """A partial coord coerces an int shape like every other coord.""" + assert CoordPartial(shape=5, dtype="float64").shape == (5,) + def test_set_units_positionally(self, basic_non_coord): """Units are set the same way as on any other coord.""" out = basic_non_coord.set_units("m")