diff --git a/codecov.yml b/codecov.yml index 70d65c692..a425a208a 100644 --- a/codecov.yml +++ b/codecov.yml @@ -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 diff --git a/dascore/io/prodml/utils.py b/dascore/io/prodml/utils.py index 498baa881..c1cbf442a 100644 --- a/dascore/io/prodml/utils.py +++ b/dascore/io/prodml/utils.py @@ -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) diff --git a/tests/test_io/test_index/test_planned.py b/tests/test_io/test_index/test_planned.py index 163017bdc..fdc375ea1 100644 --- a/tests/test_io/test_index/test_planned.py +++ b/tests/test_io/test_index/test_planned.py @@ -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"): diff --git a/tests/test_io/test_indexer.py b/tests/test_io/test_indexer.py index 26304f702..6bf09165d 100644 --- a/tests/test_io/test_indexer.py +++ b/tests/test_io/test_indexer.py @@ -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.""" diff --git a/tests/test_io/test_prodml/test_prod_ml.py b/tests/test_io/test_prodml/test_prod_ml.py index 5e8ad374d..6cf6d84af 100644 --- a/tests/test_io/test_prodml/test_prod_ml.py +++ b/tests/test_io/test_prodml/test_prod_ml.py @@ -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 @@ -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) == "" diff --git a/tests/test_io/test_prodml/test_prodml_write.py b/tests/test_io/test_prodml/test_prodml_write.py index 1e87048e6..4da29aef7 100644 --- a/tests/test_io/test_prodml/test_prodml_write.py +++ b/tests/test_io/test_prodml/test_prodml_write.py @@ -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") diff --git a/tests/test_utils/test_io_utils.py b/tests/test_utils/test_io_utils.py index 2f80fe881..096ae4d17 100644 --- a/tests/test_utils/test_io_utils.py +++ b/tests/test_utils/test_io_utils.py @@ -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, @@ -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.""" diff --git a/tests/test_utils/test_misc.py b/tests/test_utils/test_misc.py index 7d87e8764..8bc23f04b 100644 --- a/tests/test_utils/test_misc.py +++ b/tests/test_utils/test_misc.py @@ -4,6 +4,7 @@ import os import time +import warnings from io import BytesIO from pathlib import Path @@ -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) + + class TestToObjectArray: """Tests for converting a sequence of objects to an object array."""