diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..6652d87 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,24 @@ +name: Test Suite + +on: + pull_request: + push: + branches: [main, develop] + workflow_dispatch: + +permissions: + contents: read + +jobs: + unit-tests: + name: Run Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.12" + - name: Install dependencies + run: uv sync --frozen --all-extras + - name: Run tests + run: uv run pytest tests -v diff --git a/CHANGELOG.md b/CHANGELOG.md index c06e818..7180e61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Store dataset properties as netCDF-safe individual attributes while keeping read compatibility with legacy `attrs["properties"]` dict/JSON data. [\#21](https://github.com/mlwp-tools/mxalign/pull/21) @observingClouds +- Added CI test workflow with first unit tests. [\#21](https://github.com/mlwp-tools/mxalign/pull/21) @observingClouds +- Added optional `ifs` dependency group with `cfgrib`, `eccodes`, and `eccodeslib`. [\#21](https://github.com/mlwp-tools/mxalign/pull/21) @observingClouds + ## [0.1.0](https://github.com/mlwp-tools/mxalign/releases/tag/v0.1.0) First release of `mxalign`, an xarray-based package for alignment of meteorological datasets, with the following functionality and configuration: diff --git a/pyproject.toml b/pyproject.toml index 1016cdd..edaa73b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,11 @@ mxalign = "mxalign.cli:main" earthkit = [ "earthkit-meteo>=0.6.1", ] +ifs = [ + "cfgrib>=0.9.15.1", + "eccodes>=2.45.0", + "eccodeslib>=2.46.2.19", +] verification = [ "xskillscore>=0.0.29", ] @@ -43,4 +48,5 @@ build-backend = "hatchling.build" [dependency-groups] dev = [ "ipykernel>=7.2.0", + "pytest>=8.0.0", ] diff --git a/src/mxalign/interpolations/delaunay.py b/src/mxalign/interpolations/delaunay.py index 164c255..88f2507 100644 --- a/src/mxalign/interpolations/delaunay.py +++ b/src/mxalign/interpolations/delaunay.py @@ -10,6 +10,7 @@ from .base import BaseInterpolator from .registry import register_interpolator from ..properties.properties import Space +from ..properties.utils import properties_from_attrs, set_properties_attrs @register_interpolator @@ -81,8 +82,7 @@ def _interpolate(self, source_dataset): latitude=self.target_dataset["latitude"], longitude=self.target_dataset["longitude"], ) - ds_out.attrs["properties"] = source_dataset.attrs["properties"] - return ds_out + return set_properties_attrs(ds_out, properties_from_attrs(source_dataset)) def _build_weight_matrix( diff --git a/src/mxalign/loaders/base.py b/src/mxalign/loaders/base.py index 3ab8bd9..c7020b5 100644 --- a/src/mxalign/loaders/base.py +++ b/src/mxalign/loaders/base.py @@ -3,7 +3,7 @@ from .registry import register_loader from ..properties.properties import Properties, Space, Time, Uncertainty from ..properties.validation import validate_dataset -from ..properties.utils import properties_to_attrs +from ..properties.utils import set_properties_attrs class BaseLoader(ABC): @@ -29,7 +29,7 @@ def load(self): properties = self._get_properties(ds) validate_dataset(ds, properties) - ds.attrs["properties"] = properties_to_attrs(properties) + ds = set_properties_attrs(ds, properties) if self.grid_mapping: ds = self._add_grid_mapping(ds) diff --git a/src/mxalign/properties/utils.py b/src/mxalign/properties/utils.py index 53555d3..78e2997 100644 --- a/src/mxalign/properties/utils.py +++ b/src/mxalign/properties/utils.py @@ -1,24 +1,49 @@ +import json + from .properties import Properties, Space, Time, Uncertainty from .validation import validate_time_dataset, validate_space_dataset +SPACE_ATTR = "properties.space" +TIME_ATTR = "properties.time" +UNCERTAINTY_ATTR = "properties.uncertainty" + def properties_to_attrs(prop: Properties) -> dict: return { - "space": prop.space.value, - "time": prop.time.value, - "uncertainty": prop.uncertainty.value, + SPACE_ATTR: prop.space.value, + TIME_ATTR: prop.time.value, + UNCERTAINTY_ATTR: prop.uncertainty.value, } def properties_from_attrs(ds) -> Properties: - attrs = ds.attrs.get("properties", {}) + attrs = ds.attrs + old_attrs = attrs.get("properties", {}) + if isinstance(old_attrs, str): + try: + old_attrs = json.loads(old_attrs) + except json.JSONDecodeError: + old_attrs = {} + if not isinstance(old_attrs, dict): + old_attrs = {} + + space = attrs.get(SPACE_ATTR, old_attrs.get("space")) + time = attrs.get(TIME_ATTR, old_attrs.get("time")) + uncertainty = attrs.get(UNCERTAINTY_ATTR, old_attrs.get("uncertainty")) + return Properties( - space=Space(attrs["space"]), - time=Time(attrs["time"]), - uncertainty=Uncertainty(attrs.get("uncertainty", Uncertainty.DETERMINISTIC)), + space=Space(space), + time=Time(time), + uncertainty=Uncertainty(uncertainty or Uncertainty.DETERMINISTIC), ) +def set_properties_attrs(ds, prop: Properties): + ds.attrs.update(properties_to_attrs(prop)) + ds.attrs.pop("properties", None) + return ds + + def update_space_property(ds, prop: Space): old_props = properties_from_attrs(ds) new_props = Properties( @@ -27,8 +52,7 @@ def update_space_property(ds, prop: Space): uncertainty=old_props.uncertainty, ) validate_space_dataset(ds, new_props) - ds.attrs["properties"] = properties_to_attrs(new_props) - return ds + return set_properties_attrs(ds, new_props) def update_time_property(ds, prop: Time): @@ -39,5 +63,4 @@ def update_time_property(ds, prop: Time): uncertainty=old_props.uncertainty, ) validate_time_dataset(ds, new_props) - ds.attrs["properties"] = properties_to_attrs(new_props) - return ds + return set_properties_attrs(ds, new_props) diff --git a/tests/test_properties_attrs.py b/tests/test_properties_attrs.py new file mode 100644 index 0000000..9a7e06f --- /dev/null +++ b/tests/test_properties_attrs.py @@ -0,0 +1,53 @@ +import json +import tempfile + +import xarray as xr + +from mxalign.properties.properties import Properties, Space, Time, Uncertainty +from mxalign.properties.utils import properties_from_attrs, set_properties_attrs + + +class TestPropertiesAttrs: + def test_properties_are_stored_in_netcdf_compatible_attrs(self): + ds = xr.Dataset() + props = Properties( + space=Space.POINT, + time=Time.OBSERVATION, + uncertainty=Uncertainty.DETERMINISTIC, + ) + + ds = set_properties_attrs(ds, props) + + assert "properties" not in ds.attrs + assert ds.attrs["properties.space"] == "point" + assert ds.attrs["properties.time"] == "observation" + assert ds.attrs["properties.uncertainty"] == "deterministic" + + with tempfile.NamedTemporaryFile(suffix=".nc") as tmp: + ds.to_netcdf(tmp.name) + with xr.open_dataset(tmp.name) as ds_loaded: + assert properties_from_attrs(ds_loaded) == props + + def test_properties_can_still_be_read_from_legacy_format(self): + ds = xr.Dataset() + ds.attrs["properties"] = { + "space": "point", + "time": "observation", + "uncertainty": "deterministic", + } + assert properties_from_attrs(ds) == Properties( + space=Space.POINT, + time=Time.OBSERVATION, + uncertainty=Uncertainty.DETERMINISTIC, + ) + + def test_properties_can_be_read_from_legacy_json_string(self): + ds = xr.Dataset() + ds.attrs["properties"] = json.dumps( + {"space": "point", "time": "observation", "uncertainty": "deterministic"} + ) + assert properties_from_attrs(ds) == Properties( + space=Space.POINT, + time=Time.OBSERVATION, + uncertainty=Uncertainty.DETERMINISTIC, + ) diff --git a/uv.lock b/uv.lock index 7e23d67..7d12970 100644 --- a/uv.lock +++ b/uv.lock @@ -577,6 +577,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/40/0a42c7441d76c373a7bef7ec1f535f26910a8c82a37e745ffcd3ea0cdf79/eccodes-2.45.0-py3-none-any.whl", hash = "sha256:0ba61dbd2844843f1fd466c8ca24107932cc40088338f6176428cf38c533c08c", size = 91433, upload-time = "2026-01-15T16:27:06.601Z" }, ] +[[package]] +name = "eccodeslib" +version = "2.46.2.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eckitlib" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/ab/eb96e7b9a69521e438f2a562e029e0452e80be93b8f4c8b32be2766913f9/eccodeslib-2.46.2.19-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:5cb46d9bda6324935539457282195f8fc5df38dae4ea7ff545ba76a9cd50cf5f", size = 8899990, upload-time = "2026-04-01T12:08:38.156Z" }, + { url = "https://files.pythonhosted.org/packages/98/fe/7c3bec98984b4e946431d13fddf2ebdd5436106a9f85c1a3938e7339c26d/eccodeslib-2.46.2.19-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:73880e83765b78b149b747549674cf264b2e7bf64532ab4e99eb1225cb8bb485", size = 8731401, upload-time = "2026-04-01T12:14:47.677Z" }, + { url = "https://files.pythonhosted.org/packages/11/e4/1cdffaa4abd303726a06bc9630b7197360df35d11c297c702bf8b6e83fdd/eccodeslib-2.46.2.19-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8174d1350e629a0a4e51fbf63703658b1ed2f7423e7cb083a2b539a7dcee80cb", size = 9093644, upload-time = "2026-04-01T12:15:16.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/2f83c12c9987040a1a165534e02e28a82acef9744552cba43e705a674689/eccodeslib-2.46.2.19-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cab8151ac204a45088d7789236183e8ff7a51fafd168be1488a0c10be886f0f4", size = 8986646, upload-time = "2026-04-01T12:37:42.601Z" }, + { url = "https://files.pythonhosted.org/packages/d3/12/e48ea960e13791b033c08b25045eeb8221df8beb3a9e58d0753bf5bd42fe/eccodeslib-2.46.2.19-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:f5fe6a0787381115929ba521d9dafae7cb9c0c9b72790e4f92db1a09a043625f", size = 8899996, upload-time = "2026-04-01T12:30:10.847Z" }, + { url = "https://files.pythonhosted.org/packages/01/8a/0826b05445c886e95fc46ac908afae28d28a06bb66f4f84dbbf710360a85/eccodeslib-2.46.2.19-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:a5ba7e609de866ed0f2d45b4785a8a870d21efb6e9e5a71e219d39e8fdeebdf0", size = 8731409, upload-time = "2026-04-01T12:14:59.447Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3b/758a073c4ed5b1c732f6a3b4a5ba7d8ca122d8408ebc7dd608e7e447c07c/eccodeslib-2.46.2.19-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:4500abf05428d94693dd29c589c6d208f2d48aff5efa145425c704fcd1f46925", size = 9093643, upload-time = "2026-04-01T12:15:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/d9/48/5e0e31e3cb310fbe04b08040e96d3107639ceef8a94f8788691af77beb54/eccodeslib-2.46.2.19-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0f14eb14f37af34bb79f43ba8ab8dff99d6d4876b6681614c6644a3cbbb89d6c", size = 8986651, upload-time = "2026-04-01T12:23:15.323Z" }, + { url = "https://files.pythonhosted.org/packages/d2/90/3a3180b227e6483e80e6631e8630d8942fc8bfc66b8e6acd9f5549c537c6/eccodeslib-2.46.2.19-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:d93146a2770d3169f91129193e995a0bd58ac933937e26909c65c347f36b94a6", size = 8900007, upload-time = "2026-04-01T12:20:31.911Z" }, + { url = "https://files.pythonhosted.org/packages/f4/91/901cb12b04f988420b7e75c914f7361ce5c23bff8c19e8b1d9c56657d7bc/eccodeslib-2.46.2.19-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:9c2da433e1d87ee4554b684c625234cbca14fa51181256b78a9d646a90f2fe22", size = 8731400, upload-time = "2026-04-01T12:52:59.854Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/ab3c19ffc2675514259ff61a8ea757fb2a470ad6052371384f2a5021046c/eccodeslib-2.46.2.19-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e62e4d893ec2939b6096c6aed21e31d38a979e33d50917611a1907b3ef0a2996", size = 9093646, upload-time = "2026-04-01T12:13:35.905Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/c8ab7aadf86d28149c636970704a923c161b81ce301bc0fc3da82ce3d737/eccodeslib-2.46.2.19-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5daa5a8290d10accf308a94678ebc85c8fd843e20f2d5d644559d4a691dd4af1", size = 8986650, upload-time = "2026-04-01T12:32:33.346Z" }, +] + +[[package]] +name = "eckitlib" +version = "2.0.7.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/23/269c1271d00596c4e68177c9dda5350e98a3ba19c0058421da9c870380f1/eckitlib-2.0.7.19-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:cfba9bf706755e649afea97de7597242fd8f4ecff1b72a26c9ef58eafb67857e", size = 2480050, upload-time = "2026-04-01T12:08:43.95Z" }, + { url = "https://files.pythonhosted.org/packages/00/38/7adb54bdcbce6642230c122515dea472f131e0ccdb0c3cc0fb350440c6bc/eckitlib-2.0.7.19-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:66bdd16dd1cf1bbeeea9cbeccc0cc1e28822057d5f104e43868dd7011717b172", size = 2610836, upload-time = "2026-04-01T12:14:53.294Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9f/9728cc577cbf84619f57da633e7ad3625fa5bef7ad05a990118530ed9f0e/eckitlib-2.0.7.19-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5639123c5a3c645d8bcd6bb2fd29eb321675c0af7624a369a8804798abc6364b", size = 7040518, upload-time = "2026-04-01T12:15:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f5/4b2c6ac00ea46d1e7f6c680979720d51e05bce6182c61df24b8a2a75326d/eckitlib-2.0.7.19-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:725d8abc7c5eb039788fd0df28aea7d2c0739ff53861f6d00ca9c319dbd321b0", size = 7169837, upload-time = "2026-04-01T12:37:49.842Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/9ccdf223a33c07fe27426c01dcc931316a3531881e4873c24d40e4f09dfd/eckitlib-2.0.7.19-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:5b6518c555f598fef15915c52d07797fe85aa9a09d89f6e9274c5f549c427fb1", size = 2480047, upload-time = "2026-04-01T12:30:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/f2/49/31d03b6fe1270e055c521d4a350fed77afd81abe5711f254058e4c631579/eckitlib-2.0.7.19-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:04c4ec2b96b9da37406925b418e752195e4f782d77148c4654f105984525dd0f", size = 2610842, upload-time = "2026-04-01T12:15:05.025Z" }, + { url = "https://files.pythonhosted.org/packages/32/13/bf8bcf039f81c7e74f68808c6fe1eb0ba57812aaa0e16f0f5644ef7f81ce/eckitlib-2.0.7.19-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13c7acfe19da5d25e9c5f7e6fb806deca39ac7adea31958d118799a58e8739bd", size = 7040512, upload-time = "2026-04-01T12:15:29.127Z" }, + { url = "https://files.pythonhosted.org/packages/b4/64/9dc3e3a669f251707487308d0e5efaa0e6755a4da8e3566b52a509658a05/eckitlib-2.0.7.19-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2876c3fd3b2c3140a4c5c3a95c63f6d04b874a6a2b62d0d06c8d6f22375c531", size = 7169835, upload-time = "2026-04-01T12:23:22.598Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b9/fa7908d4b208d1635c13c06ea96183a25c89373ee536a746f709efb5275b/eckitlib-2.0.7.19-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:f4605a53d939c24083af431e570466be14f42ff6ae7395a7fa5691768ea02e5b", size = 2480048, upload-time = "2026-04-01T12:20:37.276Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d3/77779e785dfa6f90fea78ece275cbdd43fc19da39bb84c1b09f1db528881/eckitlib-2.0.7.19-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:a34d28f3b8903527f1693327982ad4539c228c360c24bcd2b3e357ed9b267d0a", size = 2610838, upload-time = "2026-04-01T12:53:05.693Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/0a8b7de8471eb8cda8d4c5680e983e24434391e587b48234f6e92461e790/eckitlib-2.0.7.19-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54fefebd47b7cadef235b2d1082e34ad9aabaf1cbee97ca9f7e72f99ea69685c", size = 7040523, upload-time = "2026-04-01T12:13:40.089Z" }, + { url = "https://files.pythonhosted.org/packages/61/e2/af1b905dc937bbc44d92c3d6a15d2fdab4b2d228b99583952844a317f760/eckitlib-2.0.7.19-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2479b853bb7b1dc14d5c710a9803e08bc6b3784ed721deec9799dd51b59cce76", size = 7169842, upload-time = "2026-04-01T12:32:41.65Z" }, +] + [[package]] name = "entrypoints" version = "0.4" @@ -753,6 +794,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "ipykernel" version = "7.2.0" @@ -1251,6 +1301,11 @@ dependencies = [ earthkit = [ { name = "earthkit-meteo" }, ] +ifs = [ + { name = "cfgrib" }, + { name = "eccodes" }, + { name = "eccodeslib" }, +] jobqueue = [ { name = "dask-jobqueue" }, ] @@ -1261,17 +1316,21 @@ verification = [ [package.dev-dependencies] dev = [ { name = "ipykernel" }, + { name = "pytest" }, ] [package.metadata] requires-dist = [ { name = "bokeh", specifier = ">=3.8.2" }, { name = "cartopy", specifier = ">=0.25.0" }, + { name = "cfgrib", marker = "extra == 'ifs'", specifier = ">=0.9.15.1" }, { name = "dask", specifier = ">=2026.1.2" }, { name = "dask-jobqueue", marker = "extra == 'jobqueue'", specifier = ">=0.9.0" }, { name = "distributed", specifier = ">=2026.1.2" }, { name = "earthkit-data", specifier = ">=0.19.0" }, { name = "earthkit-meteo", marker = "extra == 'earthkit'", specifier = ">=0.6.1" }, + { name = "eccodes", marker = "extra == 'ifs'", specifier = ">=2.45.0" }, + { name = "eccodeslib", marker = "extra == 'ifs'", specifier = ">=2.46.2.19" }, { name = "h5netcdf", specifier = ">=1.8.1" }, { name = "h5py", specifier = ">=3.15.1" }, { name = "netcdf4", specifier = ">=1.7.4" }, @@ -1281,10 +1340,13 @@ requires-dist = [ { name = "xskillscore", marker = "extra == 'verification'", specifier = ">=0.0.29" }, { name = "zarr", specifier = "<3.0" }, ] -provides-extras = ["earthkit", "verification", "jobqueue"] +provides-extras = ["earthkit", "ifs", "verification", "jobqueue"] [package.metadata.requires-dev] -dev = [{ name = "ipykernel", specifier = ">=7.2.0" }] +dev = [ + { name = "ipykernel", specifier = ">=7.2.0" }, + { name = "pytest", specifier = ">=8.0.0" }, +] [[package]] name = "narwhals" @@ -1627,6 +1689,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/e3/1eddccb2c39ecfbe09b3add42a04abcc3fa5b468aa4224998ffb8a7e9c8f/platformdirs-4.7.0-py3-none-any.whl", hash = "sha256:1ed8db354e344c5bb6039cd727f096af975194b508e37177719d562b2b540ee6", size = 18983, upload-time = "2026-02-12T22:21:52.237Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -1790,6 +1861,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/06/cad54e8ce758bd836ee5411691cbd49efeb9cc611b374670fce299519334/pyshp-3.0.3-py3-none-any.whl", hash = "sha256:28c8fac8c0c25bb0fecbbfd10ead7f319c2ff2f3b0b44a94f22bd2c93510ad42", size = 58465, upload-time = "2025-11-28T17:47:30.328Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"