diff --git a/dascore/core/attrs.py b/dascore/core/attrs.py index a95def43..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 @@ -141,7 +141,9 @@ def from_dict( if isinstance(out, Mapping): out = dict(out) out.pop("dims", None) - return cls(**out) + # 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 f206ca56..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,12 +442,13 @@ 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 def drop_coords( self, - *coords: str | Sequence[str], + *coords: str, array: MaybeArray = None, ) -> tuple[Self, MaybeArray]: """ @@ -1133,7 +1134,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 +1176,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/coords.py b/dascore/core/coords.py index e45ea7c6..7ae542d9 100644 --- a/dascore/core/coords.py +++ b/dascore/core/coords.py @@ -341,13 +341,13 @@ 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)) @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. @@ -1240,9 +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. - update_limits = update - set_units = update + # 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 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.""" @@ -1307,7 +1313,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}. """ @@ -1616,7 +1622,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." @@ -1730,7 +1736,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 +1806,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 +1869,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 +2338,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 = ( @@ -2708,9 +2714,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 +2736,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 +2785,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/dascore/core/patch.py b/dascore/core/patch.py index ccf9e9d9..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 @@ -17,7 +17,6 @@ from dascore.compat import DataArray, array from dascore.core.attrs import PatchAttrs from dascore.core.coordmanager import CoordManager, get_coord_manager -from dascore.core.coords import BaseCoord from dascore.core.summary import PatchSummary from dascore.utils.array import ( PatchUFunc, @@ -77,7 +76,7 @@ class Patch(NamespaceOwner): def __init__( self, data: ArrayLike | DataArray | None = None, - coords: Mapping[str, ArrayLike | BaseCoord] | CoordManager | None = None, + coords: Mapping[str, Any] | CoordManager | None = None, dims: Sequence[str] | None = None, attrs: Mapping | PatchAttrs | None = None, ): diff --git a/dascore/core/summary.py b/dascore/core/summary.py index 3d55a015..c4b6a9ee 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: @@ -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/core.py b/dascore/io/core.py index d1f119ef..55a2a759 100644 --- a/dascore/io/core.py +++ b/dascore/io/core.py @@ -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..da5f70af 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 = 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) 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/io/sintela/protobuf_utils.py b/dascore/io/sintela/protobuf_utils.py index 505155b2..fa8dc8dc 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,14 @@ 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. + + 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]")) @@ -557,7 +563,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 +578,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]: diff --git a/dascore/io/terra15/utils.py b/dascore/io/terra15/utils.py index de11079f..63f4ad09 100644 --- a/dascore/io/terra15/utils.py +++ b/dascore/io/terra15/utils.py @@ -179,8 +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: - 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 51d73d75..21ab5dfc 100644 --- a/dascore/units.py +++ b/dascore/units.py @@ -6,12 +6,14 @@ 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 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 @@ -113,8 +115,27 @@ 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). +# 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 + | PlainUnit + | np.datetime64 + | np.timedelta64 + | int + | float + | EllipsisType + | 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 +195,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. @@ -229,7 +250,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. @@ -254,7 +275,12 @@ def _unit_to_str(unit: Unit) -> str: return str(unit) -def get_quantity_str(quant_value: str | Quantity | None) -> 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. @@ -324,7 +350,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: unit_like, dim: str | None = None, ) -> tuple[float, float]: """ 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/utils/misc.py b/dascore/utils/misc.py index 06b9d54d..5bb5c945 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 @@ -31,9 +31,16 @@ 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") + def register_func(list_or_dict: list | dict, key=None): """ @@ -222,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, @@ -527,10 +534,23 @@ 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.""" +@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. + + 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 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 3b6f68be..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-04: invalid-argument-type 86, -# invalid-return-type 36, invalid-method-override 15. +# 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/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/) diff --git a/tests/test_core/test_coordmanager.py b/tests/test_core/test_coordmanager.py index 69cfde0b..1c271c13 100644 --- a/tests/test_core/test_coordmanager.py +++ b/tests/test_core/test_coordmanager.py @@ -1321,6 +1321,30 @@ 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") + + 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 f1a0685b..907c467e 100644 --- a/tests/test_core/test_coords.py +++ b/tests/test_core/test_coords.py @@ -1687,6 +1687,24 @@ 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") + assert out.units == dc.get_quantity("m") + assert isinstance(out, CoordPartial) + + 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): """A partial coord keeps its shape when defaults are excluded.""" coord = get_coord(shape=()) 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"))