Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions dascore/core/coordmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
from __future__ import annotations

from collections import defaultdict
from collections.abc import Mapping, Sequence
from collections.abc import Iterable, Mapping, Sequence
from itertools import zip_longest
from types import EllipsisType
from typing import Annotated, Any
Expand Down Expand Up @@ -83,10 +83,14 @@
)

MaybeArray = ArrayLike | np.ndarray | None
CoordManagerInput = Mapping[
str,
BaseCoord | np.ndarray | tuple[str | tuple[str, ...], BaseCoord | np.ndarray],
]

# What a coord map may hold, kept identical to the Patch constructor's coords.
# The value stays Any on purpose: get_coord_manager also accepts an int or a
# Quantity (a partial coord), a mapping of start/stop/step, and a
# (dimension, data) tuple, and every union narrow enough to be worth writing
# rejected one of those first-party forms. Mapping rather than dict so a
# caller's narrower value type still matches.
CoordManagerInput = Mapping[str, Any]


def _ensure_1d_coord(coord, coord_name: str):
Expand Down Expand Up @@ -448,7 +452,7 @@ def new(self, dims=None, coord_map=None, dim_map=None, **kwargs) -> Self:

def drop_coords(
self,
*coords: str,
*coords: str | Iterable[str],
array: MaybeArray = None,
) -> tuple[Self, MaybeArray]:
"""
Expand All @@ -460,10 +464,13 @@ def drop_coords(
Parameters
----------
*coords
The name of the coordinate or dimension.
The name of the coordinate or dimension, or a sequence of them.
"""
dim_drop_list = []
coords_to_drop = {x for x in iterate(coords)}
# iterate is applied per argument; the varargs tuple is already
# iterable, so flattening it as a whole would leave any sequence
# passed in as a single unhashable element.
coords_to_drop = {x for coord in coords for x in iterate(coord)}
# If there are either no coords to drop or this cm doesn't have them.
if not coords_to_drop or not (set(self.coord_map) & coords_to_drop):
return self, array
Expand Down
6 changes: 4 additions & 2 deletions dascore/io/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,10 @@ def _scan_payload_to_summary(
payload: ScanPayload | Mapping[str, Any],
*,
source_path: str | Path | UPath | None = None,
source_format: str | None = None,
source_version: str | None = None,
# PatchSummary stores these as plain strings and its validator maps a
# missing value to "", so default to what it would normalize None to.
source_format: str = "",
source_version: str = "",
source_patch_id: str | None = None,
) -> PatchSummary:
"""Convert one structured FiberIO scan payload into a PatchSummary."""
Expand Down
23 changes: 18 additions & 5 deletions dascore/io/index/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,8 +474,15 @@ def clear(self) -> None:
self.value, self.revision = None, -1


# sentinel: _view keeps the current order/ids spec unless told otherwise
_KEEP = object()
class _Keep:
"""Sentinel: _view keeps the current order/ids spec unless told otherwise.

A dedicated class rather than a bare object so that testing against it
narrows the parameter to the spec type it otherwise holds.
"""


_KEEP = _Keep()


class PatchCatalog:
Expand Down Expand Up @@ -702,16 +709,22 @@ def __getstate__(self) -> dict:
state["resolver"] = _membership_resolver(resolver, keep, paths)
return state

def _view(self, queries, residuals, order=_KEEP, ids=_KEEP) -> PatchCatalog:
def _view(
self,
queries,
residuals,
order: tuple | _Keep | None = _KEEP,
ids: tuple | _Keep | None = _KEEP,
) -> PatchCatalog:
out = PatchCatalog(
backend=self.backend,
resolver=self.resolver,
syncer=self._syncer,
queries=queries,
residuals=residuals,
revision=self._revision,
order=self._order if order is _KEEP else order,
ids=self._ids if ids is _KEEP else ids,
order=self._order if isinstance(order, _Keep) else order,
ids=self._ids if isinstance(ids, _Keep) else ids,
default_order=self._default_order,
)
return out
Expand Down
19 changes: 16 additions & 3 deletions dascore/io/netcdf/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from __future__ import annotations

import h5py
from collections.abc import Mapping
from typing import Any, Protocol

import numpy as np

import dascore as dc
Expand Down Expand Up @@ -32,7 +34,18 @@ def parse_cf_version(cf_version: str) -> tuple[int, int]:
return major, minor


def is_netcdf4_file(h5file: h5py.File) -> bool:
class _HasAttrs(Protocol):
"""Anything carrying HDF5-style attrs.

The two checks below only read `attrs`, and they are handed the managed
handle a FiberIO caster produces rather than an `h5py.File` proper.
"""

@property
def attrs(self) -> Mapping[str, Any]: ...


def is_netcdf4_file(h5file: _HasAttrs) -> bool:
"""Return True when an HDF5 file exposes strong NetCDF/CF markers."""
try:
if "_NCProperties" in h5file.attrs:
Expand All @@ -45,7 +58,7 @@ def is_netcdf4_file(h5file: h5py.File) -> bool:
return False


def get_cf_version(h5file: h5py.File) -> str | None:
def get_cf_version(h5file: _HasAttrs) -> str | None:
"""Extract the CF convention version string from a NetCDF file."""
conventions = h5file.attrs.get("Conventions", "")
if isinstance(conventions, bytes):
Expand Down
8 changes: 6 additions & 2 deletions dascore/proc/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
from dascore.compat import array
from dascore.constants import PatchType
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 get_coord
from dascore.exceptions import ParameterError
from dascore.utils.array import _apply_binary_ufunc
Expand Down Expand Up @@ -200,7 +204,7 @@ def bool_patch(self: PatchType):
def update(
self: PatchType,
data: ArrayLike | np.ndarray | None = None,
coords: dict[str | Sequence[str], ArrayLike] | CoordManager | None = None,
coords: CoordManagerInput | CoordManager | None = None,
dims: Sequence[str] | None = None,
attrs: Mapping | PatchAttrs | None = None,
) -> PatchType:
Expand Down
14 changes: 9 additions & 5 deletions dascore/proc/coords.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from collections.abc import Collection
from collections.abc import Iterable

import numpy as np
import pandas as pd
Expand Down Expand Up @@ -260,7 +260,7 @@ def update_coords(self: PatchType, **kwargs) -> PatchType:


@patch_function()
def drop_coords(self: PatchType, *coords: str | Collection[str]) -> PatchType:
def drop_coords(self: PatchType, *coords: str | Iterable[str]) -> PatchType:
"""
Update the coordinates of a patch.

Expand All @@ -269,7 +269,8 @@ def drop_coords(self: PatchType, *coords: str | Collection[str]) -> PatchType:
Parameters
----------
*coords
One or more coordinates to drop.
One or more coordinates to drop. Each can be a coordinate name or
a sequence of them.

Examples
--------
Expand All @@ -278,11 +279,14 @@ def drop_coords(self: PatchType, *coords: str | Collection[str]) -> PatchType:
>>> pa = dc.get_example_patch("random_patch_with_lat_lon")
>>> # Drop non-dimensional coordinate latitude
>>> pa_no_lat = pa.drop_coords("latitude")
>>> # A sequence of names works as well.
>>> pa_no_lat = pa.drop_coords(["latitude"])
"""
if dim_coords := set(coords) & set(self.dims):
names = {x for coord in coords for x in iterate(coord)}
if dim_coords := names & set(self.dims):
msg = f"Cannot drop dimensional coordinates: {dim_coords}"
raise ParameterError(msg)
new_coord, data = self.coords.drop_coords(*coords, array=self.data)
new_coord, data = self.coords.drop_coords(*names, array=self.data)
return self.new(coords=new_coord, dims=new_coord.dims, data=data)


Expand Down
13 changes: 10 additions & 3 deletions dascore/units.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from functools import cache
from threading import RLock
from types import EllipsisType
from typing import Any, TypeVar
from typing import Any, TypeVar, cast

import numpy as np
import pandas as pd
Expand Down Expand Up @@ -520,7 +520,7 @@ def maybe_convert_percent_to_fraction(obj):
return out


def __getattr__(name):
def __getattr__(name: str) -> Quantity:
"""
Allows arbitrary units (quantities) to be imported from this module.

Expand All @@ -530,5 +530,12 @@ def __getattr__(name):
is the same as
from dascore.units import get_quantity
m = get_quantity("m")

Any non-empty name either resolves to a quantity or raises
UndefinedUnitError, so the cast holds. The empty string is the one input
get_quantity maps to None, and attribute access is the right place to
reject it.
"""
return get_quantity(name)
if not name:
raise AttributeError(name)
return cast("Quantity", get_quantity(name))
4 changes: 3 additions & 1 deletion dascore/utils/mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ def __contains__(self, key: object) -> bool:

def new(self, **kwargs):
"""Copy the contents and update with new values."""
# Passed as a mapping rather than splatted so keys that are not
# strings survive the round trip.
contents = dict(self._dict)
contents.update(kwargs)
return self.__class__(**contents)
return self.__class__(contents)

def __iter__(self) -> Iterator[K]:
return iter(self._dict)
Expand Down
18 changes: 12 additions & 6 deletions dascore/utils/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import sys
import warnings
from collections import namedtuple
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Callable, Iterable, Mapping, Sequence
from typing import Any, Literal, Protocol, cast, overload

import numpy as np
Expand Down Expand Up @@ -298,10 +298,14 @@ def patch_function(
as dc at the top of the file where the patch function is defined so
the forward refs can be resolved properly for type checking.
"""
# Handled before the wrapper is built so the rest of this function sees
# required_dims as the tuple of dimension names it is everywhere else.
if callable(required_dims): # the decorator is used without parens
return patch_function()(required_dims)

def _wrapper(func):
if validate_call:
config = dict(arbitrary_types_allowed=True)
config = pydantic.ConfigDict(arbitrary_types_allowed=True)
func = pydantic.validate_call(config=config)(func)

@functools.wraps(func)
Expand Down Expand Up @@ -338,9 +342,6 @@ def _func(patch, *args, **kwargs):

return patch_func

if callable(required_dims): # the decorator is used without parens
return patch_function()(required_dims)

return _wrapper


Expand Down Expand Up @@ -551,7 +552,12 @@ def get_start_stop_step(patch: PatchType, dim):


def get_patch_names(
patch_data: pd.DataFrame | dc.Patch | dc.BaseSpool,
# Forwarded straight to scan_to_df, so anything it scans works here,
# including a plain list of patches. Spelled out rather than reusing
# io.core.ScanInput: importing that is circular, and hiding it behind
# TYPE_CHECKING leaves the annotation unresolvable at runtime, which
# breaks get_type_hints and the API doc renderer.
patch_data: pd.DataFrame | dc.Patch | dc.BaseSpool | Iterable[dc.Patch],
prefix="DAS",
attrs=("network", "station", "tag"),
coords=("time",),
Expand Down
5 changes: 4 additions & 1 deletion dascore/utils/patch_assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,10 @@ def _merge_patches_streaming(self, joined, df_dict_list, merge_dim, samples):
coords.append(patch.coords)
attrs.append(patch.attrs)
summaries.append(patch.coords._get_dim_summary())
assert buffer is not None # allocated on the first pass of the loop
# All set on the first pass of the loop, which always runs.
assert buffer is not None
assert axis is not None
assert dims is not None
if offset != buffer.shape[axis]: # over-estimated; trim excess.
buffer = buffer[broadcast_for_index(buffer.ndim, axis, slice(0, offset))]
# Ensure the loaded patches only vary along the expected dimension,
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ The [releases page](https://github.com/DASDAE/dascore/releases) tracks changes f

## Unreleased API Changes

- `Patch.drop_coords` and `CoordManager.drop_coords` accept a sequence of names as well as bare names, so `patch.drop_coords(["latitude", "longitude"])` works alongside `patch.drop_coords("latitude", "longitude")`. Previously a list or set raised `TypeError` and a tuple or generator was silently ignored; a tuple now drops the named coordinates, and one naming a dimension raises `ParameterError` as a bare dimension name always has.
- **Runtime configuration is now two-tier.** `dc.set_config(...)` sets the process-wide base permanently (visible from every thread and task) and returns the new config; it is no longer a context manager. Temporary, thread/task-local overrides use the new `dc.config_context(...)` context manager, which restores on exit and isolates concurrent overrides via a `ContextVar`. `Spool.map(...)` captures the config active at the call and re-applies it in each worker, so thread- and process-pool workers observe the caller's config regardless of the pool's start method. Unknown config field names now raise instead of being silently ignored. Migrate `with dc.set_config(...)` to `with dc.config_context(...)`.
- PRODML 2.1 now supports writing one raw time-by-distance patch with `dc.write` as a standalone HDF5 file. Full PRODML 2.3 conformance requires EPC/XML packaging, which DASCore does not write.
- **The `dascore.io.sintela_binary` module is removed (no alias).** Both Sintela readers now live in `dascore.io.sintela`, which also provides the new protobuf reader; use `from dascore.io.sintela import SintelaBinaryV3`. Reading Sintela binary files through `dc.read`/`dc.spool`/`dc.scan` is unaffected — only the direct module import path changed.
Expand Down
10 changes: 10 additions & 0 deletions tests/test_core/test_coordmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,16 @@ def test_drop_doesnt_have_coord(self, cm_multidim):
out, _ = cm_multidim.drop_coords("bob")
assert out == cm_multidim

@pytest.mark.parametrize("wrap", [list, tuple, set, iter])
def test_drop_sequence(self, cm_multidim, wrap):
"""A sequence of names should behave exactly like the bare name."""
dim = "distance"
coords, _ = cm_multidim.drop_coords(wrap([dim]))
expected, _ = cm_multidim.drop_coords(dim)
assert dim not in coords.dims
# Compared to the bare-name call so that dropping too much fails too.
assert coords == expected

def test_trims_array(self, cm_multidim):
"""Trying to drop a dim that doesnt exist should just return."""
array = np.ones(cm_multidim.shape)
Expand Down
27 changes: 27 additions & 0 deletions tests/test_proc/test_proc_coords.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,33 @@ def test_drop_dim_raises(self, random_patch):
with pytest.raises(ParameterError, match=msg):
random_patch.drop_coords("time")

@pytest.mark.parametrize(
"form", [["latitude"], ("latitude",), {"latitude"}, iter(["latitude"])]
)
def test_drop_sequence(self, random_patch_with_lat_lon, form):
"""A sequence of names should behave exactly like the bare name."""
patch = random_patch_with_lat_lon
out = patch.drop_coords(form)
expected = patch.drop_coords("latitude")
assert "latitude" not in out.coords.coord_map
# Compared to the bare-name call so that dropping too much fails too.
assert set(out.coords.coord_map) == set(expected.coords.coord_map)

def test_drop_mixed_args(self, random_patch_with_lat_lon):
"""Names and sequences of names should be usable together."""
patch = random_patch_with_lat_lon
out = patch.drop_coords("latitude", ["longitude"])
dropped = {"latitude", "longitude"}
assert not dropped & set(out.coords.coord_map)
# Everything else has to survive.
assert set(out.coords.coord_map) == set(patch.coords.coord_map) - dropped

def test_drop_dim_in_sequence_raises(self, random_patch):
"""A dimension inside a sequence should raise like a bare one."""
msg = "Cannot drop dimensional coordinates"
with pytest.raises(ParameterError, match=msg):
random_patch.drop_coords(["time"])


class TestCoordsFromDf:
"""Tests for attaching coordinate(s) to a patch."""
Expand Down
7 changes: 7 additions & 0 deletions tests/test_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,13 @@ def test_bad_import_error_msg(self):
with pytest.raises(ImportError):
from dascore.utils import bob # noqa

def test_empty_name_raises(self):
"""The empty string is the one name get_quantity maps to None."""
import dascore.units

with pytest.raises(AttributeError):
getattr(dascore.units, "")


class TestGetFilterUnits:
"""Tests for getting units that can be used for filtering."""
Expand Down
Loading
Loading