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
34 changes: 17 additions & 17 deletions dascore/core/coordmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ def _get_indexers_and_new_coords_dict(
):
"""Get reductions for each dimension."""
dim_reductions = {x: slice(None, None) for x in cm.dims}
new_coords = dict(cm._get_dim_array_dict(keep_coord=True))
new_coords = dict(cm._get_dim_coord_dict())
for coord_name, vals in kwargs.items():
# All coordinates should exist in coord_map (filtered by
# _get_single_dim_kwarg_list)
Expand Down Expand Up @@ -223,7 +223,9 @@ def __getitem__(self, item) -> BaseCoord:

def __getattr__(self, item) -> BaseCoord:
try:
return super().__getattr__(item)
# pydantic defines BaseModel.__getattr__ only at runtime so
# checkers still flag misspelled fields.
return super().__getattr__(item) # ty: ignore[unresolved-attribute]
except AttributeError:
# unlike get item, get attr returns the base coordinate.
try:
Expand Down Expand Up @@ -295,7 +297,7 @@ def _divide_kwargs(kwargs):
indirect_coord_drops = _get_dim_change_drop(coord_map, dim_map)
# drop coords then call get_coords to handle adding new ones.
coords, _ = self.drop_coords(*(coord_to_drop + indirect_coord_drops))
out = coords._get_dim_array_dict(keep_coord=True)
out = coords._get_dim_coord_dict()
out.update({i: v for i, v in kwargs.items() if i not in coord_to_drop})
# update based on keywords
for item, value in coord_updates.items():
Expand Down Expand Up @@ -800,20 +802,18 @@ def validate_data(self, data):
raise CoordDataError(msg)
return data

def _get_dim_array_dict(
self, keep_coord=False
) -> dict[str, tuple[tuple[str, ...], ArrayLike | BaseCoord]]:
"""
Get the coord map in the form:
{coord_name = ((dims,), array)}.
def _get_dim_coord_dict(self) -> dict[str, tuple[tuple[str, ...], BaseCoord]]:
"""Get the coord map in the form {coord_name: ((dims,), coord)}."""
return {
name: (self.dim_map[name], coord) for name, coord in self.coord_map.items()
}

if keep_coord, just keep the coordinate as second arg.
"""
out = {}
for name, coord in self.coord_map.items():
dims = self.dim_map[name]
out[name] = (dims, coord if keep_coord else coord.data)
return out
def _get_dim_array_dict(self) -> dict[str, tuple[tuple[str, ...], ArrayLike]]:
"""Get the coord map in the form {coord_name: ((dims,), array)}."""
return {
name: (dims, coord.data)
for name, (dims, coord) in self._get_dim_coord_dict().items()
}

def set_units(self, **kwargs):
"""Set the units of the coordinate manager."""
Expand Down Expand Up @@ -1015,7 +1015,7 @@ def keys(self):
"""Return the keys (coordinates) in the coord manager."""
return self.coord_map.keys()

def to_summary_dict(self) -> dict[str, CoordSummary | tuple[str, ...]]:
def to_summary_dict(self) -> dict[str, CoordSummary]:
"""Convert the contents of the coordinate manager to a summary dict."""
dim_map = self.dim_map
out = {}
Expand Down
37 changes: 30 additions & 7 deletions dascore/core/coords.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from functools import cache
from operator import gt, lt
from types import EllipsisType
from typing import Any, Literal, overload
from typing import TYPE_CHECKING, Any, Literal, overload

import numpy as np
import pandas as pd
Expand Down Expand Up @@ -291,6 +291,16 @@ class BaseCoord(DascoreBaseModel, abc.ABC):
shape: tuple[int, ...] | None = None
dtype: Any = None

if TYPE_CHECKING:
# Every coord exposes its values, but the array-backed coords store
# them in a pydantic field while the rest compute them in a property.
# Pydantic refuses to let a field shadow an inherited property (and a
# field here would make values a required init argument), so the
# shared interface is only declared for type checkers.
@property
def values(self) -> ArrayLike:
"""The coordinate's values."""

_rich_style = dascore_styles["default_coord"]
_evenly_sampled = False
_sorted = False
Expand Down Expand Up @@ -619,17 +629,17 @@ def size(self) -> int:
return np.prod(self.shape)

@property
def evenly_sampled(self) -> tuple[int, ...]:
def evenly_sampled(self) -> bool:
"""Returns True if the coord is evenly sampled."""
return self._evenly_sampled

@property
def sorted(self) -> tuple[int, ...]:
def sorted(self) -> bool:
"""Returns True if the coord in sorted."""
return self._sorted

@property
def reverse_sorted(self) -> tuple[int, ...]:
def reverse_sorted(self) -> bool:
"""Returns True if the coord in sorted in reverse order."""
return self._reverse_sorted

Expand Down Expand Up @@ -996,6 +1006,17 @@ def get_sample_count(self, value, samples=False, enforce_lt_coord=False) -> int:
raise ParameterError(msg)
return samples

def _get_index(self, value, forward=True):
"""
Get the index a value would occupy in the coordinate.

Overridden by the coords that index by value. Unordered arrays
have no such position, and string coords deliberately keep out of
positional semantics (see _raise_string_coord_error).
"""
msg = f"{type(self).__name__} does not support indexing by value."
raise CoordError(msg)

def get_next_index(
self, value, samples=False, allow_out_of_bounds=False, relative=False
) -> int:
Expand Down Expand Up @@ -1097,9 +1118,10 @@ def approx_equal(self: BaseCoord, other: BaseCoord) -> bool:
return self == other
if any(non_coords):
return False
# Evenly sampled coords with identical start/stop/step have identical
# values; this avoids materializing and comparing the value arrays.
if self._evenly_sampled and other._evenly_sampled:
# Ranges (the evenly sampled coords) with identical start/stop/step
# have identical values; this avoids materializing and comparing
# the value arrays.
if isinstance(self, CoordRange) and isinstance(other, CoordRange):
same = (
self.start == other.start
and self.stop == other.stop
Expand Down Expand Up @@ -2255,6 +2277,7 @@ def select(
sub, seg_lo, seg_hi = seg, 0, len(seg)
else: # boundary segment; delegate the exact trim
sub, indexer = seg.select((v1, v2))
assert isinstance(indexer, slice) # a value window is contiguous
seg_lo, seg_hi, _ = indexer.indices(len(seg))
if seg_hi <= seg_lo:
continue
Expand Down
17 changes: 9 additions & 8 deletions dascore/core/spool.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from collections.abc import Callable, Generator, Sequence
from functools import singledispatch
from pathlib import Path
from typing import ClassVar, Literal, TypeVar
from typing import TYPE_CHECKING, ClassVar, Literal, TypeVar

import numpy as np
import pandas as pd
Expand Down Expand Up @@ -44,6 +44,9 @@
)
from dascore.utils.paths import coerce_to_upath, requires_local_directory

if TYPE_CHECKING:
from dascore.io.index.catalog import PatchCatalog

T = TypeVar("T")


Expand Down Expand Up @@ -458,8 +461,8 @@ class Spool(BaseSpool):
# synthetic catalog identity columns must not join patch kwargs
# comparisons or chunk merge-compatibility checks
_drop_columns = ("patch", "path", "file_format", "file_version", "source_patch_id")
# The catalog backing this spool.
_catalog = None
# The catalog backing this spool; every construction path sets one.
_catalog: PatchCatalog
# single-file provenance (set by from_file; drives update())
_file_path = None
_file_format = None
Expand Down Expand Up @@ -539,7 +542,6 @@ def __getitem__(self, item) -> PatchType | BaseSpool:
def __iter__(self):
# The catalog snapshots the relation once and skips patches which
# cannot be resolved (see #583).
assert self._catalog is not None # __init__ always sets the catalog
yield from self._catalog

# --- selection and presentation specs -------------------------------
Expand Down Expand Up @@ -915,7 +917,7 @@ def from_file(
@property
def indexer(self):
"""The directory syncer, or None for non-directory spools."""
return None if self._catalog is None else self._catalog._syncer
return self._catalog._syncer

@property
def spool_path(self):
Expand All @@ -930,8 +932,7 @@ def spool_path(self):
@property
def has_live_patches(self) -> bool:
"""True when any of this spool's patches live in memory."""
catalog = self._catalog
return catalog is not None and bool(catalog.resolver.live_entries())
return bool(self._catalog.resolver.live_entries())

@compose_docstring(doc=BaseSpool.update.__doc__)
def update(self, progress: PROGRESS_LEVELS = "standard") -> Self:
Expand All @@ -954,7 +955,7 @@ def update(self, progress: PROGRESS_LEVELS = "standard") -> Self:
"Update the root spool and re-apply the operations, e.g. "
"root = root.update(); view = root.select(...)."
)
if catalog is None or catalog.is_view:
if catalog.is_view:
raise InvalidSpoolError(derived_msg)
if catalog._syncer is not None:
catalog.update(progress=progress)
Expand Down
53 changes: 42 additions & 11 deletions dascore/io/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@
import inspect
import warnings
from collections import defaultdict
from collections.abc import Generator, Mapping
from collections.abc import Callable, Generator, Mapping
from functools import cached_property, wraps
from numbers import Integral
from pathlib import Path
from threading import RLock
from typing import Any, Literal, NotRequired, TypedDict, get_type_hints
from typing import (
Any,
Literal,
NotRequired,
Protocol,
TypedDict,
cast,
get_type_hints,
)

import numpy as np
import pandas as pd
Expand Down Expand Up @@ -713,7 +721,7 @@ def _get_format(
# may happen in each fiber_ios get_format method, many of which
# may be third party code.
func = fiber_io.get_format
required_type = func._required_type
required_type = _required_resource_type(func)
func_input = None
try:
# Get resource has to be in the try block because it can also
Expand Down Expand Up @@ -752,6 +760,27 @@ def _get_input_type_name(self, obj):
# ------------- Protocol for File Format support


class _TypeCasterMethod(Protocol):
"""
A FiberIO method wrapped by the type caster.

The caster stamps these markers onto the wrapped method so the io
machinery can find the original function and the resource type the
method wants its input coerced to.
"""

func: Callable
_type_caster_wrapped: bool
_required_type: type | None

def __call__(self, *args, **kwargs): ...


def _required_resource_type(method) -> type | None:
"""Return the resource type a FiberIO method's caster coerces its input to."""
return cast(_TypeCasterMethod, method)._required_type


def _type_caster(func, sig, required_type, arg_name):
"""A decorator for casting types for arguments of cast ind."""
fun_name = func.__name__
Expand Down Expand Up @@ -791,14 +820,15 @@ def _wrapper(*args, _pre_cast=False, **kwargs):
return out

# attach the function and required type for later use
_wrapper.func = func
caster = cast(_TypeCasterMethod, _wrapper)
caster.func = func
# subclasses of FIBERIO subclasses can wrap this twice, so we mark
# it to avoid that scenario.
_wrapper._type_caster_wrapped = True
caster._type_caster_wrapped = True
# also specify required type
_wrapper._required_type = required_type
caster._required_type = required_type

return _wrapper
return caster


def _is_wrapped_func(func1, func2):
Expand Down Expand Up @@ -979,8 +1009,9 @@ def __init_subclass__(cls, **kwargs):
msg = "You must specify the file format with the name field."
raise InvalidFiberIOError(msg)
# register fiber_io
manager: _FiberIOManager = cls.__mro__[1].manager
manager.register_fiberio(cls())
parent = cls.__mro__[1]
assert issubclass(parent, FiberIO) # only FiberIO subclasses get here
parent.manager.register_fiberio(cls())
# decorate methods for type-casting
for name, param_ind in cls._automatic_type_casters.items():
method = getattr(cls, name)
Expand Down Expand Up @@ -1059,7 +1090,7 @@ def read(
fiber_io = FiberIO.manager.get_fiberio(
format=file_format, version=file_version
)
required_type = fiber_io.read._required_type
required_type = _required_resource_type(fiber_io.read)
path = man.get_resource(required_type)
out = fiber_io.read(
path,
Expand Down Expand Up @@ -1667,7 +1698,7 @@ def write(
patch_or_spool = _maybe_split_gapped_patches(patch_or_spool, fiber_io, split)
with IOResourceManager(path) as man:
func = fiber_io.write
required_type = func._required_type
required_type = _required_resource_type(func)
resource = man.get_resource(required_type)
func(patch_or_spool, resource, _pre_cast=True, **kwargs)
return path
Loading
Loading