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 e45ea7c6..c9736aa8 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 3b6f68be..7ba108d2 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 69cfde0b..80f3dfd8 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 f1a0685b..287fecae 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 f206ca56..905c5ae6 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 ccf9e9d9..ab30ddd8 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 505155b2..a7cbb287 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 ab30ddd8..2ea0be6f 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 51d73d75..7adae3af 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 06b9d54d..646b0699 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 de11079f..3b67f33a 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 7adae3af..e3fe4efa 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 a95def43..84c39456 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 3d55a015..f6136824 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 d1f119ef..86e0b9da 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 3d7be4f9..913ca1f0 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 646b0699..f61d0bde 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 905c5ae6..741c643b 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 f6136824..c4b6a9ee 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 3b67f33a..63f4ad09 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 411a8578..d368c57a 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 38556a5a..113fc35f 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 7ba108d2..c3f835b5 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 2791d1bc..4aa19d40 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 84c39456..ce0a63c3 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 741c643b..80ff3259 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 c9736aa8..aafcc131 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 2ea0be6f..2e792b96 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 86e0b9da..55a2a759 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 913ca1f0..da5f70af 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 a7cbb287..fa8dc8dc 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 e3fe4efa..21ab5dfc 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 f61d0bde..5bb5c945 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 113fc35f..38556a5a 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 c3f835b5..560e82f6 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 80f3dfd8..1c271c13 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 287fecae..3210eefe 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 171fd9a0..a3574f09 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 48a57cb7..a168bd34 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 80ff3259..c57f54cb 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 aafcc131..7ae542d9 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 99b56358..3118a1dd 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 ee0458e2..6ed84f56 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 560e82f6..f526ddfa 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 3210eefe..907c467e 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")