From 4122f39ac27059e1d8242985c1ca892a6590410b Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Thu, 7 May 2026 14:37:16 +0200 Subject: [PATCH 1/6] improve testing, fix several small bugs --- .github/workflows/lint.yml | 8 +- .github/workflows/runtests.yml | 2 +- .github/workflows/upload_pypi.yml | 62 +++++++- README.md | 22 ++- pyproject.toml | 28 ++-- src/unidas.py | 103 +++++++++---- test/test_unidas.py | 240 +++++++++++++++++++++++++++++- 7 files changed, 409 insertions(+), 56 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fe8551d..48cda8b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,4 @@ -# Lint the code using the defined pre-commits +# Lint the code using the configured prek hooks name: LintCode on: [push] @@ -20,7 +20,7 @@ jobs: python-version: '3.12' - name: install linting packages - run: uv tool install pre-commit + run: uv tool install prek - - name: run all precommits - run: uv tool run pre-commit run --all + - name: run all prek hooks + run: uv tool run prek run --all-files diff --git a/.github/workflows/runtests.yml b/.github/workflows/runtests.yml index dd3731a..e5ca4f6 100644 --- a/.github/workflows/runtests.yml +++ b/.github/workflows/runtests.yml @@ -26,7 +26,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.10', '3.11', "3.12"] + python-version: ["3.12", "3.13", "3.14"] # only run if CI isn't turned off if: github.event_name == 'push' || !contains(github.event.pull_request.labels.*.name, 'no_ci') diff --git a/.github/workflows/upload_pypi.yml b/.github/workflows/upload_pypi.yml index 258953b..ed36e29 100644 --- a/.github/workflows/upload_pypi.yml +++ b/.github/workflows/upload_pypi.yml @@ -1,25 +1,77 @@ -# Upload to PyPI when new code lands in main. -name: PublishPackage +# Tag, release, and upload to PyPI when the package version changes on main. +name: ReleasePackage on: push: branches: - - main + - main jobs: - upload: + release: runs-on: ubuntu-latest environment: pypi permissions: + contents: write # This must be enabled for trusted publishing. id-token: write steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - name: Install uv uses: astral-sh/setup-uv@v3 - - name: build and publish + - name: Detect release version + id: detect + shell: bash + run: | + if ! git diff --quiet "${{ github.event.before }}" "${{ github.sha }}" -- src/unidas.py; then + old_version=$(git show "${{ github.event.before }}:src/unidas.py" | python -c 'import re, sys; text = sys.stdin.read(); match = re.search(r"^__version__ = \"([^\"]+)\"", text, re.MULTILINE); print(match.group(1) if match else "")') + new_version=$(python -c 'import re; from pathlib import Path; text = Path("src/unidas.py").read_text(); match = re.search(r"^__version__ = \"([^\"]+)\"", text, re.MULTILINE); print(match.group(1) if match else "")') + else + old_version="" + new_version="" + fi + + if [ -z "$new_version" ] || [ "$old_version" = "$new_version" ]; then + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if ! [[ "$new_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Version must be stable SemVer (X.Y.Z); got $new_version" >&2 + exit 1 + fi + + tag="v$new_version" + if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "version=$new_version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Build and publish + if: steps.detect.outputs.should_release == 'true' shell: bash -l {0} run: | uv build uv publish --trusted-publishing always + + - name: Create tag + if: steps.detect.outputs.should_release == 'true' + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag "${{ steps.detect.outputs.tag }}" + git push origin "${{ steps.detect.outputs.tag }}" + + - name: Create GitHub release + if: steps.detect.outputs.should_release == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "${{ steps.detect.outputs.tag }}" --generate-notes diff --git a/README.md b/README.md index f1222c1..82657d1 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ out = daspy_function(patch) assert isinstance(out, dc.Patch) ``` -You can also use `adpater` to wrap un-wrapped functions. +You can also use `adapter` to wrap un-wrapped functions. ```python import dascore as dc @@ -61,7 +61,7 @@ sec_out = unidas.convert(blast, to='daspy.Section') ``` ## Installation -Simply install unidas with pip or mamba: +Unidas requires Python 3.12 or newer. Simply install unidas with pip or mamba: ```bash pip install unidas @@ -73,6 +73,18 @@ mamba install unidas By design, unidas has no hard dependencies other than numpy, but an `ImportError` will be raised if the libraries needed to perform a requested conversion are not installed. +To install the supported DAS libraries with unidas: + +```bash +pip install "unidas[extras]" +``` + +For development and testing: + +```bash +pip install "unidas[dev]" +``` + Unidas is single file (src/unidas.py) so it can also be vendored (copied directly into your project). If you do this, please consider sharing any improvements so the entire community can benefit. ## Guidance for package developers @@ -105,7 +117,7 @@ def fancy_machine_learning_function(sec): To add support for a new data structure/library, you need to do two things: 1. Create a subclass of `Converter` which has (at least) a conversion method to unidas' BaseDAS. -2. Add a conversion method to UnidasBasDASConverter to convert from unidas' BaseDAS back to your data structure. +2. Add a conversion method to UnidasBaseDASConverter to convert from unidas' BaseDAS back to your data structure. 3. Write a test in test/test_unidas.py (this is important for maintainability). Feel free to open a discussion if you need help. @@ -116,3 +128,7 @@ Feel free to open a discussion if you need help. - [DASPy](https://github.com/HMZ-03/DASPy) - [Lightguide](https://github.com/pyrocko/lightguide) - [Xdas](https://github.com/xdas-dev/xdas) + +## Compatibility notes + +DASPy sections require `time` and `distance` coordinates, evenly sampled coordinates, and an absolute datetime time coordinate. DASCore or XDAS objects with relative, numeric, or uneven time/distance coordinates may still convert to other formats, but will raise a `ValueError` when converting to `daspy.Section`. diff --git a/pyproject.toml b/pyproject.toml index 6144de9..46c8221 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,18 +2,24 @@ # --- Build system configuration [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["setuptools>=80"] +build-backend = "setuptools.build_meta" -[tool.hatch.build.targets.wheel] -packages = ["src/unidas.py"] +[tool.setuptools] +include-package-data = true +py-modules = ["unidas"] + +[tool.setuptools.package-dir] +"" = "src" + +[tool.setuptools.dynamic] +version = {attr = "unidas.__version__"} # --- Project Metadata [project] name = "unidas" - -version = "0.0.1" # Make sure to bump dascore.__version__ as well! +dynamic = ["version"] authors = [ { name="Derrick Chambers", email="chambers.ja.derrick@gmail.com" }, @@ -21,7 +27,7 @@ authors = [ description = "A DAS compatibility library" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.12" classifiers = [ "License :: OSI Approved :: MIT License", "Topic :: Scientific/Engineering", @@ -46,7 +52,7 @@ extras = [ ] test = [ - "pre-commit", + "prek", "pytest", "ruff", "pooch", @@ -58,9 +64,9 @@ dev = ["unidas[test]", "unidas[extras]"] # --- URLs for project [project.urls] -"Bug Tracker" = "https://github.com/unidas-dev/unidas" -"Documentation" = "https://github.com/unidas-dev/unidas" -"Homepage" = "https://github.com/unidas-dev/unidas" +"Bug Tracker" = "https://github.com/dasdae/unidas/issues" +"Documentation" = "https://github.com/dasdae/unidas" +"Homepage" = "https://github.com/dasdae/unidas" # --- External tool configuration diff --git a/src/unidas.py b/src/unidas.py index f845ebd..585603e 100644 --- a/src/unidas.py +++ b/src/unidas.py @@ -4,14 +4,6 @@ from __future__ import annotations -# Unidas version indicator. When incrementing, be sure to update -# pyproject.toml as well. -__version__ = "0.0.1" - -# Explicitly defines unidas' public API. -# https://peps.python.org/pep-0008/#public-and-internal-interfaces -__all__ = ("adapter", "convert") - import datetime import importlib import inspect @@ -25,6 +17,13 @@ import numpy as np +# Explicitly defines unidas' public API. +__all__ = ("adapter", "convert") + +# Keep the version hardcoded so vendored copies report their own version +# without requiring installed package metadata. +__version__ = "0.1.0" + # Define the urls to each project to provide helpful error messages. PROJECT_URLS = { "dascore": "https://github.com/dasdae/dascore", @@ -127,11 +126,15 @@ def time_to_float(obj): def time_to_datetime(obj): """Convert a time-like object to a datetime object.""" - if isinstance(obj, np.datetime64) and DT_PRECISION > 1e-9: - # On python 3.10 this can fail since the default time precision is - # for datetime.datetime is us not ns. Need to truncate to us precision. - # TODO: need to look into daspy's DASUTC to see if it can handle ns. - obj = obj.astype("datetime64[us]") + if isinstance(obj, np.datetime64): + if DT_PRECISION > 1e-9: + # On python 3.10 this can fail since the default time precision is + # for datetime.datetime is us not ns. Need to truncate to us precision. + # TODO: need to look into daspy's DASUTC to see if it can handle ns. + obj = obj.astype("datetime64[us]") + elif isinstance(obj, np.timedelta64) or not isinstance(obj, datetime.datetime): + msg = "DASPy conversion requires an absolute datetime time coordinate." + raise ValueError(msg) if not isinstance(obj, datetime.datetime): # Lightguide expects a timezone to be attached, so we attach utc. utc = zoneinfo.ZoneInfo("UTC") @@ -182,6 +185,14 @@ def to_xdas_coord(self): """Method to convert to xdas coordinate.""" raise NotImplementedError(f"Not implemented for {self.__class__}") + def get_step(self): + """Return the coordinate step when it is well-defined.""" + raise NotImplementedError(f"Not implemented for {self.__class__}") + + def get_start(self): + """Return the first coordinate value.""" + raise NotImplementedError(f"Not implemented for {self.__class__}") + @dataclass class EvenlySampledCoordinate(Coordinate): @@ -239,12 +250,21 @@ def to_xdas_coord(self): if isinstance(self.tie_values[0], datetime.datetime): tie_values = [np.datetime64(to_stripped_utc(x)) for x in tie_values] data = {"tie_indices": self.tie_indices, "tie_values": tie_values} - out = xcoords.InterpCoordinate(data=data) + dim = self.dims[0] if len(self.dims) == 1 else None + out = xcoords.InterpCoordinate(data=data, dim=dim) return out def __len__(self): return self.tie_indices[-1] - self.tie_indices[0] + 1 + def get_step(self): + """Return the coordinate step.""" + return self.step + + def get_start(self): + """Return the first coordinate value.""" + return self.tie_values[0] + @dataclass class ArrayCoordinate(Coordinate): @@ -270,7 +290,27 @@ class ArrayCoordinate(Coordinate): def to_dascore_coord(self): """Convert to a dascore coordinate.""" dc_core = optional_import("dascore.core") - return dc_core.get_coord(**self.to_dict()) + return dc_core.get_coord(data=self.data, units=self.units) + + def to_xdas_coord(self): + """Convert to an XDAS coordinate.""" + xcoords = optional_import("xdas.core.coordinates") + dim = self.dims[0] if len(self.dims) == 1 else None + return xcoords.DenseCoordinate(data=self.data, dim=dim) + + def get_step(self): + """Return the coordinate step when it is evenly sampled.""" + if len(self) == 1: + return 1 + diff = np.diff(self.data) + if np.all(diff == diff[0]): + return diff[0] + msg = "Array coordinates must be evenly sampled to convert to DASPy." + raise ValueError(msg) + + def get_start(self): + """Return the first coordinate value.""" + return self.data[0] def __len__(self): return len(self.data) @@ -473,13 +513,14 @@ def to_daspy_section(self, base_das: BaseDAS): daspy = optional_import("daspy") dasdt = daspy.DASDateTime out = base_das.transpose("time", "distance").to_dict(flavor="simple") - time, dist = out["coords"]["time"], out["coords"]["distance"] - start_time = time_to_datetime(time["tie_values"][0]) + time_coord = base_das.coords["time"] + dist_coord = base_das.coords["distance"] + start_time = time_to_datetime(time_coord.get_start()) section = daspy.Section( - data=base_das.data, - fs=1 / time_to_float(time["step"]), # This is sampling rate in Hz - dx=dist["step"], - start_distance=dist["tie_values"][0], + data=out["data"].T, + fs=1 / time_to_float(time_coord.get_step()), + dx=dist_coord.get_step(), + start_distance=dist_coord.get_start(), start_time=dasdt.from_datetime(start_time), **out["attrs"], ) @@ -526,7 +567,7 @@ def _to_base_coords(self, coord, dims): step=coord.step, ) else: - return ArrayCoordinate(array=coord.array, units=coord.units) + return ArrayCoordinate(data=coord.data, units=coord.units, dims=dims) @converts_to("unidas.BaseDAS") def to_base(self, patch) -> BaseDAS: @@ -644,17 +685,17 @@ def _to_base_coords(self, data_array): coords_out = {} for name, coord in coords.items(): dims = (coord.dim,) if isinstance(coord.dim, str) else (name,) - # Other libraries handle gaps differently. For now, we raise if - # there are any gaps, which I interpret as more than 2 tie values. - # Need to double check that this is right. - if len(coord.tie_values) > 2: - msg = ( - "Tie values of xdas coordinates imply gaps, cant convert to " - "other formats" - ) - raise NotImplementedError(msg) # It seems the InterpCoordinate is evenly sampled, monotonic. if isinstance(coord, xcoords.InterpCoordinate): + # Other libraries handle gaps differently. For now, we raise if + # there are any gaps, which I interpret as more than 2 tie values. + # Need to double check that this is right. + if len(coord.tie_values) > 2: + msg = ( + "Tie values of xdas coordinates imply gaps, cant convert to " + "other formats" + ) + raise NotImplementedError(msg) step = xcoords.get_sampling_interval( da=data_array, dim=name, cast=False ) diff --git a/test/test_unidas.py b/test/test_unidas.py index 59193cc..51ff6c1 100644 --- a/test/test_unidas.py +++ b/test/test_unidas.py @@ -2,6 +2,7 @@ Tests for core functionality of unidas. """ +import datetime import platform import dascore as dc @@ -10,7 +11,8 @@ import pandas as pd import pytest import unidas -from unidas import BaseDAS, adapter, convert, optional_import +from dascore.examples import EXAMPLE_PATCHES +from unidas import BaseDAS, Converter, adapter, convert, optional_import from xdas.core.dataarray import DataArray try: @@ -32,11 +34,40 @@ class Blast: "lightguide.Blast": Blast, } BASE_FORMATS = tuple(NAME_CLASS_MAP) +DASCORE_EXAMPLE_NAMES = tuple(EXAMPLE_PATCHES) +DASPY_COMPATIBLE_DASCORE_EXAMPLES = ( + "random_das", + "patch_with_null", + "random_patch_with_lat_lon", + "random_patch_with_xyz", + "sin_wav", + "chirp", + "example_event_1", + "deformation_rate_event_1", + "dispersion_event", +) +DASPY_UNSUPPORTED_DASCORE_EXAMPLES = { + "wacky_dim_coords_patch": "evenly sampled", + "example_event_2": "absolute datetime", + "forge_dss": "evenly sampled", + "forge_dts": "evenly sampled", + "ricker_moveout": "absolute datetime", +} # --- Tests for unidas utilities. +def assert_array_equal_with_nan(array_1, array_2): + """Assert arrays are equal, treating floating NaNs as equal.""" + array_1 = np.asarray(array_1) + array_2 = np.asarray(array_2) + if np.issubdtype(array_1.dtype, np.floating): + assert np.allclose(array_1, array_2, equal_nan=True) + else: + assert np.array_equal(array_1, array_2) + + @pytest.fixture(params=BASE_FORMATS) def format_name(request): """Fixture for returning format names.""" @@ -70,6 +101,55 @@ def test_version(self): assert hasattr(unidas, "__version__") assert isinstance(unidas.__version__, str) + def test_time_to_float_datetime(self): + """Ensure Python datetimes convert to timestamps.""" + time = datetime.datetime(1970, 1, 1, 0, 0, 1, tzinfo=datetime.UTC) + + out = unidas.time_to_float(time) + + assert out == 1 + + +class TestCoordinate: + """Test suite for coordinate base behavior.""" + + def test_base_coordinate_methods_raise(self): + """Ensure base coordinate methods are abstract.""" + coord = unidas.Coordinate() + msg = "Not implemented" + + with pytest.raises(NotImplementedError, match=msg): + coord.to_dascore_coord() + with pytest.raises(NotImplementedError, match=msg): + coord.to_xdas_coord() + with pytest.raises(NotImplementedError, match=msg): + coord.get_step() + with pytest.raises(NotImplementedError, match=msg): + coord.get_start() + + def test_evenly_sampled_coordinate_with_gaps_to_dascore_raises(self): + """Ensure gapped coordinates cannot convert to DASCore.""" + coord = unidas.EvenlySampledCoordinate( + tie_values=(0, 1, 3), + tie_indices=(0, 2, 4), + step=1, + dims=("distance",), + ) + + with pytest.raises(NotImplementedError, match="gaps"): + coord.to_dascore_coord() + + +class TestConverterBase: + """Test suite for converter base behavior.""" + + def test_subclass_without_name_raises(self): + """Ensure converter subclasses must define a name.""" + with pytest.raises(ValueError, match="must define a name"): + + class BadConverter(Converter): + """Converter missing a name.""" + # --------- Tests for unidas conversions. @@ -106,6 +186,130 @@ def test_convert_patch_to_other(self, dascore_patch, format_name): out = convert(dascore_patch, to=format_name) assert isinstance(out, NAME_CLASS_MAP[format_name]) + @pytest.mark.parametrize("example_name", DASCORE_EXAMPLE_NAMES) + def test_example_patch_to_base_das(self, example_name): + """Ensure all DASCore generated examples convert to BaseDAS.""" + patch = dc.get_example_patch(example_name) + + out = convert(patch, to="unidas.BaseDAS") + + assert isinstance(out, BaseDAS) + assert out.dims == patch.dims + assert out.data.shape == patch.shape + out.validate() + + @pytest.mark.parametrize("example_name", DASCORE_EXAMPLE_NAMES) + def test_example_patch_round_trip_to_dascore(self, example_name): + """Ensure all DASCore generated examples round-trip through BaseDAS.""" + patch = dc.get_example_patch(example_name) + base = convert(patch, to="unidas.BaseDAS") + + out = convert(base, to="dascore.Patch") + + assert isinstance(out, dc.Patch) + assert out.dims == patch.dims + assert out.shape == patch.shape + assert_array_equal_with_nan(out.data, patch.data) + assert set(out.coords.coord_map) == set(patch.coords.coord_map) + for coord_name in patch.coords.coord_map: + assert out.coords.dim_map[coord_name] == patch.coords.dim_map[coord_name] + assert_array_equal_with_nan( + out.get_array(coord_name), + patch.get_array(coord_name), + ) + + @pytest.mark.parametrize("example_name", DASCORE_EXAMPLE_NAMES) + def test_example_patch_to_xdas_dataarray(self, example_name): + """Ensure all DASCore generated examples convert to XDAS.""" + patch = dc.get_example_patch(example_name) + + out = convert(patch, to="xdas.DataArray") + + assert isinstance(out, DataArray) + assert out.dims == patch.dims + assert out.shape == patch.shape + assert_array_equal_with_nan(out.data, patch.data) + assert set(out.coords) == set(patch.coords.coord_map) + for coord_name in patch.coords.coord_map: + assert out.coords[coord_name].dim == patch.coords.dim_map[coord_name][0] + assert_array_equal_with_nan( + out.coords[coord_name].values, + patch.get_array(coord_name), + ) + + def test_time_distance_patch_to_daspy_section_shape(self, dascore_patch): + """Ensure DASPy sections always use channel/time data order.""" + patch = dascore_patch.transpose("time", "distance") + + out = convert(patch, to="daspy.Section") + + assert isinstance(out, daspy.Section) + assert out.data.shape == dascore_patch.shape + + @pytest.mark.parametrize("example_name", DASPY_COMPATIBLE_DASCORE_EXAMPLES) + def test_example_patch_to_daspy_section(self, example_name): + """Ensure DASCore example patches can convert to DASPy sections.""" + patch = dc.get_example_patch(example_name) + expected_shape = patch.transpose("distance", "time").shape + + out = convert(patch, to="daspy.Section") + + assert isinstance(out, daspy.Section) + assert out.data.shape == expected_shape + + @pytest.mark.parametrize( + ("example_name", "message"), + DASPY_UNSUPPORTED_DASCORE_EXAMPLES.items(), + ) + def test_unsupported_example_patch_to_daspy_section(self, example_name, message): + """Ensure unsupported DASCore examples fail with expected errors.""" + patch = dc.get_example_patch(example_name) + + with pytest.raises(ValueError, match=message): + convert(patch, to="daspy.Section") + + def test_single_distance_patch_to_daspy_section(self): + """Ensure singleton distance coordinates default to dx=1.""" + time = dc.to_datetime64("2020-01-01") + dc.to_timedelta64(np.arange(10)) + patch = dc.Patch( + data=np.zeros((1, 10)), + coords={"distance": [0], "time": time}, + dims=("distance", "time"), + ) + + out = convert(patch, to="daspy.Section") + + assert isinstance(out, daspy.Section) + assert out.data.shape == patch.shape + assert out.dx == 1 + + def test_array_coordinates_to_daspy_section(self): + """Ensure evenly sampled array coordinates can convert to DASPy.""" + time = dc.to_datetime64("2020-01-01") + dc.to_timedelta64(np.arange(4)) + distance = np.arange(3) * 2 + base_das = BaseDAS( + data=np.zeros((3, 4)), + coords={ + "distance": unidas.ArrayCoordinate( + data=distance, + dims=("distance",), + ), + "time": unidas.ArrayCoordinate( + data=time, + dims=("time",), + ), + }, + attrs={}, + dims=("distance", "time"), + ) + + out = convert(base_das, to="daspy.Section") + + assert isinstance(out, daspy.Section) + assert out.data.shape == base_das.data.shape + assert out.dx == 2 + assert out.fs == 1 + class TestDASPySection: """Test suite for converting DASPy sections.""" @@ -162,6 +366,40 @@ def test_from_base_das(self, xdas_base_das, xdas_dataarray): assert attr1 == attr2 or (not attr1 and not attr2) assert out.dims == xdas_dataarray.dims + def test_dense_coordinate_to_base_das(self): + """Ensure XDAS dense coordinates convert to array coordinates.""" + xdas = optional_import("xdas") + data_array = xdas.DataArray( + np.zeros((3, 2)), + coords={ + "time": [0, 1, 2], + "distance": [0, 1], + "quality": ("time", [1, 2, 3]), + }, + dims=("time", "distance"), + ) + + out = convert(data_array, to="unidas.BaseDAS") + + assert isinstance(out.coords["quality"], unidas.ArrayCoordinate) + assert out.coords["quality"].dims == ("time",) + assert np.array_equal(out.coords["quality"].data, [1, 2, 3]) + + def test_gapped_interp_coordinate_to_base_das_raises(self): + """Ensure gapped XDAS coordinates are rejected.""" + xdas = optional_import("xdas") + data_array = xdas.DataArray( + np.zeros((5, 2)), + coords={ + "time": {"tie_indices": [0, 2, 4], "tie_values": [0.0, 1.0, 3.0]}, + "distance": [0, 1], + }, + dims=("time", "distance"), + ) + + with pytest.raises(NotImplementedError, match="gaps"): + convert(data_array, to="unidas.BaseDAS") + class TestLightGuideBlast: """Tests for Blast Conversions.""" From 5bd777936d0317e2bb5bb7b2bb86ee455d371a0d Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Thu, 7 May 2026 16:16:24 +0200 Subject: [PATCH 2/6] Use public XDAS coordinate API --- src/unidas.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/unidas.py b/src/unidas.py index 585603e..0ede36e 100644 --- a/src/unidas.py +++ b/src/unidas.py @@ -242,7 +242,7 @@ def to_dascore_coord(self): def to_xdas_coord(self): """Convert to an XDAS coordinate.""" - xcoords = optional_import("xdas.core.coordinates") + xdas = optional_import("xdas") # Currently, xdas expects a number or numpy datatime, need to convert # python datetimes to numpy. tie_values = self.tie_values @@ -251,7 +251,7 @@ def to_xdas_coord(self): tie_values = [np.datetime64(to_stripped_utc(x)) for x in tie_values] data = {"tie_indices": self.tie_indices, "tie_values": tie_values} dim = self.dims[0] if len(self.dims) == 1 else None - out = xcoords.InterpCoordinate(data=data, dim=dim) + out = xdas.InterpCoordinate(data=data, dim=dim) return out def __len__(self): @@ -294,9 +294,9 @@ def to_dascore_coord(self): def to_xdas_coord(self): """Convert to an XDAS coordinate.""" - xcoords = optional_import("xdas.core.coordinates") + xdas = optional_import("xdas") dim = self.dims[0] if len(self.dims) == 1 else None - return xcoords.DenseCoordinate(data=self.data, dim=dim) + return xdas.DenseCoordinate(data=self.data, dim=dim) def get_step(self): """Return the coordinate step when it is evenly sampled.""" @@ -680,13 +680,13 @@ class XDASConverter(Converter): def _to_base_coords(self, data_array): """Convert the xdas coordinates to unidas coordinates.""" - xcoords = optional_import("xdas.core.coordinates") + xdas = optional_import("xdas") coords = data_array.coords coords_out = {} for name, coord in coords.items(): dims = (coord.dim,) if isinstance(coord.dim, str) else (name,) # It seems the InterpCoordinate is evenly sampled, monotonic. - if isinstance(coord, xcoords.InterpCoordinate): + if isinstance(coord, xdas.InterpCoordinate): # Other libraries handle gaps differently. For now, we raise if # there are any gaps, which I interpret as more than 2 tie values. # Need to double check that this is right. @@ -696,9 +696,7 @@ def _to_base_coords(self, data_array): "other formats" ) raise NotImplementedError(msg) - step = xcoords.get_sampling_interval( - da=data_array, dim=name, cast=False - ) + step = xdas.get_sampling_interval(da=data_array, dim=name, cast=False) ucoord = EvenlySampledCoordinate( tie_values=coord.tie_values, tie_indices=coord.tie_indices, From 35914e51055a7fe20137d062b5b75ec8a683c028 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Thu, 7 May 2026 16:21:11 +0200 Subject: [PATCH 3/6] Skip lightguide extra on Windows --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 46c8221..e690811 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ extras = [ "dascore", "daspy-toolbox", - "lightguide", + "lightguide; platform_system != 'Windows'", "xdas", "numpy<2" # This is due to pyrocko. Remove when it has a new release. ] From b5f798ecebda3a195438f5c525c19223f5852b17 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Thu, 7 May 2026 16:25:28 +0200 Subject: [PATCH 4/6] Handle naive datetimes without local conversion --- src/unidas.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/unidas.py b/src/unidas.py index 0ede36e..ff87072 100644 --- a/src/unidas.py +++ b/src/unidas.py @@ -138,7 +138,8 @@ def time_to_datetime(obj): if not isinstance(obj, datetime.datetime): # Lightguide expects a timezone to be attached, so we attach utc. utc = zoneinfo.ZoneInfo("UTC") - obj = datetime.datetime.fromisoformat(str(obj)).astimezone(utc) + obj = datetime.datetime.fromisoformat(str(obj)) + obj = obj.replace(tzinfo=utc) if obj.tzinfo is None else obj.astimezone(utc) return obj From 1ef453912d3a0accb45a096e196b40391892cd19 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Thu, 7 May 2026 16:35:48 +0200 Subject: [PATCH 5/6] Limit full extras CI to Python 3.12 --- .github/workflows/runtests.yml | 27 +++++++++++++++++++++++++-- pyproject.toml | 8 +++++--- test/conftest.py | 5 ++++- test/test_unidas.py | 21 +++++++++++++-------- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/.github/workflows/runtests.yml b/.github/workflows/runtests.yml index e5ca4f6..f9a3ecf 100644 --- a/.github/workflows/runtests.yml +++ b/.github/workflows/runtests.yml @@ -18,7 +18,8 @@ concurrency: cancel-in-progress: true jobs: - # Runs the tests on combinations of the supported python/os matrix. + # Runs the full optional-dependency integration suite on the supported + # Python/OS matrix. test_code: timeout-minutes: 25 @@ -26,7 +27,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.12", "3.13", "3.14"] + python-version: ["3.12"] # only run if CI isn't turned off if: github.event_name == 'push' || !contains(github.event.pull_request.labels.*.name, 'no_ci') @@ -57,6 +58,28 @@ jobs: name: PR_tests token: ${{ secrets.CODECOV_TOKEN }} + # Optional native dependencies lag new Python releases, so keep the broader + # Python matrix to a package import smoke test. + test_core_python: + + timeout-minutes: 10 + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.13", "3.14"] + + # only run if CI isn't turned off + if: github.event_name == 'push' || !contains(github.event.pull_request.labels.*.name, 'no_ci') + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: import package + run: uv run --python ${{ matrix.python-version }} python -c "import unidas; assert isinstance(unidas.__version__, str)" + # This is a very useful step for debugging, it allows you to ssh into the CI # machine (https://github.com/marketplace/actions/debugging-with-tmate). diff --git a/pyproject.toml b/pyproject.toml index e690811..c3b75f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,8 @@ keywords = ["geophysics", "distributed-acoustic-sensing"] # --- Dependencies dependencies = [ - "numpy" + "numpy>=1.26; python_version < '3.13'", + "numpy>=2; python_version >= '3.13'", ] [project.optional-dependencies] @@ -46,9 +47,10 @@ dependencies = [ extras = [ "dascore", "daspy-toolbox", - "lightguide; platform_system != 'Windows'", + "lightguide; platform_system != 'Windows' and python_version < '3.13'", "xdas", - "numpy<2" # This is due to pyrocko. Remove when it has a new release. + # This is due to pyrocko. Remove when it has a new release. + "numpy<2; python_version < '3.13'", ] test = [ diff --git a/test/conftest.py b/test/conftest.py index 5a0ef2b..323668f 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -2,6 +2,7 @@ Pytest configuration and global fixtures for unidas. """ +import importlib.util import platform import dascore as dc @@ -24,11 +25,13 @@ def daspy_section(): @pytest.fixture(scope="session") -# Currently, lightguide doesn't install on windows in CI. Just skip. +# Lightguide currently has platform and Python-version compatibility limits. def lightguide_blast(): """Get a Blast from lightguide.""" if platform.system().lower() == "windows": pytest.skip("Lightguide is not supported on Windows") + if importlib.util.find_spec("lightguide") is None: + pytest.skip("Lightguide is not installed") from lightguide.blast import Blast diff --git a/test/test_unidas.py b/test/test_unidas.py index 51ff6c1..91489d1 100644 --- a/test/test_unidas.py +++ b/test/test_unidas.py @@ -19,12 +19,17 @@ from lightguide.blast import Blast except ImportError: + LIGHTGUIDE_AVAILABLE = False class Blast: """A dummy blast.""" +else: + LIGHTGUIDE_AVAILABLE = True + ON_WINDOWS = platform.system().lower() == "windows" +LIGHTGUIDE_SUPPORTED = not ON_WINDOWS and LIGHTGUIDE_AVAILABLE # A tuple of format names for testing generic conversions. NAME_CLASS_MAP = { @@ -72,8 +77,8 @@ def assert_array_equal_with_nan(array_1, array_2): def format_name(request): """Fixture for returning format names.""" name = request.param - if ON_WINDOWS and name.startswith("lightguide"): - pytest.skip("waveguide does not support windows") + if name.startswith("lightguide") and not LIGHTGUIDE_SUPPORTED: + pytest.skip("Lightguide is not supported or installed") return request.param @@ -405,10 +410,10 @@ class TestLightGuideBlast: """Tests for Blast Conversions.""" @pytest.fixture(scope="class", autouse=True) - def skip_on_windows(self): - """Skip tests if on windows.""" - if ON_WINDOWS: - pytest.skip("Lightguide doesn't support windows") + def skip_if_unsupported(self): + """Skip tests if lightguide is unsupported or unavailable.""" + if not LIGHTGUIDE_SUPPORTED: + pytest.skip("Lightguide is not supported or installed") @pytest.fixture(scope="class") def lightguide_base_das(self, lightguide_blast): @@ -504,8 +509,8 @@ class TestIntegrations: def test_readme_1(self): """First test for readme examples.""" - if ON_WINDOWS: - pytest.skip("Lightguide doesn't support windows") + if not LIGHTGUIDE_SUPPORTED: + pytest.skip("Lightguide is not supported or installed") sec = daspy.read() blast = unidas.convert(sec, to="lightguide.Blast") blast.afk_filter(exponent=0.8) From 9bea408c9bfcb8419bd97c20c99791c4f16e5702 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Thu, 7 May 2026 17:09:07 +0200 Subject: [PATCH 6/6] Simplify release publishing and update support metadata --- .github/workflows/lint.yml | 8 ++-- .github/workflows/runtests.yml | 12 +++--- .github/workflows/upload_pypi.yml | 62 +++---------------------------- README.md | 10 ++++- pyproject.toml | 10 ++--- src/unidas.py | 17 ++------- test/test_unidas.py | 18 +++++++++ 7 files changed, 51 insertions(+), 86 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 48cda8b..0167426 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,14 +10,14 @@ jobs: if: github.event_name == 'push' || !contains(github.event.pull_request.labels.*.name, 'no_ci') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v8.1.0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6.2.0 with: - python-version: '3.12' + python-version: '3.11' - name: install linting packages run: uv tool install prek diff --git a/.github/workflows/runtests.yml b/.github/workflows/runtests.yml index f9a3ecf..0164437 100644 --- a/.github/workflows/runtests.yml +++ b/.github/workflows/runtests.yml @@ -27,7 +27,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.12"] + python-version: ["3.11", "3.12"] # only run if CI isn't turned off if: github.event_name == 'push' || !contains(github.event.pull_request.labels.*.name, 'no_ci') @@ -37,10 +37,10 @@ jobs: env_file: "environment.yml" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v8.1.0 - name: run pytest run: uv run --all-extras --python ${{ matrix.python-version }} pytest -s --cov src --cov-append --cov-report=xml @@ -50,7 +50,7 @@ jobs: run: uv run --all-extras --python ${{ matrix.python-version }} pytest src --doctest-modules # Upload coverage files - - uses: codecov/codecov-action@v5 + - uses: codecov/codecov-action@v6.0.0 with: fail_ci_if_error: false files: ./coverage.xml @@ -72,10 +72,10 @@ jobs: if: github.event_name == 'push' || !contains(github.event.pull_request.labels.*.name, 'no_ci') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v8.1.0 - name: import package run: uv run --python ${{ matrix.python-version }} python -c "import unidas; assert isinstance(unidas.__version__, str)" diff --git a/.github/workflows/upload_pypi.yml b/.github/workflows/upload_pypi.yml index ed36e29..79adb96 100644 --- a/.github/workflows/upload_pypi.yml +++ b/.github/workflows/upload_pypi.yml @@ -1,9 +1,8 @@ -# Tag, release, and upload to PyPI when the package version changes on main. +# Upload to PyPI when a GitHub release is published. name: ReleasePackage on: - push: - branches: - - main + release: + types: [published] jobs: release: @@ -14,64 +13,13 @@ jobs: # This must be enabled for trusted publishing. id-token: write steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true + - uses: actions/checkout@v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v3 - - - name: Detect release version - id: detect - shell: bash - run: | - if ! git diff --quiet "${{ github.event.before }}" "${{ github.sha }}" -- src/unidas.py; then - old_version=$(git show "${{ github.event.before }}:src/unidas.py" | python -c 'import re, sys; text = sys.stdin.read(); match = re.search(r"^__version__ = \"([^\"]+)\"", text, re.MULTILINE); print(match.group(1) if match else "")') - new_version=$(python -c 'import re; from pathlib import Path; text = Path("src/unidas.py").read_text(); match = re.search(r"^__version__ = \"([^\"]+)\"", text, re.MULTILINE); print(match.group(1) if match else "")') - else - old_version="" - new_version="" - fi - - if [ -z "$new_version" ] || [ "$old_version" = "$new_version" ]; then - echo "should_release=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if ! [[ "$new_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Version must be stable SemVer (X.Y.Z); got $new_version" >&2 - exit 1 - fi - - tag="v$new_version" - if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then - echo "should_release=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "should_release=true" >> "$GITHUB_OUTPUT" - echo "version=$new_version" >> "$GITHUB_OUTPUT" - echo "tag=$tag" >> "$GITHUB_OUTPUT" + uses: astral-sh/setup-uv@v8.1.0 - name: Build and publish - if: steps.detect.outputs.should_release == 'true' shell: bash -l {0} run: | uv build uv publish --trusted-publishing always - - - name: Create tag - if: steps.detect.outputs.should_release == 'true' - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag "${{ steps.detect.outputs.tag }}" - git push origin "${{ steps.detect.outputs.tag }}" - - - name: Create GitHub release - if: steps.detect.outputs.should_release == 'true' - env: - GH_TOKEN: ${{ github.token }} - run: gh release create "${{ steps.detect.outputs.tag }}" --generate-notes diff --git a/README.md b/README.md index 82657d1..5e6b50d 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ sec_out = unidas.convert(blast, to='daspy.Section') ``` ## Installation -Unidas requires Python 3.12 or newer. Simply install unidas with pip or mamba: +Unidas requires Python 3.11 or newer. Simply install unidas with pip or mamba: ```bash pip install unidas @@ -79,6 +79,10 @@ To install the supported DAS libraries with unidas: pip install "unidas[extras]" ``` +Some optional libraries lag new Python releases. The aggregate `unidas[extras]` +install currently targets Python 3.11 and 3.12; on Python 3.13 and newer, +install optional DAS libraries directly once they publish compatible wheels. + For development and testing: ```bash @@ -132,3 +136,7 @@ Feel free to open a discussion if you need help. ## Compatibility notes DASPy sections require `time` and `distance` coordinates, evenly sampled coordinates, and an absolute datetime time coordinate. DASCore or XDAS objects with relative, numeric, or uneven time/distance coordinates may still convert to other formats, but will raise a `ValueError` when converting to `daspy.Section`. + +## Making releases + +To publish a release, bump `__version__` in `src/unidas.py`, merge the change to `main`, create a version tag such as `v0.1.0`, then publish a GitHub Release from that tag. Publishing the GitHub Release triggers the PyPI upload workflow. diff --git a/pyproject.toml b/pyproject.toml index c3b75f5..b2a71ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,9 +27,9 @@ authors = [ description = "A DAS compatibility library" readme = "README.md" -requires-python = ">=3.12" +license = "MIT" +requires-python = ">=3.11" classifiers = [ - "License :: OSI Approved :: MIT License", "Topic :: Scientific/Engineering", ] @@ -45,10 +45,10 @@ dependencies = [ [project.optional-dependencies] extras = [ - "dascore", - "daspy-toolbox", + "dascore; python_version < '3.13'", + "daspy-toolbox; python_version < '3.13'", "lightguide; platform_system != 'Windows' and python_version < '3.13'", - "xdas", + "xdas; python_version < '3.13'", # This is due to pyrocko. Remove when it has a new release. "numpy<2; python_version < '3.13'", ] diff --git a/src/unidas.py b/src/unidas.py index ff87072..eb52603 100644 --- a/src/unidas.py +++ b/src/unidas.py @@ -32,9 +32,6 @@ "xdas": "https://github.com/xdas-dev/xdas", } -# Datetime precision. This can change between python versions. -DT_PRECISION = datetime.datetime.resolution.total_seconds() - # A generic type variable. T = TypeVar("T") @@ -127,19 +124,13 @@ def time_to_float(obj): def time_to_datetime(obj): """Convert a time-like object to a datetime object.""" if isinstance(obj, np.datetime64): - if DT_PRECISION > 1e-9: - # On python 3.10 this can fail since the default time precision is - # for datetime.datetime is us not ns. Need to truncate to us precision. - # TODO: need to look into daspy's DASUTC to see if it can handle ns. - obj = obj.astype("datetime64[us]") + obj = obj.astype("datetime64[us]").item() elif isinstance(obj, np.timedelta64) or not isinstance(obj, datetime.datetime): msg = "DASPy conversion requires an absolute datetime time coordinate." raise ValueError(msg) - if not isinstance(obj, datetime.datetime): - # Lightguide expects a timezone to be attached, so we attach utc. - utc = zoneinfo.ZoneInfo("UTC") - obj = datetime.datetime.fromisoformat(str(obj)) - obj = obj.replace(tzinfo=utc) if obj.tzinfo is None else obj.astimezone(utc) + # Lightguide expects a timezone to be attached, so attach UTC to naive values. + utc = zoneinfo.ZoneInfo("UTC") + obj = obj.replace(tzinfo=utc) if obj.tzinfo is None else obj.astimezone(utc) return obj diff --git a/test/test_unidas.py b/test/test_unidas.py index 91489d1..646b4c5 100644 --- a/test/test_unidas.py +++ b/test/test_unidas.py @@ -114,6 +114,24 @@ def test_time_to_float_datetime(self): assert out == 1 + def test_time_to_datetime_truncates_numpy_nanoseconds(self): + """Ensure numpy nanosecond datetimes convert to UTC Python datetimes.""" + time = np.datetime64("2020-01-01T00:00:00.123456789") + + out = unidas.time_to_datetime(time) + + expected = datetime.datetime( + 2020, + 1, + 1, + 0, + 0, + 0, + 123456, + tzinfo=datetime.UTC, + ) + assert out == expected + class TestCoordinate: """Test suite for coordinate base behavior."""