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
16 changes: 16 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ codecov:
comment:
after_n_builds: 7

# Require 100% coverage on the unittests flag.
coverage:
status:
project:
default:
target: 100%
threshold: 0%
flags:
- unittests
patch:
default:
target: 100%
threshold: 0%
flags:
- unittests

flags:
unittests:
carryforward: false
Expand Down
4 changes: 2 additions & 2 deletions dascore/io/prodml/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,8 @@ def _round_times_to_microseconds(coord):
if not np.issubdtype(values.dtype, np.datetime64) or len(values) < 2:
msg = "ProdML writing requires at least two absolute time samples."
raise PatchError(msg)
if np.any(np.isnat(values)):
raise PatchError("ProdML time coordinates cannot contain NaT.")
# A NaT could not survive _get_single_patch's require_evenly_sampled check
# (uneven spacing), so it never reaches here and needs no explicit guard.
microsecond_values = values.astype("datetime64[us]")
if np.array_equal(microsecond_values.astype(values.dtype), values):
microseconds = microsecond_values.astype(np.int64)
Expand Down
12 changes: 12 additions & 0 deletions tests/test_io/test_index/test_planned.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ def test_coord_record_zero_step_length(self):
record = _coord_record_from_row(row, "time")
assert record.length is None

def test_coord_record_empty_units_dropped(self):
"""An empty-string units cell normalizes to None (a real step keeps length)."""
row = {
"distance_min": 0.0,
"distance_max": 10.0,
"distance_step": 1.0,
"_distance_units": "",
}
record = _coord_record_from_row(row, "distance")
assert record.units is None
assert record.length == 11

def test_plan_resolver_requires_output_id(self):
"""member_rows without output_id is a construction error."""
with pytest.raises(ValueError, match="output_id"):
Expand Down
45 changes: 45 additions & 0 deletions tests/test_io/test_indexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,51 @@ def test_get_reads_fresh_each_call(self, tmp_path):
cache_path.write_text(json.dumps({"a": "1", "b": "2"}))
assert _get_index_map(str(cache_path)) == {"a": "1", "b": "2"}

def test_failed_swap_cleans_up_temp(self, tmp_path, monkeypatch):
"""A failure during the atomic swap unlinks the temp file and re-raises."""
from dascore.io.index import indexer as indexer_mod

cache_path = tmp_path / "cache_paths.json"

def boom(*args, **kwargs):
raise RuntimeError("swap failed")

monkeypatch.setattr(indexer_mod.os, "replace", boom)
with pytest.raises(RuntimeError, match="swap failed"):
indexer_mod._update_index_map({"a": "1"}, cache_path=str(cache_path))
# No temp debris and no half-written target left behind.
assert list(tmp_path.iterdir()) == []


class TestWalkResilience:
"""Tests for the filesystem walk tolerating concurrent changes."""

def test_walk_skips_file_removed_mid_scan(self, tmp_path, monkeypatch):
"""A file vanishing between the walk and its stat is skipped, not fatal."""
from dascore.io.index import indexer as indexer_mod

good = tmp_path / "good.h5"
good.write_bytes(b"")
# Never created: models a file deleted between the walk yielding it and
# _walk's stat() call, so its real stat() raises FileNotFoundError.
vanished = tmp_path / "vanished.h5"
indexer = DBDirectoryIndexer(tmp_path)

def fake_iter(*args, **kwargs):
# Deterministically feed _walk both candidates, independent of the
# real filesystem, so the stat guard is exercised on the vanished one.
yield good
yield vanished

monkeypatch.setattr(indexer_mod, "_iter_filesystem", fake_iter)
try:
walked = indexer._walk()
finally:
indexer.close()
names = {Path(entry[-1]).name for entry in walked.values()}
assert "good.h5" in names
assert "vanished.h5" not in names


class TestBasics:
"""Basic tests for indexer."""
Expand Down
13 changes: 13 additions & 0 deletions tests/test_io/test_prodml/test_prod_ml.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import dascore as dc
from dascore.core.coords import get_coord
from dascore.io.core import read
from dascore.io.prodml.utils import _get_prodml_version_str
from dascore.utils.downloader import fetch


Expand Down Expand Up @@ -107,3 +108,15 @@ def test_precision_of_time_array(self, quantx_v2_das_patch):
time = quantx_v2_das_patch.coords.get_array("time")
dtype = time.dtype
assert "[ns]" in str(dtype)


class TestVersionDetection:
"""Tests for the ProdML version fingerprint helper."""

def test_acquisition_without_expected_attrs(self, tmp_path):
"""An Acquisition group lacking the fingerprint attrs is not ProdML."""
path = tmp_path / "not_prodml.h5"
with h5py.File(path, "w") as file:
file.create_group("Acquisition").attrs["unrelated"] = "x"
with h5py.File(path, "r") as file:
assert _get_prodml_version_str(file) == ""
15 changes: 15 additions & 0 deletions tests/test_io/test_prodml/test_prodml_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,21 @@ def test_nat_time(self, prodml_patch, tmp_path):
with pytest.raises(PatchError, match=r"NaT|time"):
dc.write(patch, tmp_path / "nat.h5", "PRODML")

def test_relative_time_rejected(self, prodml_patch, tmp_path):
"""A relative (non-absolute) time coordinate has no PRODML representation."""
length = len(prodml_patch.get_coord("time"))
relative = dc.get_coord(data=np.arange(length) * 1.0, units="s")
patch = prodml_patch.new(coords=prodml_patch.coords.update(time=relative))
with pytest.raises(PatchError, match="two absolute time samples"):
dc.write(patch, tmp_path / "relative_time.h5", "PRODML")

def test_nonpositive_optional_measure_ignored(self, prodml_patch, tmp_path):
"""A non-positive optional measure is dropped rather than written."""
patch = prodml_patch.update_attrs(pulse_width=0.0, pulse_width_units="ns")
path = dc.write(patch, tmp_path / "nonpositive_measure.h5", "PRODML")
with h5py.File(path, "r") as file:
assert "PulseWidth" not in file["Acquisition"].attrs

def test_time_irregular_after_rounding(self, prodml_patch, tmp_path):
"""Uniform nanoseconds that round to irregular microseconds are invalid."""
base = np.datetime64("2020-01-01", "ns")
Expand Down
8 changes: 8 additions & 0 deletions tests/test_utils/test_io_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from dascore.utils.remote_io import (
_FallbackFileObj,
_get_cached_local_file,
_warn_remote_cache_download,
clear_remote_file_cache,
get_remote_cache_path,
get_remote_cache_scope,
Expand Down Expand Up @@ -849,6 +850,13 @@ def test_remote_cache_scope_restores_previous_value_after_exception(self):
raise RuntimeError("boom")
assert get_remote_cache_scope() == "default"

def test_metadata_scope_download_warning_guidance(self, tmp_path):
"""Metadata scope yields metadata-specific download-warning guidance."""
resource = UPath("memory://dascore/metadata_warning.txt")
with remote_cache_scope("metadata"):
with pytest.warns(UserWarning, match="allow_remote_cache_for_metadata"):
_warn_remote_cache_download(resource, tmp_path / "metadata_warning.txt")


class TestRemoteIOFallback:
"""Tests for remote fallback helpers."""
Expand Down
11 changes: 11 additions & 0 deletions tests/test_utils/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import os
import time
import warnings
from io import BytesIO
from pathlib import Path

Expand Down Expand Up @@ -571,6 +572,16 @@ def test_nothing(self):
warn_or_raise(msg, behavior=None)


class TestSuppressWarnings:
"""Tests for the suppress_warnings context manager."""

def test_message_filter_applies_action(self):
"""A message pattern applies the action to matching warnings."""
with suppress_warnings(message="boom", action="error"):
with pytest.raises(UserWarning, match="boom"):
warnings.warn("boom", UserWarning)
Comment on lines +578 to +582

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add an explicit stacklevel to satisfy Ruff B028.

-                warnings.warn("boom", UserWarning)
+                warnings.warn("boom", UserWarning, stacklevel=2)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_message_filter_applies_action(self):
"""A message pattern applies the action to matching warnings."""
with suppress_warnings(message="boom", action="error"):
with pytest.raises(UserWarning, match="boom"):
warnings.warn("boom", UserWarning)
def test_message_filter_applies_action(self):
"""A message pattern applies the action to matching warnings."""
with suppress_warnings(message="boom", action="error"):
with pytest.raises(UserWarning, match="boom"):
warnings.warn("boom", UserWarning, stacklevel=2)
🧰 Tools
🪛 Ruff (0.15.21)

[warning] 582-582: No explicit stacklevel keyword argument found

Set stacklevel=2

(B028)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_utils/test_misc.py` around lines 578 - 582, Update
test_message_filter_applies_action to pass an explicit stacklevel argument to
warnings.warn, using the appropriate positive level while preserving the
existing UserWarning and message matching behavior.

Source: Linters/SAST tools



class TestToObjectArray:
"""Tests for converting a sequence of objects to an object array."""

Expand Down
Loading